1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct QuillConfig {
20 pub name: String,
22 pub description: String,
26 pub main: LeafSchema,
28 pub leaf_kinds: Vec<LeafSchema>,
31 pub backend: String,
33 pub version: String,
35 pub author: String,
37 pub example_file: Option<String>,
39 pub example_markdown: Option<String>,
41 pub plate_file: Option<String>,
43 #[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 #[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 pub fn leaf_kind(&self, name: &str) -> Option<&LeafSchema> {
75 self.leaf_kinds.iter().find(|leaf| leaf.name == name)
76 }
77
78 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 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 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 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 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 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 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 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 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 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 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 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 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 } 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 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 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 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 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 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 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 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 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 let version = match quill_section.get("version") {
875 Some(version_val) => {
876 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1268fn 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}