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::{BodyLeafSchema, LeafSchema, FieldSchema, FieldType, UiLeafSchema, 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 leaf's schema.
25    pub description: String,
26    /// The entry-point leaf schema (parsed from the Quill.yaml `main:` section).
27    pub main: LeafSchema,
28    /// Named, composable leaf-type schemas (parsed from the Quill.yaml
29    /// `leaf_kinds:` section). Does not include `main`.
30    pub leaf_kinds: Vec<LeafSchema>,
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 LeafSchemaDef {
52    pub description: Option<String>,
53    // Declared so `deny_unknown_fields` accepts a `fields:` block on a leaf.
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<UiLeafSchema>,
58    pub body: Option<BodyLeafSchema>,
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 leaf-type schema by name.
74    pub fn leaf_kind(&self, name: &str) -> Option<&LeafSchema> {
75        self.leaf_kinds.iter().find(|leaf| leaf.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 `leaf_kinds[<name>].fields` is prefixed with a required `KIND` 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.leaf_kinds.is_empty() {
99            let leaf_kinds: BTreeMap<String, serde_json::Value> = self
100                .leaf_kinds
101                .iter()
102                .map(|leaf| {
103                    let mut leaf_value =
104                        serde_json::to_value(leaf).unwrap_or(serde_json::Value::Null);
105                    Self::prepend_sentinel_field(
106                        &mut leaf_value,
107                        "KIND",
108                        &leaf.name,
109                        "Leaf kind name. Must be exactly this value as the KIND: sentinel in the leaf body.",
110                    );
111                    (leaf.name.clone(), leaf_value)
112                })
113                .collect();
114            obj.insert(
115                "leaf_kinds".to_string(),
116                serde_json::to_value(&leaf_kinds).unwrap_or(serde_json::Value::Null),
117            );
118        }
119
120        serde_json::Value::Object(obj)
121    }
122
123    /// Insert a `QUILL`/`KIND` sentinel as the first entry of a leaf's `fields`.
124    fn prepend_sentinel_field(
125        leaf_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)) = leaf_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 LEAVES/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 leaf (IndexMap, no KIND/BODY keys).
164    ///
165    /// Returns the input unchanged when the leaf tag is unknown.
166    pub fn coerce_leaf(
167        &self,
168        leaf_tag: &str,
169        fields: &IndexMap<String, QuillValue>,
170    ) -> Result<IndexMap<String, QuillValue>, CoercionError> {
171        let Some(leaf_schema) = self.leaf_kind(leaf_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) = leaf_schema.fields.get(field_name) {
177                let path = format!("leaf_kinds.{leaf_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(props) = &field_schema.properties {
214                    let mut out = Vec::with_capacity(arr.len());
215                    for (idx, elem) in arr.iter().enumerate() {
216                        if let Some(obj) = elem.as_object() {
217                            let coerced_obj =
218                                Self::coerce_object_props(obj, props, &format!("{path}[{idx}]"))?;
219                            out.push(serde_json::Value::Object(coerced_obj));
220                        } else {
221                            out.push(elem.clone());
222                        }
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 coerced_obj = Self::coerce_object_props(obj, props, path)?;
408                        Ok(QuillValue::from_json(serde_json::Value::Object(
409                            coerced_obj,
410                        )))
411                    } else {
412                        Ok(value.clone())
413                    }
414                } else {
415                    Ok(value.clone())
416                }
417            }
418        }
419    }
420
421    /// Walk `obj`'s keys, coercing any that match `props` against the matching
422    /// schema and copying any others through verbatim. `parent_path` is the
423    /// breadcrumb for the enclosing scope (e.g. `"foo[3]"` or `"foo"`); each
424    /// child's path is `"{parent_path}.{k}"`.
425    fn coerce_object_props(
426        obj: &serde_json::Map<String, serde_json::Value>,
427        props: &std::collections::BTreeMap<String, Box<super::FieldSchema>>,
428        parent_path: &str,
429    ) -> Result<serde_json::Map<String, serde_json::Value>, CoercionError> {
430        let mut out = serde_json::Map::new();
431        for (k, v) in obj {
432            if let Some(prop_schema) = props.get(k) {
433                let child_path = format!("{parent_path}.{k}");
434                out.insert(
435                    k.clone(),
436                    Self::coerce_value_strict(
437                        &QuillValue::from_json(v.clone()),
438                        prop_schema,
439                        &child_path,
440                    )?
441                    .into_json(),
442                );
443            } else {
444                out.insert(k.clone(), v.clone());
445            }
446        }
447        Ok(out)
448    }
449
450    fn has_disallowed_nested_object(schema: &FieldSchema, allow_object_here: bool) -> bool {
451        if schema.r#type == FieldType::Object {
452            if !allow_object_here {
453                return true;
454            }
455            if let Some(props) = &schema.properties {
456                for prop_schema in props.values() {
457                    if Self::has_disallowed_nested_object(prop_schema, false) {
458                        return true;
459                    }
460                }
461            }
462        }
463
464        if schema.r#type == FieldType::Array {
465            if let Some(props) = &schema.properties {
466                for prop_schema in props.values() {
467                    if Self::has_disallowed_nested_object(prop_schema, false) {
468                        return true;
469                    }
470                }
471            }
472        }
473
474        false
475    }
476
477    /// Reject multi-line descriptions. Single-line is required so the leading
478    /// `# <description>` blueprint slot stays one line and the field-comment
479    /// stack remains parseable for LLM consumers.
480    fn validate_description_singleline(
481        desc: Option<&str>,
482        owner_label: &str,
483        errors: &mut Vec<Diagnostic>,
484    ) {
485        if let Some(d) = desc {
486            if d.contains('\n') {
487                errors.push(
488                    Diagnostic::new(
489                        Severity::Error,
490                        format!(
491                            "{} description must be a single line; multi-line \
492                             descriptions are not allowed.",
493                            owner_label
494                        ),
495                    )
496                    .with_code("quill::description_multiline".to_string()),
497                );
498            }
499        }
500    }
501
502    /// Reject `>`, `;`, `|` in enum literals. These characters are reserved by
503    /// the blueprint inline annotation grammar (`<format>` close, role
504    /// separator, enum value separator) and have no escape syntax.
505    fn validate_enum_literals(
506        field: &FieldSchema,
507        owner_label: &str,
508        errors: &mut Vec<Diagnostic>,
509    ) {
510        if let Some(values) = &field.enum_values {
511            for v in values {
512                if v.contains('>') || v.contains(';') || v.contains('|') {
513                    errors.push(
514                        Diagnostic::new(
515                            Severity::Error,
516                            format!(
517                                "{} enum value '{}' contains a reserved character \
518                                 ('>', ';', or '|') that conflicts with the \
519                                 blueprint inline annotation grammar.",
520                                owner_label, v
521                            ),
522                        )
523                        .with_code("quill::format_literal_reserved_char".to_string()),
524                    );
525                }
526            }
527        }
528    }
529
530    /// Recursively validate field-level blueprint constraints across the field
531    /// and any nested object properties.
532    fn validate_field_blueprint_constraints(
533        schema: &FieldSchema,
534        owner_label: &str,
535        errors: &mut Vec<Diagnostic>,
536    ) {
537        Self::validate_description_singleline(schema.description.as_deref(), owner_label, errors);
538        Self::validate_enum_literals(schema, owner_label, errors);
539        if let Some(props) = &schema.properties {
540            for (name, prop) in props {
541                let nested = format!("{}.{}", owner_label, name);
542                Self::validate_field_blueprint_constraints(prop, &nested, errors);
543            }
544        }
545    }
546
547    /// Parse fields from a JSON Value map, assigning ui.order based on key_order.
548    ///
549    /// This helper ensures consistent field ordering logic for both top-level
550    /// fields and leaf fields.
551    ///
552    /// # Arguments
553    /// * `fields_map` - The JSON map containing field definitions
554    /// * `key_order` - Vector of field names in their definition order
555    /// * `context` - Context string for error messages (e.g., "field" or "leaf 'indorsement' field")
556    fn parse_fields_with_order(
557        fields_map: &serde_json::Map<String, serde_json::Value>,
558        key_order: &[String],
559        context: &str,
560        errors: &mut Vec<Diagnostic>,
561    ) -> BTreeMap<String, FieldSchema> {
562        let mut fields = BTreeMap::new();
563        let mut fallback_counter = 0;
564
565        for (field_name, field_value) in fields_map {
566            if !Self::is_snake_case_identifier(field_name) {
567                errors.push(
568                    Diagnostic::new(
569                        Severity::Error,
570                        format!(
571                            "Invalid {} '{}': field keys must be snake_case \
572                             (lowercase letters, digits, and underscores only), \
573                             and capitalized field keys are reserved.",
574                            context, field_name
575                        ),
576                    )
577                    .with_code("quill::invalid_field_name".to_string()),
578                );
579                continue;
580            }
581
582            // Determine order from key_order, or use fallback counter
583            let order = if let Some(idx) = key_order.iter().position(|k| k == field_name) {
584                idx as i32
585            } else {
586                let o = key_order.len() as i32 + fallback_counter;
587                fallback_counter += 1;
588                o
589            };
590
591            let quill_value = QuillValue::from_json(field_value.clone());
592            match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
593                Ok(mut schema) => {
594                    // Typed dictionaries (type: object with properties) are supported.
595                    // Freeform objects (no properties) and objects nested inside
596                    // typed-dictionary properties are not.
597                    if schema.r#type == FieldType::Object {
598                        if schema.properties.is_none() {
599                            errors.push(
600                                Diagnostic::new(
601                                    Severity::Error,
602                                    format!(
603                                        "Field '{}' has type: object but no properties defined. \
604                                        Declare a properties map, or use type: array with \
605                                        a properties map for a list of objects.",
606                                        field_name
607                                    ),
608                                )
609                                .with_code("quill::object_missing_properties".to_string()),
610                            );
611                            continue;
612                        }
613                        // Properties of a typed dictionary may not themselves be objects.
614                        if Self::has_disallowed_nested_object(&schema, true) {
615                            errors.push(
616                                Diagnostic::new(
617                                    Severity::Error,
618                                    format!(
619                                        "Field '{}' contains a nested type: object property, \
620                                        which is not supported. Properties of a typed dictionary \
621                                        may not themselves be objects.",
622                                        field_name
623                                    ),
624                                )
625                                .with_code("quill::nested_object_not_supported".to_string()),
626                            );
627                            continue;
628                        }
629                        // Typed dictionary — fall through to normal processing.
630                    } else if Self::has_disallowed_nested_object(&schema, false) {
631                        errors.push(
632                            Diagnostic::new(
633                                Severity::Error,
634                                format!(
635                                    "Field '{}' uses nested type: object, which is not supported. \
636                                    Use type: array with a properties map for a list of objects.",
637                                    field_name
638                                ),
639                            )
640                            .with_code("quill::nested_object_not_supported".to_string()),
641                        );
642                        continue;
643                    }
644
645                    // Always set ui.order based on position
646                    if schema.ui.is_none() {
647                        schema.ui = Some(UiFieldSchema {
648                            title: None,
649                            group: None,
650                            order: Some(order),
651                            compact: None,
652                            multiline: None,
653                        });
654                    } else if let Some(ui) = &mut schema.ui {
655                        if ui.order.is_none() {
656                            ui.order = Some(order);
657                        }
658                    }
659
660                    let owner = format!("{} '{}'", context, field_name);
661                    Self::validate_field_blueprint_constraints(&schema, &owner, errors);
662
663                    fields.insert(field_name.clone(), schema);
664                }
665                Err(e) => {
666                    let hint = Self::field_parse_hint(field_value);
667                    let mut diag = Diagnostic::new(
668                        Severity::Error,
669                        format!("Failed to parse {} '{}': {}", context, field_name, e),
670                    )
671                    .with_code("quill::field_parse_error".to_string());
672                    if let Some(h) = hint {
673                        diag = diag.with_hint(h);
674                    }
675                    errors.push(diag);
676                }
677            }
678        }
679
680        fields
681    }
682
683    /// Produce an actionable hint for common field schema mistakes based on the raw value.
684    fn field_parse_hint(field_value: &serde_json::Value) -> Option<String> {
685        if let Some(obj) = field_value.as_object() {
686            if obj.contains_key("title") {
687                return Some(
688                    "'title' is not a valid field key; use 'description' instead.".to_string(),
689                );
690            }
691        }
692        None
693    }
694
695    fn is_snake_case_identifier(name: &str) -> bool {
696        let mut chars = name.chars();
697        match chars.next() {
698            Some(c) if c.is_ascii_lowercase() => {}
699            _ => return false,
700        }
701
702        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
703    }
704
705    fn is_valid_quill_name(name: &str) -> bool {
706        name == "__default__" || Self::is_snake_case_identifier(name)
707    }
708
709    /// Parse QuillConfig from YAML content
710    pub fn from_yaml(yaml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
711        match Self::from_yaml_with_warnings(yaml_content) {
712            Ok((config, _warnings)) => Ok(config),
713            Err(diags) => {
714                let msg = diags
715                    .iter()
716                    .map(|d| d.fmt_pretty())
717                    .collect::<Vec<_>>()
718                    .join("\n");
719                Err(msg.into())
720            }
721        }
722    }
723
724    /// Parse QuillConfig from YAML content while collecting non-fatal warnings.
725    ///
726    /// Returns `Ok((config, warnings))` on success, or `Err(errors)` containing all
727    /// parse/validation errors when the config is invalid. Errors are always collected
728    /// exhaustively — callers see every problem, not just the first.
729    pub fn from_yaml_with_warnings(
730        yaml_content: &str,
731    ) -> Result<(Self, Vec<Diagnostic>), Vec<Diagnostic>> {
732        let mut warnings: Vec<Diagnostic> = Vec::new();
733        let mut errors: Vec<Diagnostic> = Vec::new();
734
735        // Parse YAML into serde_json::Value via serde_saphyr
736        // Note: serde_json with "preserve_order" feature is required for this to work as expected
737        let quill_yaml_val: serde_json::Value = match serde_saphyr::from_str(yaml_content) {
738            Ok(v) => v,
739            Err(e) => {
740                return Err(vec![Diagnostic::new(
741                    Severity::Error,
742                    format!("Failed to parse Quill.yaml: {}", e),
743                )
744                .with_code("quill::yaml_parse_error".to_string())]);
745            }
746        };
747
748        // Extract [quill] section (required) — fail immediately if absent since all
749        // subsequent validation depends on it.
750        let quill_section = match quill_yaml_val.get("quill") {
751            Some(v) => v,
752            None => {
753                return Err(vec![Diagnostic::new(
754                    Severity::Error,
755                    "Missing required 'quill' section in Quill.yaml".to_string(),
756                )
757                .with_code("quill::missing_section".to_string())
758                .with_hint(
759                    "Add a 'quill:' section with name, backend, version, and description."
760                        .to_string(),
761                )]);
762            }
763        };
764
765        // Validate that no unknown keys appear in the [quill] section.
766        const KNOWN_QUILL_KEYS: &[&str] = &[
767            "name",
768            "backend",
769            "description",
770            "version",
771            "author",
772            "example",
773            "example_file",
774            "plate_file",
775            "ui",
776        ];
777        if let Some(quill_obj) = quill_section.as_object() {
778            for key in quill_obj.keys() {
779                if !KNOWN_QUILL_KEYS.contains(&key.as_str()) {
780                    errors.push(
781                        Diagnostic::new(
782                            Severity::Error,
783                            format!("Unknown key '{}' in 'quill:' section", key),
784                        )
785                        .with_code("quill::unknown_key".to_string())
786                        .with_hint(format!("Valid keys are: {}", KNOWN_QUILL_KEYS.join(", "))),
787                    );
788                }
789            }
790        }
791
792        // Extract required fields — collect all missing-field errors before returning.
793        let name = match quill_section.get("name").and_then(|v| v.as_str()) {
794            Some(n) => {
795                if !Self::is_valid_quill_name(n) {
796                    errors.push(
797                        Diagnostic::new(
798                            Severity::Error,
799                            format!(
800                                "Invalid Quill name '{}': quill.name must be snake_case \
801                                 (lowercase letters, digits, and underscores only).",
802                                n
803                            ),
804                        )
805                        .with_code("quill::invalid_name".to_string())
806                        .with_hint(format!(
807                            "Rename '{}' to '{}'",
808                            n,
809                            n.to_lowercase().replace('-', "_")
810                        )),
811                    );
812                }
813                n.to_string()
814            }
815            None => {
816                errors.push(
817                    Diagnostic::new(
818                        Severity::Error,
819                        "Missing required 'name' field in 'quill' section".to_string(),
820                    )
821                    .with_code("quill::missing_name".to_string())
822                    .with_hint(
823                        "Add 'name: your_quill_name' under the 'quill:' section.".to_string(),
824                    ),
825                );
826                String::new()
827            }
828        };
829
830        let backend = match quill_section.get("backend").and_then(|v| v.as_str()) {
831            Some(b) => b.to_string(),
832            None => {
833                errors.push(
834                    Diagnostic::new(
835                        Severity::Error,
836                        "Missing required 'backend' field in 'quill' section".to_string(),
837                    )
838                    .with_code("quill::missing_backend".to_string())
839                    .with_hint("Add 'backend: typst' (or another supported backend).".to_string()),
840                );
841                String::new()
842            }
843        };
844
845        let description = match quill_section.get("description").and_then(|v| v.as_str()) {
846            Some(d) if !d.trim().is_empty() => {
847                Self::validate_description_singleline(Some(d), "quill", &mut errors);
848                d.to_string()
849            }
850            Some(_) => {
851                errors.push(
852                    Diagnostic::new(
853                        Severity::Error,
854                        "'description' field in 'quill' section cannot be empty".to_string(),
855                    )
856                    .with_code("quill::empty_description".to_string()),
857                );
858                String::new()
859            }
860            None => {
861                errors.push(
862                    Diagnostic::new(
863                        Severity::Error,
864                        "Missing required 'description' field in 'quill' section".to_string(),
865                    )
866                    .with_code("quill::missing_description".to_string())
867                    .with_hint("Add a brief 'description:' of what this quill is for.".to_string()),
868                );
869                String::new()
870            }
871        };
872
873        // Extract optional fields (now version is required)
874        let version = match quill_section.get("version") {
875            Some(version_val) => {
876                // Handle version as string or number (YAML might parse 1.0 as number)
877                let raw = if let Some(s) = version_val.as_str() {
878                    s.to_string()
879                } else if let Some(n) = version_val.as_f64() {
880                    n.to_string()
881                } else {
882                    errors.push(
883                        Diagnostic::new(
884                            Severity::Error,
885                            "Invalid 'version' field format".to_string(),
886                        )
887                        .with_code("quill::invalid_version".to_string())
888                        .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
889                    );
890                    String::new()
891                };
892                if !raw.is_empty() {
893                    use std::str::FromStr;
894                    if let Err(e) = crate::version::Version::from_str(&raw) {
895                        errors.push(
896                            Diagnostic::new(
897                                Severity::Error,
898                                format!("Invalid version '{}': {}", raw, e),
899                            )
900                            .with_code("quill::invalid_version".to_string())
901                            .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
902                        );
903                    }
904                }
905                raw
906            }
907            None => {
908                errors.push(
909                    Diagnostic::new(
910                        Severity::Error,
911                        "Missing required 'version' field in 'quill' section".to_string(),
912                    )
913                    .with_code("quill::missing_version".to_string())
914                    .with_hint("Add 'version: 1.0' under the 'quill:' section.".to_string()),
915                );
916                String::new()
917            }
918        };
919
920        let author = quill_section
921            .get("author")
922            .and_then(|v| v.as_str())
923            .map(|s| s.to_string())
924            .unwrap_or_else(|| "Unknown".to_string());
925
926        let example_file = quill_section
927            .get("example")
928            .and_then(|v| v.as_str())
929            .map(|s| s.to_string())
930            .or_else(|| {
931                quill_section
932                    .get("example_file")
933                    .and_then(|v| v.as_str())
934                    .map(|s| s.to_string())
935            });
936
937        let plate_file = quill_section
938            .get("plate_file")
939            .and_then(|v| v.as_str())
940            .map(|s| s.to_string());
941
942        let ui_section: Option<UiLeafSchema> = match quill_section.get("ui").cloned() {
943            None => None,
944            Some(v) => match serde_json::from_value::<UiLeafSchema>(v) {
945                Ok(parsed) => Some(parsed),
946                Err(e) => {
947                    errors.push(
948                        Diagnostic::new(
949                            Severity::Error,
950                            format!("Invalid 'quill.ui' block: {}", e),
951                        )
952                        .with_code("quill::invalid_ui".to_string())
953                        .with_hint("Valid key under 'ui' is: title.".to_string()),
954                    );
955                    None
956                }
957            },
958        };
959
960        // Extract optional backend-specific section (keyed by `quill.backend`).
961        let mut backend_config = HashMap::new();
962        if !backend.is_empty() {
963            if let Some(section_val) = quill_yaml_val.get(&backend) {
964                if let Some(table) = section_val.as_object() {
965                    for (key, value) in table {
966                        backend_config.insert(key.clone(), QuillValue::from_json(value.clone()));
967                    }
968                }
969            }
970        }
971
972        // Reject unknown top-level sections. Known sections are: quill, main, leaf_kinds,
973        // and the backend name (e.g. typst). Everything else is a mistake. `fields` gets
974        // a targeted hint since it's the most common shape mistake.
975        if let Some(top_obj) = quill_yaml_val.as_object() {
976            for key in top_obj.keys() {
977                let is_known = key == "quill"
978                    || key == "main"
979                    || key == "leaf_kinds"
980                    || (!backend.is_empty() && key == &backend);
981                if is_known {
982                    continue;
983                }
984
985                let mut diag = Diagnostic::new(
986                    Severity::Error,
987                    format!("Unknown top-level section '{}'", key),
988                )
989                .with_code("quill::unknown_section".to_string());
990
991                diag = if key == "fields" {
992                    diag.with_hint(
993                        "Root-level `fields` is not supported; use `main.fields` instead."
994                            .to_string(),
995                    )
996                } else {
997                    diag.with_hint(format!(
998                        "Valid top-level sections are: quill, main, leaf_kinds{}",
999                        if backend.is_empty() {
1000                            String::new()
1001                        } else {
1002                            format!(", {}", backend)
1003                        }
1004                    ))
1005                };
1006
1007                errors.push(diag);
1008            }
1009        }
1010
1011        let main_obj_opt = quill_yaml_val.get("main").and_then(|v| v.as_object());
1012
1013        // Extract main.fields (optional)
1014        let fields = if let Some(main_obj) = main_obj_opt {
1015            if let Some(fields_val) = main_obj.get("fields") {
1016                if let Some(fields_map) = fields_val.as_object() {
1017                    // With preserve_order feature, keys iterator respects insertion order
1018                    let field_order: Vec<String> = fields_map.keys().cloned().collect();
1019                    Self::parse_fields_with_order(
1020                        fields_map,
1021                        &field_order,
1022                        "field schema",
1023                        &mut errors,
1024                    )
1025                } else {
1026                    BTreeMap::new()
1027                }
1028            } else {
1029                BTreeMap::new()
1030            }
1031        } else {
1032            BTreeMap::new()
1033        };
1034
1035        // Extract main.ui (optional). Fail loudly on malformed UI metadata rather
1036        // than silently dropping it — see `quill.ui` handling above.
1037        let main_ui: Option<UiLeafSchema> = match main_obj_opt
1038            .and_then(|main_obj| main_obj.get("ui"))
1039            .cloned()
1040        {
1041            None => None,
1042            Some(v) => match serde_json::from_value::<UiLeafSchema>(v) {
1043                Ok(parsed) => Some(parsed),
1044                Err(e) => {
1045                    errors.push(
1046                        Diagnostic::new(Severity::Error, format!("Invalid 'main.ui' block: {}", e))
1047                            .with_code("quill::invalid_ui".to_string())
1048                            .with_hint("Valid key under 'ui' is: title.".to_string()),
1049                    );
1050                    None
1051                }
1052            },
1053        };
1054
1055        // Extract main.body (optional). Fail loudly on malformed body metadata.
1056        let main_body: Option<BodyLeafSchema> = match main_obj_opt
1057            .and_then(|main_obj| main_obj.get("body"))
1058            .cloned()
1059        {
1060            None => None,
1061            Some(v) => match serde_json::from_value::<BodyLeafSchema>(v) {
1062                Ok(parsed) => Some(parsed),
1063                Err(e) => {
1064                    errors.push(
1065                        Diagnostic::new(
1066                            Severity::Error,
1067                            format!("Invalid 'main.body' block: {}", e),
1068                        )
1069                        .with_code("quill::invalid_body".to_string())
1070                        .with_hint("Valid keys under 'body' are: enabled, example.".to_string()),
1071                    );
1072                    None
1073                }
1074            },
1075        };
1076
1077        // Extract main.description (optional, authored under `main:` like any
1078        // other leaf type). This is independent of `quill.description`.
1079        let main_description = main_obj_opt
1080            .and_then(|main_obj| main_obj.get("description"))
1081            .and_then(|v| v.as_str())
1082            .map(|s| s.to_string());
1083        Self::validate_description_singleline(main_description.as_deref(), "main", &mut errors);
1084
1085        // The main entry-point leaf.
1086        let main = LeafSchema {
1087            name: "main".to_string(),
1088            description: main_description,
1089            fields,
1090            ui: main_ui.or(ui_section),
1091            body: main_body,
1092        };
1093
1094        // Extract [leaf_kinds] section (optional)
1095        let mut leaf_kinds: Vec<LeafSchema> = Vec::new();
1096        if let Some(leaf_kinds_val) = quill_yaml_val.get("leaf_kinds") {
1097            match leaf_kinds_val.as_object() {
1098                None => {
1099                    errors.push(
1100                        Diagnostic::new(
1101                            Severity::Error,
1102                            "'leaf_kinds' section must be an object (mapping of type names to schemas)".to_string(),
1103                        )
1104                        .with_code("quill::invalid_leaf_kinds".to_string()),
1105                    );
1106                }
1107                Some(leaf_kinds_table) => {
1108                    for (leaf_name, leaf_value) in leaf_kinds_table {
1109                        if !crate::document::sentinel::is_valid_tag_name(leaf_name) {
1110                            errors.push(
1111                                Diagnostic::new(
1112                                    Severity::Error,
1113                                    format!(
1114                                        "Invalid leaf-type name '{}': names must match \
1115                                         [a-z_][a-z0-9_]* (lowercase letters, digits, and underscores only).",
1116                                        leaf_name
1117                                    ),
1118                                )
1119                                .with_code("quill::invalid_leaf_kind_name".to_string()),
1120                            );
1121                            continue;
1122                        }
1123
1124                        // Parse leaf basic info using serde
1125                        let leaf_def: LeafSchemaDef =
1126                            match serde_json::from_value(leaf_value.clone()) {
1127                                Ok(d) => d,
1128                                Err(e) => {
1129                                    errors.push(
1130                                        Diagnostic::new(
1131                                            Severity::Error,
1132                                            format!(
1133                                                "Failed to parse leaf_kind '{}': {}",
1134                                                leaf_name, e
1135                                            ),
1136                                        )
1137                                        .with_code("quill::invalid_leaf_kind_schema".to_string()),
1138                                    );
1139                                    continue;
1140                                }
1141                            };
1142
1143                        // Parse leaf fields
1144                        let leaf_fields = if let Some(leaf_fields_table) =
1145                            leaf_value.get("fields").and_then(|v| v.as_object())
1146                        {
1147                            let leaf_field_order: Vec<String> =
1148                                leaf_fields_table.keys().cloned().collect();
1149                            Self::parse_fields_with_order(
1150                                leaf_fields_table,
1151                                &leaf_field_order,
1152                                &format!("leaf_kind '{}' field", leaf_name),
1153                                &mut errors,
1154                            )
1155                        } else {
1156                            BTreeMap::new()
1157                        };
1158
1159                        Self::validate_description_singleline(
1160                            leaf_def.description.as_deref(),
1161                            &format!("leaf_kind '{}'", leaf_name),
1162                            &mut errors,
1163                        );
1164                        leaf_kinds.push(LeafSchema {
1165                            name: leaf_name.clone(),
1166                            description: leaf_def.description,
1167                            fields: leaf_fields,
1168                            ui: leaf_def.ui,
1169                            body: leaf_def.body,
1170                        });
1171                    }
1172                }
1173            }
1174        }
1175
1176        // Warn when `body.example` is set together with `body.enabled: false` —
1177        // the example has no effect since the body editor is disabled.
1178        let warn_example_unused = |label: &str,
1179                                   body: &Option<BodyLeafSchema>|
1180         -> Option<Diagnostic> {
1181            let body = body.as_ref()?;
1182            if body.enabled == Some(false) && body.example.is_some() {
1183                Some(
1184                    Diagnostic::new(
1185                        Severity::Warning,
1186                        format!(
1187                            "`{label}.body.example` is set but `{label}.body.enabled` is false; the example will have no effect"
1188                        ),
1189                    )
1190                    .with_code("quill::body_example_unused".to_string())
1191                    .with_hint(
1192                        "Set `body.enabled: true` to surface the example, or remove `body.example`."
1193                            .to_string(),
1194                    ),
1195                )
1196            } else {
1197                None
1198            }
1199        };
1200        if let Some(d) = warn_example_unused("main", &main.body) {
1201            warnings.push(d);
1202        }
1203        for leaf in &leaf_kinds {
1204            if let Some(d) = warn_example_unused(&format!("leaf_kinds.{}", leaf.name), &leaf.body) {
1205                warnings.push(d);
1206            }
1207        }
1208
1209        // Error when `body.example` contains a line that the document parser
1210        // would interpret as a metadata fence (`---` with up to 3 leading
1211        // spaces and optional trailing whitespace). Such a line would split the
1212        // blueprint body region into a new fence, corrupting document structure.
1213        let err_example_contains_fence = |label: &str,
1214                                          body: &Option<BodyLeafSchema>|
1215         -> Option<Diagnostic> {
1216            let example = body.as_ref()?.example.as_deref()?;
1217            if example_contains_fence_line(example) {
1218                Some(
1219                    Diagnostic::new(
1220                        Severity::Error,
1221                        format!(
1222                            "`{label}.body.example` contains a line that would be parsed as a metadata fence (`---`); this would corrupt the blueprint"
1223                        ),
1224                    )
1225                    .with_code("quill::body_example_contains_fence".to_string())
1226                    .with_hint(
1227                        "Remove or reword any line that is exactly `---` (with up to 3 leading spaces and optional trailing whitespace).".to_string(),
1228                    ),
1229                )
1230            } else {
1231                None
1232            }
1233        };
1234        if let Some(d) = err_example_contains_fence("main", &main.body) {
1235            errors.push(d);
1236        }
1237        for leaf in &leaf_kinds {
1238            if let Some(d) =
1239                err_example_contains_fence(&format!("leaf_kinds.{}", leaf.name), &leaf.body)
1240            {
1241                errors.push(d);
1242            }
1243        }
1244
1245        if !errors.is_empty() {
1246            return Err(errors);
1247        }
1248
1249        Ok((
1250            QuillConfig {
1251                name,
1252                description,
1253                main,
1254                leaf_kinds,
1255                backend,
1256                version,
1257                author,
1258                example_file,
1259                example_markdown: None,
1260                plate_file,
1261                backend_config,
1262            },
1263            warnings,
1264        ))
1265    }
1266}
1267
1268/// Returns true if any line in `text` would be parsed as a metadata-fence
1269/// marker by the document parser. Mirrors `document::fences::is_fence_marker_line`:
1270/// up to 3 leading spaces (no leading tab), then `---`, then only whitespace.
1271fn example_contains_fence_line(text: &str) -> bool {
1272    text.lines().any(|line| {
1273        let line = line.strip_suffix('\r').unwrap_or(line);
1274        let indent = line.bytes().take_while(|&b| b == b' ').count();
1275        if indent > 3 || line.as_bytes().first() == Some(&b'\t') {
1276            return false;
1277        }
1278        matches!(
1279            line[indent..].strip_prefix("---"),
1280            Some(rest) if rest.chars().all(|c| c == ' ' || c == '\t')
1281        )
1282    })
1283}