headless_lms_mcp/engine/course_structure/
fields.rs1use 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
11pub(crate) const MODULE_FIELDS: [&str; 1] = ["name"];
13pub(crate) const CHAPTER_FIELDS: [&str; 4] = ["name", "color", "opens_at", "deadline"];
15pub(crate) const PAGE_FIELDS: [&str; 3] = ["title", "url_path", "hidden"];
17pub(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];
27pub(crate) const COUNT_FIELDS: [&str; 3] = ["block_count", "exercise_count", "has_submissions"];
29
30impl Kind {
31 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
49pub(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
102pub(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}