Skip to main content

quillmark_core/quill/
config.rs

1//! Quill configuration parsing and normalization.
2use std::collections::{BTreeMap, HashMap};
3use std::error::Error as StdError;
4
5use indexmap::IndexMap;
6
7use serde::{Deserialize, Serialize};
8use time::format_description::well_known::Rfc3339;
9use time::{Date, OffsetDateTime};
10
11use crate::error::{Diagnostic, Severity};
12use crate::value::QuillValue;
13
14use super::formats::DATE_FORMAT;
15use super::{BodyCardSchema, CardSchema, FieldSchema, FieldType, UiCardSchema, UiFieldSchema};
16
17/// Top-level configuration for a Quillmark project
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct QuillConfig {
20    /// Quill package name
21    pub name: String,
22    /// Human-readable description of the quill itself (parsed from
23    /// `quill.description`). Distinct from `main.description`, which describes
24    /// the main card's schema.
25    pub description: String,
26    /// The entry-point card schema (parsed from the Quill.yaml `main:` section).
27    pub main: CardSchema,
28    /// Named, composable card-type schemas (parsed from the Quill.yaml
29    /// `card_types:` section). Does not include `main`.
30    pub card_types: Vec<CardSchema>,
31    /// Backend to use for rendering (e.g., "typst", "html")
32    pub backend: String,
33    /// Version of the Quillmark spec
34    pub version: String,
35    /// Author of the project
36    pub author: String,
37    /// Example data file for preview
38    pub example_file: Option<String>,
39    /// Loaded markdown example content from `Quill.example`/`Quill.example_file`
40    pub example_markdown: Option<String>,
41    /// Plate file (template)
42    pub plate_file: Option<String>,
43    /// Backend-specific configuration parsed from the top-level YAML section
44    /// whose key matches `backend` (e.g. `[typst]`, `[html]`).
45    #[serde(default)]
46    pub backend_config: HashMap<String, QuillValue>,
47}
48
49#[derive(Debug, Deserialize)]
50#[serde(deny_unknown_fields)]
51struct CardSchemaDef {
52    pub description: Option<String>,
53    // Declared so `deny_unknown_fields` accepts a `fields:` block on a card.
54    // Fields are re-parsed via `parse_fields_with_order` for ordering.
55    #[allow(dead_code)]
56    pub fields: Option<serde_json::Map<String, serde_json::Value>>,
57    pub ui: Option<UiCardSchema>,
58    pub body: Option<BodyCardSchema>,
59}
60
61#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
62pub enum CoercionError {
63    #[error("cannot coerce `{value}` to type `{target}` at `{path}`: {reason}")]
64    Uncoercible {
65        path: String,
66        value: String,
67        target: String,
68        reason: String,
69    },
70}
71
72impl QuillConfig {
73    /// Returns a named card-type schema by name.
74    pub fn card_type(&self, name: &str) -> Option<&CardSchema> {
75        self.card_types.iter().find(|card| card.name == name)
76    }
77
78    /// Full schema including `ui` hints.
79    ///
80    /// `main.fields` is prefixed with a required `QUILL` entry (`const = name@version`);
81    /// each `card_types[<name>].fields` is prefixed with a required `CARD` entry
82    /// (`const = <name>`). Identity (`name`, `version`, etc.) and the bundled
83    /// example live elsewhere on the host's metadata surface.
84    pub fn schema(&self) -> serde_json::Value {
85        let canonical_ref = format!("{}@{}", self.name, self.version);
86
87        let mut obj = serde_json::Map::new();
88
89        let mut main_value = serde_json::to_value(&self.main).unwrap_or(serde_json::Value::Null);
90        Self::prepend_sentinel_field(
91            &mut main_value,
92            "QUILL",
93            &canonical_ref,
94            "Canonical quill reference. Must be exactly this value as the QUILL: sentinel in the document frontmatter.",
95        );
96        obj.insert("main".to_string(), main_value);
97
98        if !self.card_types.is_empty() {
99            let card_types: BTreeMap<String, serde_json::Value> = self
100                .card_types
101                .iter()
102                .map(|card| {
103                    let mut card_value =
104                        serde_json::to_value(card).unwrap_or(serde_json::Value::Null);
105                    Self::prepend_sentinel_field(
106                        &mut card_value,
107                        "CARD",
108                        &card.name,
109                        "Card type name. Must be exactly this value as the CARD: sentinel in the card frontmatter.",
110                    );
111                    (card.name.clone(), card_value)
112                })
113                .collect();
114            obj.insert(
115                "card_types".to_string(),
116                serde_json::to_value(&card_types).unwrap_or(serde_json::Value::Null),
117            );
118        }
119
120        serde_json::Value::Object(obj)
121    }
122
123    /// Insert a `QUILL`/`CARD` sentinel as the first entry of a card's `fields`.
124    fn prepend_sentinel_field(
125        card_value: &mut serde_json::Value,
126        key: &str,
127        const_value: &str,
128        description: &str,
129    ) {
130        let sentinel = serde_json::json!({
131            "type": "string",
132            "const": const_value,
133            "description": description,
134            "required": true
135        });
136        if let Some(serde_json::Value::Object(fields)) = card_value.get_mut("fields") {
137            let existing = std::mem::take(fields);
138            fields.insert(key.to_string(), sentinel);
139            fields.extend(existing);
140        }
141    }
142
143    /// Coerce typed frontmatter fields (IndexMap, no CARDS/BODY keys).
144    pub fn coerce_frontmatter(
145        &self,
146        frontmatter: &IndexMap<String, QuillValue>,
147    ) -> Result<IndexMap<String, QuillValue>, CoercionError> {
148        let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
149        for (field_name, field_value) in frontmatter {
150            if let Some(field_schema) = self.main.fields.get(field_name) {
151                let path = field_name.as_str();
152                coerced.insert(
153                    field_name.clone(),
154                    Self::coerce_value_strict(field_value, field_schema, path)?,
155                );
156            } else {
157                coerced.insert(field_name.clone(), field_value.clone());
158            }
159        }
160        Ok(coerced)
161    }
162
163    /// Coerce typed fields for a single card (IndexMap, no CARD/BODY keys).
164    ///
165    /// Returns the input unchanged when the card tag is unknown.
166    pub fn coerce_card(
167        &self,
168        card_tag: &str,
169        fields: &IndexMap<String, QuillValue>,
170    ) -> Result<IndexMap<String, QuillValue>, CoercionError> {
171        let Some(card_schema) = self.card_type(card_tag) else {
172            return Ok(fields.clone());
173        };
174        let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
175        for (field_name, field_value) in fields {
176            if let Some(field_schema) = card_schema.fields.get(field_name) {
177                let path = format!("card_types.{card_tag}.{field_name}");
178                coerced.insert(
179                    field_name.clone(),
180                    Self::coerce_value_strict(field_value, field_schema, &path)?,
181                );
182            } else {
183                coerced.insert(field_name.clone(), field_value.clone());
184            }
185        }
186        Ok(coerced)
187    }
188
189    /// Validate a typed [`crate::document::Document`] against this configuration.
190    pub fn validate_document(
191        &self,
192        doc: &crate::document::Document,
193    ) -> Result<(), Vec<super::validation::ValidationError>> {
194        super::validation::validate_typed_document(self, doc)
195    }
196
197    fn coerce_value_strict(
198        value: &QuillValue,
199        field_schema: &super::FieldSchema,
200        path: &str,
201    ) -> Result<QuillValue, CoercionError> {
202        use super::FieldType;
203
204        let json_value = value.as_json();
205        match field_schema.r#type {
206            FieldType::Array => {
207                let arr = if let Some(a) = json_value.as_array() {
208                    a.clone()
209                } else {
210                    vec![json_value.clone()]
211                };
212
213                if let Some(items_schema) = &field_schema.items {
214                    let mut out = Vec::with_capacity(arr.len());
215                    for (idx, elem) in arr.iter().enumerate() {
216                        let item_path = format!("{path}[{idx}]");
217                        let coerced = Self::coerce_value_strict(
218                            &QuillValue::from_json(elem.clone()),
219                            items_schema,
220                            &item_path,
221                        )?;
222                        out.push(coerced.into_json());
223                    }
224                    Ok(QuillValue::from_json(serde_json::Value::Array(out)))
225                } else {
226                    Ok(QuillValue::from_json(serde_json::Value::Array(arr)))
227                }
228            }
229            FieldType::Boolean => {
230                if let Some(b) = json_value.as_bool() {
231                    return Ok(QuillValue::from_json(serde_json::Value::Bool(b)));
232                }
233                if let Some(s) = json_value.as_str() {
234                    let lower = s.to_lowercase();
235                    if lower == "true" {
236                        return Ok(QuillValue::from_json(serde_json::Value::Bool(true)));
237                    } else if lower == "false" {
238                        return Ok(QuillValue::from_json(serde_json::Value::Bool(false)));
239                    }
240                }
241                if let Some(n) = json_value.as_i64() {
242                    return Ok(QuillValue::from_json(serde_json::Value::Bool(n != 0)));
243                }
244                if let Some(n) = json_value.as_f64() {
245                    if n.is_nan() {
246                        return Ok(QuillValue::from_json(serde_json::Value::Bool(false)));
247                    }
248                    return Ok(QuillValue::from_json(serde_json::Value::Bool(
249                        n.abs() > f64::EPSILON,
250                    )));
251                }
252
253                Err(CoercionError::Uncoercible {
254                    path: path.to_string(),
255                    value: json_value.to_string(),
256                    target: "boolean".to_string(),
257                    reason: "value is not coercible to boolean".to_string(),
258                })
259            }
260            FieldType::Number => {
261                if json_value.is_number() {
262                    return Ok(value.clone());
263                }
264                if let Some(s) = json_value.as_str() {
265                    if let Ok(i) = s.parse::<i64>() {
266                        return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
267                    }
268                    if let Ok(f) = s.parse::<f64>() {
269                        if let Some(num) = serde_json::Number::from_f64(f) {
270                            return Ok(QuillValue::from_json(num.into()));
271                        }
272                    }
273                    return Err(CoercionError::Uncoercible {
274                        path: path.to_string(),
275                        value: s.to_string(),
276                        target: "number".to_string(),
277                        reason: "string is not a valid number".to_string(),
278                    });
279                }
280                if let Some(b) = json_value.as_bool() {
281                    let n = if b { 1 } else { 0 };
282                    return Ok(QuillValue::from_json(serde_json::Value::Number(
283                        serde_json::Number::from(n),
284                    )));
285                }
286
287                Err(CoercionError::Uncoercible {
288                    path: path.to_string(),
289                    value: json_value.to_string(),
290                    target: "number".to_string(),
291                    reason: "value is not coercible to number".to_string(),
292                })
293            }
294            FieldType::Integer => {
295                if let Some(i) = json_value.as_i64() {
296                    return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
297                }
298                if let Some(u) = json_value.as_u64() {
299                    if let Ok(i) = i64::try_from(u) {
300                        return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
301                    }
302                    return Err(CoercionError::Uncoercible {
303                        path: path.to_string(),
304                        value: json_value.to_string(),
305                        target: "integer".to_string(),
306                        reason: "integer value exceeds i64 range".to_string(),
307                    });
308                }
309                if let Some(s) = json_value.as_str() {
310                    if let Ok(i) = s.parse::<i64>() {
311                        return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
312                    }
313                    return Err(CoercionError::Uncoercible {
314                        path: path.to_string(),
315                        value: s.to_string(),
316                        target: "integer".to_string(),
317                        reason: "string is not a valid integer".to_string(),
318                    });
319                }
320                if let Some(b) = json_value.as_bool() {
321                    let n = if b { 1 } else { 0 };
322                    return Ok(QuillValue::from_json(serde_json::Value::Number(
323                        serde_json::Number::from(n),
324                    )));
325                }
326
327                Err(CoercionError::Uncoercible {
328                    path: path.to_string(),
329                    value: json_value.to_string(),
330                    target: "integer".to_string(),
331                    reason: "value is not coercible to integer".to_string(),
332                })
333            }
334            FieldType::String | FieldType::Markdown => {
335                if json_value.is_string() {
336                    return Ok(value.clone());
337                }
338                if let Some(arr) = json_value.as_array() {
339                    if arr.len() == 1 {
340                        if let Some(s) = arr[0].as_str() {
341                            return Ok(QuillValue::from_json(serde_json::Value::String(
342                                s.to_string(),
343                            )));
344                        }
345                    }
346                }
347                Ok(value.clone())
348            }
349            FieldType::Date | FieldType::DateTime => {
350                if json_value.is_null() {
351                    return Ok(QuillValue::from_json(serde_json::Value::Null));
352                }
353                let text = if let Some(s) = json_value.as_str() {
354                    if s.is_empty() {
355                        return Ok(QuillValue::from_json(serde_json::Value::Null));
356                    }
357                    s.to_string()
358                } else if let Some(arr) = json_value.as_array() {
359                    if arr.len() == 1 {
360                        if let Some(s) = arr[0].as_str() {
361                            s.to_string()
362                        } else {
363                            return Err(CoercionError::Uncoercible {
364                                path: path.to_string(),
365                                value: json_value.to_string(),
366                                target: field_schema.r#type.as_str().to_string(),
367                                reason: "value must be a string".to_string(),
368                            });
369                        }
370                    } else {
371                        return Err(CoercionError::Uncoercible {
372                            path: path.to_string(),
373                            value: json_value.to_string(),
374                            target: field_schema.r#type.as_str().to_string(),
375                            reason: "value must be a single string".to_string(),
376                        });
377                    }
378                } else {
379                    return Err(CoercionError::Uncoercible {
380                        path: path.to_string(),
381                        value: json_value.to_string(),
382                        target: field_schema.r#type.as_str().to_string(),
383                        reason: "value must be a string".to_string(),
384                    });
385                };
386
387                let valid = if field_schema.r#type == FieldType::Date {
388                    Date::parse(&text, &DATE_FORMAT).is_ok()
389                } else {
390                    OffsetDateTime::parse(&text, &Rfc3339).is_ok()
391                };
392
393                if valid {
394                    Ok(QuillValue::from_json(serde_json::Value::String(text)))
395                } else {
396                    Err(CoercionError::Uncoercible {
397                        path: path.to_string(),
398                        value: text,
399                        target: field_schema.r#type.as_str().to_string(),
400                        reason: "invalid date/datetime format".to_string(),
401                    })
402                }
403            }
404            FieldType::Object => {
405                if let Some(obj) = json_value.as_object() {
406                    if let Some(props) = &field_schema.properties {
407                        let mut coerced_obj = serde_json::Map::new();
408                        for (k, v) in obj {
409                            if let Some(prop_schema) = props.get(k) {
410                                let child_path = format!("{path}.{k}");
411                                coerced_obj.insert(
412                                    k.clone(),
413                                    Self::coerce_value_strict(
414                                        &QuillValue::from_json(v.clone()),
415                                        prop_schema,
416                                        &child_path,
417                                    )?
418                                    .into_json(),
419                                );
420                            } else {
421                                coerced_obj.insert(k.clone(), v.clone());
422                            }
423                        }
424                        Ok(QuillValue::from_json(serde_json::Value::Object(
425                            coerced_obj,
426                        )))
427                    } else {
428                        Ok(value.clone())
429                    }
430                } else {
431                    Ok(value.clone())
432                }
433            }
434        }
435    }
436
437    fn has_disallowed_nested_object(schema: &FieldSchema, allow_object_here: bool) -> bool {
438        if schema.r#type == FieldType::Object {
439            if !allow_object_here {
440                return true;
441            }
442            if let Some(props) = &schema.properties {
443                for prop_schema in props.values() {
444                    if Self::has_disallowed_nested_object(prop_schema, false) {
445                        return true;
446                    }
447                }
448            }
449        }
450
451        if schema.r#type == FieldType::Array {
452            if let Some(items_schema) = &schema.items {
453                return Self::has_disallowed_nested_object(items_schema, true);
454            }
455        }
456
457        false
458    }
459
460    /// Parse fields from a JSON Value map, assigning ui.order based on key_order.
461    ///
462    /// This helper ensures consistent field ordering logic for both top-level
463    /// fields and card fields.
464    ///
465    /// # Arguments
466    /// * `fields_map` - The JSON map containing field definitions
467    /// * `key_order` - Vector of field names in their definition order
468    /// * `context` - Context string for error messages (e.g., "field" or "card 'indorsement' field")
469    fn parse_fields_with_order(
470        fields_map: &serde_json::Map<String, serde_json::Value>,
471        key_order: &[String],
472        context: &str,
473        errors: &mut Vec<Diagnostic>,
474    ) -> BTreeMap<String, FieldSchema> {
475        let mut fields = BTreeMap::new();
476        let mut fallback_counter = 0;
477
478        for (field_name, field_value) in fields_map {
479            if !Self::is_snake_case_identifier(field_name) {
480                errors.push(
481                    Diagnostic::new(
482                        Severity::Error,
483                        format!(
484                            "Invalid {} '{}': field keys must be snake_case \
485                             (lowercase letters, digits, and underscores only), \
486                             and capitalized field keys are reserved.",
487                            context, field_name
488                        ),
489                    )
490                    .with_code("quill::invalid_field_name".to_string()),
491                );
492                continue;
493            }
494
495            // Determine order from key_order, or use fallback counter
496            let order = if let Some(idx) = key_order.iter().position(|k| k == field_name) {
497                idx as i32
498            } else {
499                let o = key_order.len() as i32 + fallback_counter;
500                fallback_counter += 1;
501                o
502            };
503
504            let quill_value = QuillValue::from_json(field_value.clone());
505            match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
506                Ok(mut schema) => {
507                    // Reject standalone object/dict fields — object is only valid inside array items.
508                    if schema.r#type == FieldType::Object {
509                        errors.push(
510                            Diagnostic::new(
511                                Severity::Error,
512                                format!(
513                                    "Field '{}' uses standalone type: object, which is not supported. \
514                                    Use separate fields with ui.group instead, or use \
515                                    type: array with items: {{type: object, properties: {{...}}}}.",
516                                    field_name
517                                ),
518                            )
519                            .with_code("quill::standalone_object_not_supported".to_string()),
520                        );
521                        continue;
522                    }
523
524                    if Self::has_disallowed_nested_object(&schema, false) {
525                        errors.push(
526                            Diagnostic::new(
527                                Severity::Error,
528                                format!(
529                                    "Field '{}' uses nested type: object, which is not supported. \
530                                    Only object schemas nested under array.items are supported.",
531                                    field_name
532                                ),
533                            )
534                            .with_code("quill::nested_object_not_supported".to_string()),
535                        );
536                        continue;
537                    }
538
539                    // Always set ui.order based on position
540                    if schema.ui.is_none() {
541                        schema.ui = Some(UiFieldSchema {
542                            title: None,
543                            group: None,
544                            order: Some(order),
545                            compact: None,
546                            multiline: None,
547                        });
548                    } else if let Some(ui) = &mut schema.ui {
549                        if ui.order.is_none() {
550                            ui.order = Some(order);
551                        }
552                    }
553
554                    fields.insert(field_name.clone(), schema);
555                }
556                Err(e) => {
557                    let hint = Self::field_parse_hint(field_value);
558                    let mut diag = Diagnostic::new(
559                        Severity::Error,
560                        format!("Failed to parse {} '{}': {}", context, field_name, e),
561                    )
562                    .with_code("quill::field_parse_error".to_string());
563                    if let Some(h) = hint {
564                        diag = diag.with_hint(h);
565                    }
566                    errors.push(diag);
567                }
568            }
569        }
570
571        fields
572    }
573
574    /// Produce an actionable hint for common field schema mistakes based on the raw value.
575    fn field_parse_hint(field_value: &serde_json::Value) -> Option<String> {
576        if let Some(obj) = field_value.as_object() {
577            if obj.contains_key("title") {
578                return Some(
579                    "'title' is not a valid field key; use 'description' instead.".to_string(),
580                );
581            }
582        }
583        None
584    }
585
586    fn is_snake_case_identifier(name: &str) -> bool {
587        let mut chars = name.chars();
588        match chars.next() {
589            Some(c) if c.is_ascii_lowercase() => {}
590            _ => return false,
591        }
592
593        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
594    }
595
596    fn is_valid_card_identifier(name: &str) -> bool {
597        let mut chars = name.chars();
598        match chars.next() {
599            Some(c) if c.is_ascii_lowercase() || c == '_' => {}
600            _ => return false,
601        }
602
603        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
604    }
605
606    fn is_valid_quill_name(name: &str) -> bool {
607        name == "__default__" || Self::is_snake_case_identifier(name)
608    }
609
610    /// Parse QuillConfig from YAML content
611    pub fn from_yaml(yaml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
612        match Self::from_yaml_with_warnings(yaml_content) {
613            Ok((config, _warnings)) => Ok(config),
614            Err(diags) => {
615                let msg = diags
616                    .iter()
617                    .map(|d| d.fmt_pretty())
618                    .collect::<Vec<_>>()
619                    .join("\n");
620                Err(msg.into())
621            }
622        }
623    }
624
625    /// Parse QuillConfig from YAML content while collecting non-fatal warnings.
626    ///
627    /// Returns `Ok((config, warnings))` on success, or `Err(errors)` containing all
628    /// parse/validation errors when the config is invalid. Errors are always collected
629    /// exhaustively — callers see every problem, not just the first.
630    pub fn from_yaml_with_warnings(
631        yaml_content: &str,
632    ) -> Result<(Self, Vec<Diagnostic>), Vec<Diagnostic>> {
633        let mut warnings: Vec<Diagnostic> = Vec::new();
634        let mut errors: Vec<Diagnostic> = Vec::new();
635
636        // Parse YAML into serde_json::Value via serde_saphyr
637        // Note: serde_json with "preserve_order" feature is required for this to work as expected
638        let quill_yaml_val: serde_json::Value = match serde_saphyr::from_str(yaml_content) {
639            Ok(v) => v,
640            Err(e) => {
641                return Err(vec![Diagnostic::new(
642                    Severity::Error,
643                    format!("Failed to parse Quill.yaml: {}", e),
644                )
645                .with_code("quill::yaml_parse_error".to_string())]);
646            }
647        };
648
649        // Extract [quill] section (required) — fail immediately if absent since all
650        // subsequent validation depends on it.
651        let quill_section = match quill_yaml_val.get("quill") {
652            Some(v) => v,
653            None => {
654                return Err(vec![Diagnostic::new(
655                    Severity::Error,
656                    "Missing required 'quill' section in Quill.yaml".to_string(),
657                )
658                .with_code("quill::missing_section".to_string())
659                .with_hint(
660                    "Add a 'quill:' section with name, backend, version, and description."
661                        .to_string(),
662                )]);
663            }
664        };
665
666        // Validate that no unknown keys appear in the [quill] section.
667        const KNOWN_QUILL_KEYS: &[&str] = &[
668            "name",
669            "backend",
670            "description",
671            "version",
672            "author",
673            "example",
674            "example_file",
675            "plate_file",
676            "ui",
677        ];
678        if let Some(quill_obj) = quill_section.as_object() {
679            for key in quill_obj.keys() {
680                if !KNOWN_QUILL_KEYS.contains(&key.as_str()) {
681                    errors.push(
682                        Diagnostic::new(
683                            Severity::Error,
684                            format!("Unknown key '{}' in 'quill:' section", key),
685                        )
686                        .with_code("quill::unknown_key".to_string())
687                        .with_hint(format!("Valid keys are: {}", KNOWN_QUILL_KEYS.join(", "))),
688                    );
689                }
690            }
691        }
692
693        // Extract required fields — collect all missing-field errors before returning.
694        let name = match quill_section.get("name").and_then(|v| v.as_str()) {
695            Some(n) => {
696                if !Self::is_valid_quill_name(n) {
697                    errors.push(
698                        Diagnostic::new(
699                            Severity::Error,
700                            format!(
701                                "Invalid Quill name '{}': quill.name must be snake_case \
702                                 (lowercase letters, digits, and underscores only).",
703                                n
704                            ),
705                        )
706                        .with_code("quill::invalid_name".to_string())
707                        .with_hint(format!(
708                            "Rename '{}' to '{}'",
709                            n,
710                            n.to_lowercase().replace('-', "_")
711                        )),
712                    );
713                }
714                n.to_string()
715            }
716            None => {
717                errors.push(
718                    Diagnostic::new(
719                        Severity::Error,
720                        "Missing required 'name' field in 'quill' section".to_string(),
721                    )
722                    .with_code("quill::missing_name".to_string())
723                    .with_hint(
724                        "Add 'name: your_quill_name' under the 'quill:' section.".to_string(),
725                    ),
726                );
727                String::new()
728            }
729        };
730
731        let backend = match quill_section.get("backend").and_then(|v| v.as_str()) {
732            Some(b) => b.to_string(),
733            None => {
734                errors.push(
735                    Diagnostic::new(
736                        Severity::Error,
737                        "Missing required 'backend' field in 'quill' section".to_string(),
738                    )
739                    .with_code("quill::missing_backend".to_string())
740                    .with_hint("Add 'backend: typst' (or another supported backend).".to_string()),
741                );
742                String::new()
743            }
744        };
745
746        let description = match quill_section.get("description").and_then(|v| v.as_str()) {
747            Some(d) if !d.trim().is_empty() => d.to_string(),
748            Some(_) => {
749                errors.push(
750                    Diagnostic::new(
751                        Severity::Error,
752                        "'description' field in 'quill' section cannot be empty".to_string(),
753                    )
754                    .with_code("quill::empty_description".to_string()),
755                );
756                String::new()
757            }
758            None => {
759                errors.push(
760                    Diagnostic::new(
761                        Severity::Error,
762                        "Missing required 'description' field in 'quill' section".to_string(),
763                    )
764                    .with_code("quill::missing_description".to_string())
765                    .with_hint("Add a brief 'description:' of what this quill is for.".to_string()),
766                );
767                String::new()
768            }
769        };
770
771        // Extract optional fields (now version is required)
772        let version = match quill_section.get("version") {
773            Some(version_val) => {
774                // Handle version as string or number (YAML might parse 1.0 as number)
775                let raw = if let Some(s) = version_val.as_str() {
776                    s.to_string()
777                } else if let Some(n) = version_val.as_f64() {
778                    n.to_string()
779                } else {
780                    errors.push(
781                        Diagnostic::new(
782                            Severity::Error,
783                            "Invalid 'version' field format".to_string(),
784                        )
785                        .with_code("quill::invalid_version".to_string())
786                        .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
787                    );
788                    String::new()
789                };
790                if !raw.is_empty() {
791                    use std::str::FromStr;
792                    if let Err(e) = crate::version::Version::from_str(&raw) {
793                        errors.push(
794                            Diagnostic::new(
795                                Severity::Error,
796                                format!("Invalid version '{}': {}", raw, e),
797                            )
798                            .with_code("quill::invalid_version".to_string())
799                            .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
800                        );
801                    }
802                }
803                raw
804            }
805            None => {
806                errors.push(
807                    Diagnostic::new(
808                        Severity::Error,
809                        "Missing required 'version' field in 'quill' section".to_string(),
810                    )
811                    .with_code("quill::missing_version".to_string())
812                    .with_hint("Add 'version: 1.0' under the 'quill:' section.".to_string()),
813                );
814                String::new()
815            }
816        };
817
818        let author = quill_section
819            .get("author")
820            .and_then(|v| v.as_str())
821            .map(|s| s.to_string())
822            .unwrap_or_else(|| "Unknown".to_string());
823
824        let example_file = quill_section
825            .get("example")
826            .and_then(|v| v.as_str())
827            .map(|s| s.to_string())
828            .or_else(|| {
829                quill_section
830                    .get("example_file")
831                    .and_then(|v| v.as_str())
832                    .map(|s| s.to_string())
833            });
834
835        let plate_file = quill_section
836            .get("plate_file")
837            .and_then(|v| v.as_str())
838            .map(|s| s.to_string());
839
840        let ui_section: Option<UiCardSchema> = match quill_section.get("ui").cloned() {
841            None => None,
842            Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
843                Ok(parsed) => Some(parsed),
844                Err(e) => {
845                    errors.push(
846                        Diagnostic::new(
847                            Severity::Error,
848                            format!("Invalid 'quill.ui' block: {}", e),
849                        )
850                        .with_code("quill::invalid_ui".to_string())
851                        .with_hint("Valid key under 'ui' is: title.".to_string()),
852                    );
853                    None
854                }
855            },
856        };
857
858        // Extract optional backend-specific section (keyed by `quill.backend`).
859        let mut backend_config = HashMap::new();
860        if !backend.is_empty() {
861            if let Some(section_val) = quill_yaml_val.get(&backend) {
862                if let Some(table) = section_val.as_object() {
863                    for (key, value) in table {
864                        backend_config.insert(key.clone(), QuillValue::from_json(value.clone()));
865                    }
866                }
867            }
868        }
869
870        // Reject unknown top-level sections. Known sections are: quill, main, card_types,
871        // and the backend name (e.g. typst). Everything else is a mistake. `fields` gets
872        // a targeted hint since it's the most common shape mistake.
873        if let Some(top_obj) = quill_yaml_val.as_object() {
874            for key in top_obj.keys() {
875                let is_known = key == "quill"
876                    || key == "main"
877                    || key == "card_types"
878                    || (!backend.is_empty() && key == &backend);
879                if is_known {
880                    continue;
881                }
882
883                let mut diag = Diagnostic::new(
884                    Severity::Error,
885                    format!("Unknown top-level section '{}'", key),
886                )
887                .with_code("quill::unknown_section".to_string());
888
889                diag = if key == "fields" {
890                    diag.with_hint(
891                        "Root-level `fields` is not supported; use `main.fields` instead."
892                            .to_string(),
893                    )
894                } else {
895                    diag.with_hint(format!(
896                        "Valid top-level sections are: quill, main, card_types{}",
897                        if backend.is_empty() {
898                            String::new()
899                        } else {
900                            format!(", {}", backend)
901                        }
902                    ))
903                };
904
905                errors.push(diag);
906            }
907        }
908
909        let main_obj_opt = quill_yaml_val.get("main").and_then(|v| v.as_object());
910
911        // Extract main.fields (optional)
912        let fields = if let Some(main_obj) = main_obj_opt {
913            if let Some(fields_val) = main_obj.get("fields") {
914                if let Some(fields_map) = fields_val.as_object() {
915                    // With preserve_order feature, keys iterator respects insertion order
916                    let field_order: Vec<String> = fields_map.keys().cloned().collect();
917                    Self::parse_fields_with_order(
918                        fields_map,
919                        &field_order,
920                        "field schema",
921                        &mut errors,
922                    )
923                } else {
924                    BTreeMap::new()
925                }
926            } else {
927                BTreeMap::new()
928            }
929        } else {
930            BTreeMap::new()
931        };
932
933        // Extract main.ui (optional). Fail loudly on malformed UI metadata rather
934        // than silently dropping it — see `quill.ui` handling above.
935        let main_ui: Option<UiCardSchema> = match main_obj_opt
936            .and_then(|main_obj| main_obj.get("ui"))
937            .cloned()
938        {
939            None => None,
940            Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
941                Ok(parsed) => Some(parsed),
942                Err(e) => {
943                    errors.push(
944                        Diagnostic::new(Severity::Error, format!("Invalid 'main.ui' block: {}", e))
945                            .with_code("quill::invalid_ui".to_string())
946                            .with_hint("Valid key under 'ui' is: title.".to_string()),
947                    );
948                    None
949                }
950            },
951        };
952
953        // Extract main.body (optional). Fail loudly on malformed body metadata.
954        let main_body: Option<BodyCardSchema> = match main_obj_opt
955            .and_then(|main_obj| main_obj.get("body"))
956            .cloned()
957        {
958            None => None,
959            Some(v) => match serde_json::from_value::<BodyCardSchema>(v) {
960                Ok(parsed) => Some(parsed),
961                Err(e) => {
962                    errors.push(
963                        Diagnostic::new(
964                            Severity::Error,
965                            format!("Invalid 'main.body' block: {}", e),
966                        )
967                        .with_code("quill::invalid_body".to_string())
968                        .with_hint(
969                            "Valid keys under 'body' are: enabled, description.".to_string(),
970                        ),
971                    );
972                    None
973                }
974            },
975        };
976
977        // Extract main.description (optional, authored under `main:` like any
978        // other card type). This is independent of `quill.description`.
979        let main_description = main_obj_opt
980            .and_then(|main_obj| main_obj.get("description"))
981            .and_then(|v| v.as_str())
982            .map(|s| s.to_string());
983
984        // The main entry-point card.
985        let main = CardSchema {
986            name: "main".to_string(),
987            description: main_description,
988            fields,
989            ui: main_ui.or(ui_section),
990            body: main_body,
991        };
992
993        // Extract [card_types] section (optional)
994        let mut card_types: Vec<CardSchema> = Vec::new();
995        if let Some(card_types_val) = quill_yaml_val.get("card_types") {
996            match card_types_val.as_object() {
997                None => {
998                    errors.push(
999                        Diagnostic::new(
1000                            Severity::Error,
1001                            "'card_types' section must be an object (mapping of type names to schemas)".to_string(),
1002                        )
1003                        .with_code("quill::invalid_card_types".to_string()),
1004                    );
1005                }
1006                Some(card_types_table) => {
1007                    for (card_name, card_value) in card_types_table {
1008                        if !Self::is_valid_card_identifier(card_name) {
1009                            errors.push(
1010                                Diagnostic::new(
1011                                    Severity::Error,
1012                                    format!(
1013                                        "Invalid card-type name '{}': names must match \
1014                                         [a-z_][a-z0-9_]* (lowercase letters, digits, and underscores only).",
1015                                        card_name
1016                                    ),
1017                                )
1018                                .with_code("quill::invalid_card_name".to_string()),
1019                            );
1020                            continue;
1021                        }
1022
1023                        // Parse card basic info using serde
1024                        let card_def: CardSchemaDef =
1025                            match serde_json::from_value(card_value.clone()) {
1026                                Ok(d) => d,
1027                                Err(e) => {
1028                                    errors.push(
1029                                        Diagnostic::new(
1030                                            Severity::Error,
1031                                            format!(
1032                                                "Failed to parse card_type '{}': {}",
1033                                                card_name, e
1034                                            ),
1035                                        )
1036                                        .with_code("quill::invalid_card_schema".to_string()),
1037                                    );
1038                                    continue;
1039                                }
1040                            };
1041
1042                        // Parse card fields
1043                        let card_fields = if let Some(card_fields_table) =
1044                            card_value.get("fields").and_then(|v| v.as_object())
1045                        {
1046                            let card_field_order: Vec<String> =
1047                                card_fields_table.keys().cloned().collect();
1048                            Self::parse_fields_with_order(
1049                                card_fields_table,
1050                                &card_field_order,
1051                                &format!("card_type '{}' field", card_name),
1052                                &mut errors,
1053                            )
1054                        } else {
1055                            BTreeMap::new()
1056                        };
1057
1058                        card_types.push(CardSchema {
1059                            name: card_name.clone(),
1060                            description: card_def.description,
1061                            fields: card_fields,
1062                            ui: card_def.ui,
1063                            body: card_def.body,
1064                        });
1065                    }
1066                }
1067            }
1068        }
1069
1070        // Warn when `body.description` is set together with `body.enabled: false` —
1071        // the description has no effect since the body editor is disabled.
1072        let warn_description_unused = |label: &str,
1073                                       body: &Option<BodyCardSchema>|
1074         -> Option<Diagnostic> {
1075            let body = body.as_ref()?;
1076            if body.enabled == Some(false) && body.description.is_some() {
1077                Some(
1078                    Diagnostic::new(
1079                        Severity::Warning,
1080                        format!(
1081                            "`{label}.body.description` is set but `{label}.body.enabled` is false; the description will have no effect"
1082                        ),
1083                    )
1084                    .with_code("quill::body_description_unused".to_string())
1085                    .with_hint(
1086                        "Set `body.enabled: true` to surface the description, or remove `body.description`."
1087                            .to_string(),
1088                    ),
1089                )
1090            } else {
1091                None
1092            }
1093        };
1094        if let Some(d) = warn_description_unused("main", &main.body) {
1095            warnings.push(d);
1096        }
1097        for card in &card_types {
1098            if let Some(d) =
1099                warn_description_unused(&format!("card_types.{}", card.name), &card.body)
1100            {
1101                warnings.push(d);
1102            }
1103        }
1104
1105        if !errors.is_empty() {
1106            return Err(errors);
1107        }
1108
1109        Ok((
1110            QuillConfig {
1111                name,
1112                description,
1113                main,
1114                card_types,
1115                backend,
1116                version,
1117                author,
1118                example_file,
1119                example_markdown: None,
1120                plate_file,
1121                backend_config,
1122            },
1123            warnings,
1124        ))
1125    }
1126}