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::{BodyCardSchema, CardSchema, FieldSchema, FieldType, UiCardSchema, UiFieldSchema};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct QuillConfig {
20 pub name: String,
22 pub description: String,
26 pub main: CardSchema,
28 pub card_types: Vec<CardSchema>,
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 CardSchemaDef {
52 pub description: Option<String>,
53 #[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 pub fn card_type(&self, name: &str) -> Option<&CardSchema> {
75 self.card_types.iter().find(|card| card.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.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 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 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_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 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 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 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 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 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 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 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 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 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 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 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 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 let version = match quill_section.get("version") {
773 Some(version_val) => {
774 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 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 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 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 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 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 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 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 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 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 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 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 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}