Skip to main content

headless_lms_mcp/engine/course_structure/
fields.rs

1//! The fields ops may set on modules, chapters and pages, and their value checks.
2
3use chrono::{DateTime, Utc};
4use serde_json::Value;
5
6use super::model::Kind;
7use crate::engine::course_settings::EDITABLE_MODULE_FIELDS;
8use crate::engine::error::{EditError, EditErrorCode};
9use crate::engine::non_empty_string;
10
11/// Module fields ops may set.
12pub(crate) const MODULE_FIELDS: [&str; 1] = ["name"];
13/// Chapter fields ops may set.
14pub(crate) const CHAPTER_FIELDS: [&str; 4] = ["name", "color", "opens_at", "deadline"];
15/// Page fields ops may set.
16pub(crate) const PAGE_FIELDS: [&str; 3] = ["title", "url_path", "hidden"];
17/// Ids and fields the server sets from the order and nesting of the tree.
18pub(crate) const DERIVED_FIELDS: [&str; 7] = [
19    "id",
20    "chapter_number",
21    "order_number",
22    "course_module_id",
23    "chapter_id",
24    "front_page_id",
25    "default",
26];
27/// Page fields computed from the page's content and its students' activity.
28pub(crate) const COUNT_FIELDS: [&str; 3] = ["block_count", "exercise_count", "has_submissions"];
29
30impl Kind {
31    /// The fields ops may set.
32    pub(super) fn editable_fields(self) -> &'static [&'static str] {
33        match self {
34            Kind::Module => &MODULE_FIELDS,
35            Kind::Chapter => &CHAPTER_FIELDS,
36            Kind::Page => &PAGE_FIELDS,
37        }
38    }
39}
40
41pub(super) fn check_field(kind: Kind, field: &str) -> Result<(), EditError> {
42    if kind.editable_fields().contains(&field) {
43        Ok(())
44    } else {
45        Err(field_error(kind, field))
46    }
47}
48
49/// Why `field` can't be set on a `kind`, teaching where it changes instead.
50pub(super) fn field_error(kind: Kind, field: &str) -> EditError {
51    if DERIVED_FIELDS.contains(&field) {
52        let fix = match field {
53            "chapter_number" | "order_number" => {
54                "change the order with move or reorder; numbers follow the order"
55            }
56            "course_module_id" => "move the chapter into another module's chapters list",
57            "chapter_id" => "move the page into another chapter's pages list",
58            "front_page_id" => {
59                "move a page to the start of the chapter's pages to make it the front page"
60            }
61            _ => "ids and derived values are set by the server",
62        };
63        return EditError::new(
64            EditErrorCode::DerivedField,
65            format!("{field} is derived and can't be set"),
66        )
67        .with_fix(fix);
68    }
69    if COUNT_FIELDS.contains(&field) {
70        return EditError::new(
71            EditErrorCode::ReadOnlyField,
72            format!("{field} is computed from the page's content"),
73        );
74    }
75    if kind == Kind::Module && EDITABLE_MODULE_FIELDS.contains(&field) {
76        return EditError::new(
77            EditErrorCode::WrongTool,
78            format!("module {field} is a setting; change it with patch_course_settings"),
79        );
80    }
81    if matches!(field, "content" | "blocks") && kind == Kind::Page {
82        return EditError::new(
83            EditErrorCode::WrongTool,
84            "page content is edited with patch_page_content",
85        );
86    }
87    unknown_field(kind, field)
88}
89
90pub(super) fn unknown_field(kind: Kind, field: &str) -> EditError {
91    EditError::new(
92        EditErrorCode::UnknownField,
93        format!("a {} has no editable field \"{field}\"", kind.noun()),
94    )
95    .with_fix(format!(
96        "{} fields: {}",
97        kind.noun(),
98        kind.editable_fields().join(", ")
99    ))
100}
101
102/// `value` as a non-empty string; `field` names it when null, `what` otherwise.
103pub(super) fn required_text(value: Value, field: &str, what: &str) -> Result<String, EditError> {
104    match value {
105        Value::Null => Err(not_nullable(field)),
106        value => non_empty_string(Some(&value), what),
107    }
108}
109
110pub(super) fn not_nullable(field: &str) -> EditError {
111    EditError::new(
112        EditErrorCode::InvalidValue,
113        format!("{field} can't be empty"),
114    )
115    .with_fix(format!("replace {field} with a new value instead"))
116}
117
118pub(super) fn optional_color(value: Option<&Value>) -> Result<Option<String>, EditError> {
119    match value {
120        None | Some(Value::Null) => Ok(None),
121        Some(Value::String(color)) if is_hex_color(color) => Ok(Some(color.to_uppercase())),
122        Some(_) => Err(EditError::new(
123            EditErrorCode::InvalidValue,
124            "color is a hex colour like \"#1F6964\", or null",
125        )),
126    }
127}
128
129pub(super) fn is_hex_color(color: &str) -> bool {
130    color.len() == 7 && color.starts_with('#') && color[1..].chars().all(|c| c.is_ascii_hexdigit())
131}
132
133pub(super) fn optional_date(
134    value: Option<&Value>,
135    field: &str,
136) -> Result<Option<DateTime<Utc>>, EditError> {
137    match value {
138        None | Some(Value::Null) => Ok(None),
139        Some(Value::String(raw)) => DateTime::parse_from_rfc3339(raw)
140            .map(|d| Some(d.with_timezone(&Utc)))
141            .map_err(|_| {
142                EditError::new(
143                    EditErrorCode::InvalidValue,
144                    format!("{field} is an RFC 3339 time like \"2027-02-01T08:00:00Z\", or null"),
145                )
146            }),
147        Some(_) => Err(EditError::new(
148            EditErrorCode::InvalidValue,
149            format!("{field} is a time string or null"),
150        )),
151    }
152}