Skip to main content

quillmark_core/
quill.rs

1//! Quill template bundle types and implementations.
2
3use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::value::QuillValue;
10
11/// Semantic constants for field schema keys used in parsing and JSON Schema generation.
12/// Using constants provides IDE support (find references, autocomplete) and ensures
13/// consistency between parsing and output.
14pub mod field_key {
15    /// Short label for the field
16    pub const TITLE: &str = "title";
17    /// Field type (string, number, boolean, array, etc.)
18    pub const TYPE: &str = "type";
19    /// Detailed field description
20    pub const DESCRIPTION: &str = "description";
21    /// Default value for the field
22    pub const DEFAULT: &str = "default";
23    /// Example values for the field
24    pub const EXAMPLES: &str = "examples";
25    /// UI-specific metadata
26    pub const UI: &str = "ui";
27    /// Whether the field is required
28    pub const REQUIRED: &str = "required";
29    /// Enum values for string fields
30    pub const ENUM: &str = "enum";
31    /// Date format specifier (JSON Schema)
32    pub const FORMAT: &str = "format";
33}
34
35/// Semantic constants for UI schema keys
36pub mod ui_key {
37    /// Group name for field organization
38    pub const GROUP: &str = "group";
39    /// Display order within the UI
40    pub const ORDER: &str = "order";
41    /// Whether the field or specific component is hide-body (no body editor)
42    pub const HIDE_BODY: &str = "hide_body";
43}
44
45/// UI-specific metadata for field rendering
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct UiFieldSchema {
49    /// Group name for organizing fields (e.g., "Personal Info", "Preferences")
50    pub group: Option<String>,
51    /// Order of the field in the UI (automatically generated based on field position in Quill.yaml)
52    pub order: Option<i32>,
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct UiContainerSchema {
58    /// Whether to hide the body editor for this element (metadata only)
59    pub hide_body: Option<bool>,
60}
61
62/// Schema definition for a card type (composable content blocks)
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct CardSchema {
65    /// Card type name (e.g., "indorsements")
66    pub name: String,
67    /// Short label for the card type
68    pub title: Option<String>,
69    /// Detailed description of this card type
70    pub description: Option<String>,
71    /// List of fields in the card
72    pub fields: HashMap<String, FieldSchema>,
73    /// UI layout hints
74    pub ui: Option<UiContainerSchema>,
75}
76
77/// Field type hint enum for type-safe field type definitions
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum FieldType {
81    /// String type
82    #[serde(alias = "str")]
83    String,
84    /// Numeric type
85    Number,
86    /// Boolean type
87    Boolean,
88    /// Array type
89    Array,
90    /// Dictionary/object type
91    Object,
92    /// Date type (formatted as string with date format)
93    Date,
94    /// DateTime type (formatted as string with date-time format)
95    DateTime,
96    /// Markdown type (string with markdown content, contentMediaType: text/markdown)
97    Markdown,
98}
99
100impl FieldType {
101    /// Parse a FieldType from a string
102    pub fn from_str(s: &str) -> Option<Self> {
103        match s {
104            "string" | "str" => Some(FieldType::String),
105            "number" => Some(FieldType::Number),
106            "boolean" => Some(FieldType::Boolean),
107            "array" => Some(FieldType::Array),
108            "object" => Some(FieldType::Object),
109            "date" => Some(FieldType::Date),
110            "datetime" => Some(FieldType::DateTime),
111            "markdown" => Some(FieldType::Markdown),
112            _ => None,
113        }
114    }
115
116    /// Get the canonical string representation for this type
117    pub fn as_str(&self) -> &'static str {
118        match self {
119            FieldType::String => "string",
120            FieldType::Number => "number",
121            FieldType::Boolean => "boolean",
122            FieldType::Array => "array",
123            FieldType::Object => "dict",
124            FieldType::Date => "date",
125            FieldType::DateTime => "datetime",
126            FieldType::Markdown => "markdown",
127        }
128    }
129}
130
131/// Schema definition for a template field
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct FieldSchema {
134    pub name: String,
135    /// Short label for the field (used in JSON Schema title)
136    pub title: Option<String>,
137    /// Field type (required)
138    pub r#type: FieldType,
139    /// Detailed description of the field (used in JSON Schema description)
140    pub description: Option<String>,
141    /// Default value for the field
142    pub default: Option<QuillValue>,
143    /// Example values for the field
144    pub examples: Option<QuillValue>,
145    /// UI layout hints
146    pub ui: Option<UiFieldSchema>,
147    /// Whether this field is required (fields are optional by default)
148    pub required: bool,
149    /// Enum values for string fields (restricts valid values)
150    pub enum_values: Option<Vec<String>>,
151    /// Properties for dict/object types (nested field schemas)
152    pub properties: Option<HashMap<String, Box<FieldSchema>>>,
153    /// Item schema for array types
154    pub items: Option<Box<FieldSchema>>,
155}
156
157#[derive(Debug, Deserialize)]
158#[serde(deny_unknown_fields)]
159struct FieldSchemaDef {
160    pub title: Option<String>,
161    pub r#type: FieldType,
162    pub description: Option<String>,
163    pub default: Option<QuillValue>,
164    pub examples: Option<QuillValue>,
165    pub ui: Option<UiFieldSchema>,
166    #[serde(default)]
167    pub required: bool,
168    #[serde(rename = "enum")]
169    pub enum_values: Option<Vec<String>>,
170    // Nested schema support
171    // Nested schema support
172    pub properties: Option<serde_json::Map<String, serde_json::Value>>,
173    pub items: Option<serde_json::Value>,
174}
175
176impl FieldSchema {
177    /// Create a new FieldSchema with default values
178    pub fn new(name: String, r#type: FieldType, description: Option<String>) -> Self {
179        Self {
180            name,
181            title: None,
182            r#type,
183            description,
184            default: None,
185            examples: None,
186            ui: None,
187            required: false,
188            enum_values: None,
189            properties: None,
190            items: None,
191        }
192    }
193
194    /// Parse a FieldSchema from a QuillValue
195    pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
196        let def: FieldSchemaDef = serde_json::from_value(value.clone().into_json())
197            .map_err(|e| format!("Failed to parse field schema: {}", e))?;
198
199        Ok(Self {
200            name: key,
201            title: def.title,
202            r#type: def.r#type,
203            description: def.description,
204            default: def.default,
205            examples: def.examples,
206            ui: def.ui,
207            required: def.required,
208            enum_values: def.enum_values,
209            properties: if let Some(props) = def.properties {
210                let mut p = HashMap::new();
211                for (key, value) in props {
212                    p.insert(
213                        key.clone(),
214                        Box::new(FieldSchema::from_quill_value(
215                            key,
216                            &QuillValue::from_json(value),
217                        )?),
218                    );
219                }
220                Some(p)
221            } else {
222                None
223            },
224            items: if let Some(item_def) = def.items {
225                Some(Box::new(FieldSchema::from_quill_value(
226                    "items".to_string(),
227                    &QuillValue::from_json(item_def),
228                )?))
229            } else {
230                None
231            },
232        })
233    }
234}
235
236/// A node in the file tree structure
237#[derive(Debug, Clone)]
238pub enum FileTreeNode {
239    /// A file with its contents
240    File {
241        /// The file contents as bytes or UTF-8 string
242        contents: Vec<u8>,
243    },
244    /// A directory containing other files and directories
245    Directory {
246        /// The files and subdirectories in this directory
247        files: HashMap<String, FileTreeNode>,
248    },
249}
250
251impl FileTreeNode {
252    /// Get a file or directory node by path
253    pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
254        let path = path.as_ref();
255
256        // Handle root path
257        if path == Path::new("") {
258            return Some(self);
259        }
260
261        // Split path into components
262        let components: Vec<_> = path
263            .components()
264            .filter_map(|c| {
265                if let std::path::Component::Normal(s) = c {
266                    s.to_str()
267                } else {
268                    None
269                }
270            })
271            .collect();
272
273        if components.is_empty() {
274            return Some(self);
275        }
276
277        // Navigate through the tree
278        let mut current_node = self;
279        for component in components {
280            match current_node {
281                FileTreeNode::Directory { files } => {
282                    current_node = files.get(component)?;
283                }
284                FileTreeNode::File { .. } => {
285                    return None; // Can't traverse into a file
286                }
287            }
288        }
289
290        Some(current_node)
291    }
292
293    /// Get file contents by path
294    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
295        match self.get_node(path)? {
296            FileTreeNode::File { contents } => Some(contents.as_slice()),
297            FileTreeNode::Directory { .. } => None,
298        }
299    }
300
301    /// Check if a file exists at the given path
302    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
303        matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
304    }
305
306    /// Check if a directory exists at the given path
307    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
308        matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
309    }
310
311    /// List all files in a directory (non-recursive)
312    pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
313        match self.get_node(dir_path) {
314            Some(FileTreeNode::Directory { files }) => files
315                .iter()
316                .filter_map(|(name, node)| {
317                    if matches!(node, FileTreeNode::File { .. }) {
318                        Some(name.clone())
319                    } else {
320                        None
321                    }
322                })
323                .collect(),
324            _ => Vec::new(),
325        }
326    }
327
328    /// List all subdirectories in a directory (non-recursive)
329    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
330        match self.get_node(dir_path) {
331            Some(FileTreeNode::Directory { files }) => files
332                .iter()
333                .filter_map(|(name, node)| {
334                    if matches!(node, FileTreeNode::Directory { .. }) {
335                        Some(name.clone())
336                    } else {
337                        None
338                    }
339                })
340                .collect(),
341            _ => Vec::new(),
342        }
343    }
344
345    /// Insert a file or directory at the given path
346    pub fn insert<P: AsRef<Path>>(
347        &mut self,
348        path: P,
349        node: FileTreeNode,
350    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
351        let path = path.as_ref();
352
353        // Split path into components
354        let components: Vec<_> = path
355            .components()
356            .filter_map(|c| {
357                if let std::path::Component::Normal(s) = c {
358                    s.to_str().map(|s| s.to_string())
359                } else {
360                    None
361                }
362            })
363            .collect();
364
365        if components.is_empty() {
366            return Err("Cannot insert at root path".into());
367        }
368
369        // Navigate to parent directory, creating directories as needed
370        let mut current_node = self;
371        for component in &components[..components.len() - 1] {
372            match current_node {
373                FileTreeNode::Directory { files } => {
374                    current_node =
375                        files
376                            .entry(component.clone())
377                            .or_insert_with(|| FileTreeNode::Directory {
378                                files: HashMap::new(),
379                            });
380                }
381                FileTreeNode::File { .. } => {
382                    return Err("Cannot traverse into a file".into());
383                }
384            }
385        }
386
387        // Insert the new node
388        let filename = &components[components.len() - 1];
389        match current_node {
390            FileTreeNode::Directory { files } => {
391                files.insert(filename.clone(), node);
392                Ok(())
393            }
394            FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
395        }
396    }
397
398    /// Parse a tree structure from JSON value
399    fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
400        if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
401            // It's a file with string contents
402            Ok(FileTreeNode::File {
403                contents: contents_str.as_bytes().to_vec(),
404            })
405        } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
406            // It's a file with byte array contents
407            let contents: Vec<u8> = bytes_array
408                .iter()
409                .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
410                .collect();
411            Ok(FileTreeNode::File { contents })
412        } else if let Some(obj) = value.as_object() {
413            // It's a directory (either empty or with nested files)
414            let mut files = HashMap::new();
415            for (name, child_value) in obj {
416                files.insert(name.clone(), Self::from_json_value(child_value)?);
417            }
418            // Empty directories are valid
419            Ok(FileTreeNode::Directory { files })
420        } else {
421            Err(format!("Invalid file tree node: {:?}", value).into())
422        }
423    }
424
425    pub fn print_tree(&self) -> String {
426        self.print_tree_recursive("", "", true)
427    }
428
429    fn print_tree_recursive(&self, name: &str, prefix: &str, is_last: bool) -> String {
430        let mut result = String::new();
431
432        // Choose the appropriate tree characters
433        let connector = if is_last { "└── " } else { "├── " };
434        let extension = if is_last { "    " } else { "│   " };
435
436        match self {
437            FileTreeNode::File { .. } => {
438                result.push_str(&format!("{}{}{}\n", prefix, connector, name));
439            }
440            FileTreeNode::Directory { files } => {
441                // Add trailing slash for directories like `tree` does
442                result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
443
444                let child_prefix = format!("{}{}", prefix, extension);
445                let count = files.len();
446
447                for (i, (child_name, node)) in files.iter().enumerate() {
448                    let is_last_child = i == count - 1;
449                    result.push_str(&node.print_tree_recursive(
450                        child_name,
451                        &child_prefix,
452                        is_last_child,
453                    ));
454                }
455            }
456        }
457
458        result
459    }
460}
461
462/// Simple gitignore-style pattern matcher for .quillignore
463#[derive(Debug, Clone)]
464pub struct QuillIgnore {
465    patterns: Vec<String>,
466}
467
468impl QuillIgnore {
469    /// Create a new QuillIgnore from pattern strings
470    pub fn new(patterns: Vec<String>) -> Self {
471        Self { patterns }
472    }
473
474    /// Parse .quillignore content into patterns
475    pub fn from_content(content: &str) -> Self {
476        let patterns = content
477            .lines()
478            .map(|line| line.trim())
479            .filter(|line| !line.is_empty() && !line.starts_with('#'))
480            .map(|line| line.to_string())
481            .collect();
482        Self::new(patterns)
483    }
484
485    /// Check if a path should be ignored
486    pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
487        let path = path.as_ref();
488        let path_str = path.to_string_lossy();
489
490        for pattern in &self.patterns {
491            if self.matches_pattern(pattern, &path_str) {
492                return true;
493            }
494        }
495        false
496    }
497
498    /// Simple pattern matching (supports * wildcard and directory patterns)
499    fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
500        // Handle directory patterns
501        if let Some(pattern_prefix) = pattern.strip_suffix('/') {
502            return path.starts_with(pattern_prefix)
503                && (path.len() == pattern_prefix.len()
504                    || path.chars().nth(pattern_prefix.len()) == Some('/'));
505        }
506
507        // Handle exact matches
508        if !pattern.contains('*') {
509            return path == pattern || path.ends_with(&format!("/{}", pattern));
510        }
511
512        // Simple wildcard matching
513        if pattern == "*" {
514            return true;
515        }
516
517        // Handle patterns with wildcards
518        let pattern_parts: Vec<&str> = pattern.split('*').collect();
519        if pattern_parts.len() == 2 {
520            let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
521            if prefix.is_empty() {
522                return path.ends_with(suffix);
523            } else if suffix.is_empty() {
524                return path.starts_with(prefix);
525            } else {
526                return path.starts_with(prefix) && path.ends_with(suffix);
527            }
528        }
529
530        false
531    }
532}
533
534/// A quill template bundle.
535#[derive(Debug, Clone)]
536pub struct Quill {
537    /// Quill-specific metadata
538    pub metadata: HashMap<String, QuillValue>,
539    /// Name of the quill
540    pub name: String,
541    /// Backend identifier (e.g., "typst")
542    pub backend: String,
543    /// Plate template content (optional)
544    pub plate: Option<String>,
545    /// Markdown template content (optional)
546    pub example: Option<String>,
547    /// Field JSON schema (single source of truth for schema and defaults)
548    pub schema: QuillValue,
549    /// Cached default values extracted from schema (for performance)
550    pub defaults: HashMap<String, QuillValue>,
551    /// Cached example values extracted from schema (for performance)
552    pub examples: HashMap<String, Vec<QuillValue>>,
553    /// In-memory file system (tree structure)
554    pub files: FileTreeNode,
555}
556
557/// Top-level configuration for a Quillmark project
558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
559pub struct QuillConfig {
560    /// The root document schema
561    pub document: CardSchema,
562    /// Backend to use for rendering (e.g., "typst", "html")
563    pub backend: String,
564    /// Version of the Quillmark spec
565    pub version: String,
566    /// Author of the project
567    pub author: String,
568    /// Example data file for preview
569    pub example_file: Option<String>,
570    /// Plate file (template)
571    pub plate_file: Option<String>,
572    /// Card definitions (reusable sub-schemas)
573    pub cards: HashMap<String, CardSchema>,
574    /// Additional unstructured metadata
575    #[serde(flatten)]
576    pub metadata: HashMap<String, QuillValue>,
577    /// Typst specific configuration
578    #[serde(default)]
579    pub typst_config: HashMap<String, QuillValue>,
580}
581
582#[derive(Debug, Deserialize)]
583#[serde(deny_unknown_fields)]
584struct CardSchemaDef {
585    pub title: Option<String>,
586    pub description: Option<String>,
587    pub fields: Option<serde_json::Map<String, serde_json::Value>>,
588    pub ui: Option<UiContainerSchema>,
589}
590
591impl QuillConfig {
592    /// Parse fields from a JSON Value map, assigning ui.order based on key_order.
593    ///
594    /// This helper ensures consistent field ordering logic for both top-level
595    /// fields and card fields.
596    ///
597    /// # Arguments
598    /// * `fields_map` - The JSON map containing field definitions
599    /// * `key_order` - Vector of field names in their definition order
600    /// * `context` - Context string for error messages (e.g., "field" or "card 'indorsement' field")
601    fn parse_fields_with_order(
602        fields_map: &serde_json::Map<String, serde_json::Value>,
603        key_order: &[String],
604        context: &str,
605    ) -> HashMap<String, FieldSchema> {
606        let mut fields = HashMap::new();
607        let mut fallback_counter = 0;
608
609        for (field_name, field_value) in fields_map {
610            // Determine order from key_order, or use fallback counter
611            let order = if let Some(idx) = key_order.iter().position(|k| k == field_name) {
612                idx as i32
613            } else {
614                let o = key_order.len() as i32 + fallback_counter;
615                fallback_counter += 1;
616                o
617            };
618
619            let quill_value = QuillValue::from_json(field_value.clone());
620            match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
621                Ok(mut schema) => {
622                    // Always set ui.order based on position
623                    if schema.ui.is_none() {
624                        schema.ui = Some(UiFieldSchema {
625                            group: None,
626                            order: Some(order),
627                        });
628                    } else if let Some(ui) = &mut schema.ui {
629                        // Only set if not already set
630                        if ui.order.is_none() {
631                            ui.order = Some(order);
632                        }
633                    }
634
635                    fields.insert(field_name.clone(), schema);
636                }
637                Err(e) => {
638                    eprintln!(
639                        "Warning: Failed to parse {} '{}': {}",
640                        context, field_name, e
641                    );
642                }
643            }
644        }
645
646        fields
647    }
648
649    /// Parse QuillConfig from YAML content
650    pub fn from_yaml(yaml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
651        // Parse YAML into serde_json::Value via serde_saphyr
652        // Note: serde_json with "preserve_order" feature is required for this to work as expected
653        let quill_yaml_val: serde_json::Value = serde_saphyr::from_str(yaml_content)
654            .map_err(|e| format!("Failed to parse Quill.yaml: {}", e))?;
655
656        // Extract [Quill] section (required)
657        let quill_section = quill_yaml_val
658            .get("Quill")
659            .ok_or("Missing required 'Quill' section in Quill.yaml")?;
660
661        // Extract required fields
662        let name = quill_section
663            .get("name")
664            .and_then(|v| v.as_str())
665            .ok_or("Missing required 'name' field in 'Quill' section")?
666            .to_string();
667
668        let backend = quill_section
669            .get("backend")
670            .and_then(|v| v.as_str())
671            .ok_or("Missing required 'backend' field in 'Quill' section")?
672            .to_string();
673
674        let description = quill_section
675            .get("description")
676            .and_then(|v| v.as_str())
677            .ok_or("Missing required 'description' field in 'Quill' section")?;
678
679        if description.trim().is_empty() {
680            return Err("'description' field in 'Quill' section cannot be empty".into());
681        }
682        let description = description.to_string();
683
684        // Extract optional fields (now version is required)
685        let version_val = quill_section
686            .get("version")
687            .ok_or("Missing required 'version' field in 'Quill' section")?;
688
689        // Handle version as string or number (YAML might parse 1.0 as number)
690        let version = if let Some(s) = version_val.as_str() {
691            s.to_string()
692        } else if let Some(n) = version_val.as_f64() {
693            n.to_string()
694        } else {
695            return Err("Invalid 'version' field format".into());
696        };
697
698        // Validate version format (must be MAJOR.MINOR)
699        use std::str::FromStr;
700        crate::version::Version::from_str(&version)
701            .map_err(|e| format!("Invalid version '{}': {}", version, e))?;
702
703        let author = quill_section
704            .get("author")
705            .and_then(|v| v.as_str())
706            .map(|s| s.to_string())
707            .unwrap_or_else(|| "Unknown".to_string()); // Default author
708
709        let example_file = quill_section
710            .get("example_file")
711            .and_then(|v| v.as_str())
712            .map(|s| s.to_string());
713
714        let plate_file = quill_section
715            .get("plate_file")
716            .and_then(|v| v.as_str())
717            .map(|s| s.to_string());
718
719        let ui_section: Option<UiContainerSchema> = quill_section
720            .get("ui")
721            .cloned()
722            .and_then(|v| serde_json::from_value(v).ok());
723
724        // Extract additional metadata from [Quill] section (excluding standard fields)
725        let mut metadata = HashMap::new();
726        if let Some(table) = quill_section.as_object() {
727            for (key, value) in table {
728                // Skip standard fields that are stored in dedicated struct fields
729                if key != "name"
730                    && key != "backend"
731                    && key != "description"
732                    && key != "version"
733                    && key != "author"
734                    && key != "example_file"
735                    && key != "plate_file"
736                    && key != "ui"
737                {
738                    metadata.insert(key.clone(), QuillValue::from_json(value.clone()));
739                }
740            }
741        }
742
743        // Extract [typst] section (optional)
744        let mut typst_config = HashMap::new();
745        if let Some(typst_val) = quill_yaml_val.get("typst") {
746            if let Some(table) = typst_val.as_object() {
747                for (key, value) in table {
748                    typst_config.insert(key.clone(), QuillValue::from_json(value.clone()));
749                }
750            }
751        }
752
753        // Extract [fields] section (optional) using shared helper
754        let fields = if let Some(fields_val) = quill_yaml_val.get("fields") {
755            if let Some(fields_map) = fields_val.as_object() {
756                // With preserve_order feature, keys iterator respects insertion order
757                let field_order: Vec<String> = fields_map.keys().cloned().collect();
758                Self::parse_fields_with_order(fields_map, &field_order, "field schema")
759            } else {
760                HashMap::new()
761            }
762        } else {
763            HashMap::new()
764        };
765
766        // Extract [cards] section (optional)
767        let mut cards: HashMap<String, CardSchema> = HashMap::new();
768        if let Some(cards_val) = quill_yaml_val.get("cards") {
769            let cards_table = cards_val
770                .as_object()
771                .ok_or("'cards' section must be an object")?;
772
773            for (card_name, card_value) in cards_table {
774                // Parse card basic info using serde
775                let card_def: CardSchemaDef = serde_json::from_value(card_value.clone())
776                    .map_err(|e| format!("Failed to parse card '{}': {}", card_name, e))?;
777
778                // Parse card fields
779                let card_fields = if let Some(card_fields_table) =
780                    card_value.get("fields").and_then(|v| v.as_object())
781                {
782                    let card_field_order: Vec<String> = card_fields_table.keys().cloned().collect();
783
784                    Self::parse_fields_with_order(
785                        card_fields_table,
786                        &card_field_order,
787                        &format!("card '{}' field", card_name),
788                    )
789                } else if let Some(_toml_fields) = &card_def.fields {
790                    HashMap::new()
791                } else {
792                    HashMap::new()
793                };
794
795                let card_schema = CardSchema {
796                    name: card_name.clone(),
797                    title: card_def.title,
798                    description: card_def.description,
799                    fields: card_fields,
800                    ui: card_def.ui,
801                };
802
803                cards.insert(card_name.clone(), card_schema);
804            }
805        }
806
807        // Create document schema from root fields
808        let document = CardSchema {
809            name: name.clone(),
810            title: Some(name),
811            description: Some(description),
812            fields,
813            ui: ui_section,
814        };
815
816        Ok(QuillConfig {
817            document,
818            backend,
819            version,
820            author,
821            example_file,
822            plate_file,
823            cards,
824            metadata,
825            typst_config,
826        })
827    }
828}
829
830impl Quill {
831    /// Create a Quill from a directory path
832    pub fn from_path<P: AsRef<std::path::Path>>(
833        path: P,
834    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
835        use std::fs;
836
837        let path = path.as_ref();
838
839        // Load .quillignore if it exists
840        let quillignore_path = path.join(".quillignore");
841        let ignore = if quillignore_path.exists() {
842            let ignore_content = fs::read_to_string(&quillignore_path)
843                .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
844            QuillIgnore::from_content(&ignore_content)
845        } else {
846            // Default ignore patterns
847            QuillIgnore::new(vec![
848                ".git/".to_string(),
849                ".gitignore".to_string(),
850                ".quillignore".to_string(),
851                "target/".to_string(),
852                "node_modules/".to_string(),
853            ])
854        };
855
856        // Load all files into a tree structure
857        let root = Self::load_directory_as_tree(path, path, &ignore)?;
858
859        // Create Quill from the file tree
860        Self::from_tree(root)
861    }
862
863    /// Create a Quill from a tree structure
864    ///
865    /// This is the authoritative method for creating a Quill from an in-memory file tree.
866    ///
867    /// # Arguments
868    ///
869    /// * `root` - The root node of the file tree
870    ///
871    /// # Errors
872    ///
873    /// Returns an error if:
874    /// - Quill.yaml is not found in the file tree
875    /// - Quill.yaml is not valid UTF-8 or YAML
876    /// - The plate file specified in Quill.yaml is not found or not valid UTF-8
877    /// - Validation fails
878    pub fn from_tree(root: FileTreeNode) -> Result<Self, Box<dyn StdError + Send + Sync>> {
879        // Read Quill.yaml
880        let quill_yaml_bytes = root
881            .get_file("Quill.yaml")
882            .ok_or("Quill.yaml not found in file tree")?;
883
884        let quill_yaml_content = String::from_utf8(quill_yaml_bytes.to_vec())
885            .map_err(|e| format!("Quill.yaml is not valid UTF-8: {}", e))?;
886
887        // Parse YAML into QuillConfig
888        let config = QuillConfig::from_yaml(&quill_yaml_content)?;
889
890        // Construct Quill from QuillConfig
891        Self::from_config(config, root)
892    }
893
894    /// Create a Quill from a QuillConfig and file tree
895    ///
896    /// This method constructs a Quill from a parsed QuillConfig and validates
897    /// all file references.
898    ///
899    /// # Arguments
900    ///
901    /// * `config` - The parsed QuillConfig
902    /// * `root` - The root node of the file tree
903    ///
904    /// # Errors
905    ///
906    /// Returns an error if:
907    /// - The plate file specified in config is not found or not valid UTF-8
908    /// - The example file specified in config is not found or not valid UTF-8
909    /// - Schema generation fails
910    fn from_config(
911        config: QuillConfig,
912        root: FileTreeNode,
913    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
914        // Build metadata from config
915        let mut metadata = config.metadata.clone();
916
917        // Add backend to metadata
918        metadata.insert(
919            "backend".to_string(),
920            QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
921        );
922
923        metadata.insert(
924            "description".to_string(),
925            QuillValue::from_json(serde_json::Value::String(
926                config.document.description.clone().unwrap_or_default(),
927            )),
928        );
929
930        // Add author
931        metadata.insert(
932            "author".to_string(),
933            QuillValue::from_json(serde_json::Value::String(config.author.clone())),
934        );
935
936        // Add version
937        metadata.insert(
938            "version".to_string(),
939            QuillValue::from_json(serde_json::Value::String(config.version.clone())),
940        );
941
942        // Add typst config to metadata with typst_ prefix
943        for (key, value) in &config.typst_config {
944            metadata.insert(format!("typst_{}", key), value.clone());
945        }
946
947        // Build JSON schema from field and card schemas
948        // Build JSON schema from field and card schemas
949        let schema = crate::schema::build_schema(&config.document, &config.cards)
950            .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
951
952        // Read the plate content from plate file (if specified)
953        let plate_content: Option<String> = if let Some(ref plate_file_name) = config.plate_file {
954            let plate_bytes = root.get_file(plate_file_name).ok_or_else(|| {
955                format!("Plate file '{}' not found in file tree", plate_file_name)
956            })?;
957
958            let content = String::from_utf8(plate_bytes.to_vec()).map_err(|e| {
959                format!("Plate file '{}' is not valid UTF-8: {}", plate_file_name, e)
960            })?;
961            Some(content)
962        } else {
963            // No plate file specified
964            None
965        };
966
967        // Read the markdown example content if specified
968        // Read the markdown example content if specified, or check for default "example.md"
969        let example_content = if let Some(ref example_file_name) = config.example_file {
970            root.get_file(example_file_name).and_then(|bytes| {
971                String::from_utf8(bytes.to_vec())
972                    .map_err(|e| {
973                        eprintln!(
974                            "Warning: Example file '{}' is not valid UTF-8: {}",
975                            example_file_name, e
976                        );
977                        e
978                    })
979                    .ok()
980            })
981        } else if root.file_exists("example.md") {
982            // Smart default: use example.md if it exists
983            root.get_file("example.md").and_then(|bytes| {
984                String::from_utf8(bytes.to_vec())
985                    .map_err(|e| {
986                        eprintln!(
987                            "Warning: Default example file 'example.md' is not valid UTF-8: {}",
988                            e
989                        );
990                        e
991                    })
992                    .ok()
993            })
994        } else {
995            None
996        };
997
998        // Extract and cache defaults and examples from schema for performance
999        let defaults = crate::schema::extract_defaults_from_schema(&schema);
1000        let examples = crate::schema::extract_examples_from_schema(&schema);
1001
1002        let quill = Quill {
1003            metadata,
1004            name: config.document.name,
1005            backend: config.backend,
1006            plate: plate_content,
1007            example: example_content,
1008            schema,
1009            defaults,
1010            examples,
1011            files: root,
1012        };
1013
1014        Ok(quill)
1015    }
1016
1017    /// Create a Quill from a JSON representation
1018    ///
1019    /// Parses a JSON string into an in-memory file tree and validates it. The
1020    /// precise JSON contract is documented in `designs/QUILL.md`.
1021    /// The JSON format MUST have a root object with a `files` key. The optional
1022    /// `metadata` key provides additional metadata that overrides defaults.
1023    pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
1024        use serde_json::Value as JsonValue;
1025
1026        let json: JsonValue =
1027            serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1028
1029        let obj = json.as_object().ok_or("Root must be an object")?;
1030
1031        // Extract files (required)
1032        let files_obj = obj
1033            .get("files")
1034            .and_then(|v| v.as_object())
1035            .ok_or("Missing or invalid 'files' key")?;
1036
1037        // Parse file tree
1038        let mut root_files = HashMap::new();
1039        for (key, value) in files_obj {
1040            root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
1041        }
1042
1043        let root = FileTreeNode::Directory { files: root_files };
1044
1045        // Create Quill from tree
1046        Self::from_tree(root)
1047    }
1048
1049    /// Recursively load all files from a directory into a tree structure
1050    fn load_directory_as_tree(
1051        current_dir: &Path,
1052        base_dir: &Path,
1053        ignore: &QuillIgnore,
1054    ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
1055        use std::fs;
1056
1057        if !current_dir.exists() {
1058            return Ok(FileTreeNode::Directory {
1059                files: HashMap::new(),
1060            });
1061        }
1062
1063        let mut files = HashMap::new();
1064
1065        for entry in fs::read_dir(current_dir)? {
1066            let entry = entry?;
1067            let path = entry.path();
1068            let relative_path = path
1069                .strip_prefix(base_dir)
1070                .map_err(|e| format!("Failed to get relative path: {}", e))?
1071                .to_path_buf();
1072
1073            // Check if this path should be ignored
1074            if ignore.is_ignored(&relative_path) {
1075                continue;
1076            }
1077
1078            // Get the filename
1079            let filename = path
1080                .file_name()
1081                .and_then(|n| n.to_str())
1082                .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
1083                .to_string();
1084
1085            if path.is_file() {
1086                let contents = fs::read(&path)
1087                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
1088
1089                files.insert(filename, FileTreeNode::File { contents });
1090            } else if path.is_dir() {
1091                // Recursively process subdirectory
1092                let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
1093                files.insert(filename, subdir_tree);
1094            }
1095        }
1096
1097        Ok(FileTreeNode::Directory { files })
1098    }
1099
1100    /// Get the list of typst packages to download, if specified in Quill.yaml
1101    pub fn typst_packages(&self) -> Vec<String> {
1102        self.metadata
1103            .get("typst_packages")
1104            .and_then(|v| v.as_array())
1105            .map(|arr| {
1106                arr.iter()
1107                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
1108                    .collect()
1109            })
1110            .unwrap_or_default()
1111    }
1112
1113    /// Get default values from the cached schema defaults
1114    ///
1115    /// Returns a reference to the pre-computed defaults HashMap that was extracted
1116    /// during Quill construction. This is more efficient than re-parsing the schema.
1117    ///
1118    /// This is used by `ParsedDocument::with_defaults()` to apply default values
1119    /// to missing fields.
1120    pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
1121        &self.defaults
1122    }
1123
1124    /// Get example values from the cached schema examples
1125    ///
1126    /// Returns a reference to the pre-computed examples HashMap that was extracted
1127    /// during Quill construction. This is more efficient than re-parsing the schema.
1128    pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
1129        &self.examples
1130    }
1131
1132    /// Get file contents by path (relative to quill root)
1133    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
1134        self.files.get_file(path)
1135    }
1136
1137    /// Check if a file exists in memory
1138    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1139        self.files.file_exists(path)
1140    }
1141
1142    /// Check if a directory exists in memory
1143    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1144        self.files.dir_exists(path)
1145    }
1146
1147    /// List files in a directory (non-recursive, returns file names only)
1148    pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1149        self.files.list_files(path)
1150    }
1151
1152    /// List subdirectories in a directory (non-recursive, returns directory names only)
1153    pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1154        self.files.list_subdirectories(path)
1155    }
1156
1157    /// List all files in a directory (returns paths relative to quill root)
1158    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1159        let dir_path = dir_path.as_ref();
1160        let filenames = self.files.list_files(dir_path);
1161
1162        // Convert filenames to full paths
1163        filenames
1164            .iter()
1165            .map(|name| {
1166                if dir_path == Path::new("") {
1167                    PathBuf::from(name)
1168                } else {
1169                    dir_path.join(name)
1170                }
1171            })
1172            .collect()
1173    }
1174
1175    /// List all directories in a directory (returns paths relative to quill root)
1176    pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1177        let dir_path = dir_path.as_ref();
1178        let subdirs = self.files.list_subdirectories(dir_path);
1179
1180        // Convert subdirectory names to full paths
1181        subdirs
1182            .iter()
1183            .map(|name| {
1184                if dir_path == Path::new("") {
1185                    PathBuf::from(name)
1186                } else {
1187                    dir_path.join(name)
1188                }
1189            })
1190            .collect()
1191    }
1192
1193    /// Get all files matching a pattern (supports glob-style wildcards)
1194    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1195        let pattern_str = pattern.as_ref().to_string_lossy();
1196        let mut matches = Vec::new();
1197
1198        // Compile the glob pattern
1199        let glob_pattern = match glob::Pattern::new(&pattern_str) {
1200            Ok(pat) => pat,
1201            Err(_) => return matches, // Invalid pattern returns empty results
1202        };
1203
1204        // Recursively search the tree for matching files
1205        Self::find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1206
1207        matches.sort();
1208        matches
1209    }
1210
1211    /// Helper method to recursively search for files matching a pattern
1212    fn find_files_recursive(
1213        node: &FileTreeNode,
1214        current_path: &Path,
1215        pattern: &glob::Pattern,
1216        matches: &mut Vec<PathBuf>,
1217    ) {
1218        match node {
1219            FileTreeNode::File { .. } => {
1220                let path_str = current_path.to_string_lossy();
1221                if pattern.matches(&path_str) {
1222                    matches.push(current_path.to_path_buf());
1223                }
1224            }
1225            FileTreeNode::Directory { files } => {
1226                for (name, child_node) in files {
1227                    let child_path = if current_path == Path::new("") {
1228                        PathBuf::from(name)
1229                    } else {
1230                        current_path.join(name)
1231                    };
1232                    Self::find_files_recursive(child_node, &child_path, pattern, matches);
1233                }
1234            }
1235        }
1236    }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242    use std::fs;
1243    use tempfile::TempDir;
1244
1245    #[test]
1246    fn test_quillignore_parsing() {
1247        let ignore_content = r#"
1248# This is a comment
1249*.tmp
1250target/
1251node_modules/
1252.git/
1253"#;
1254        let ignore = QuillIgnore::from_content(ignore_content);
1255        assert_eq!(ignore.patterns.len(), 4);
1256        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1257        assert!(ignore.patterns.contains(&"target/".to_string()));
1258    }
1259
1260    #[test]
1261    fn test_quillignore_matching() {
1262        let ignore = QuillIgnore::new(vec![
1263            "*.tmp".to_string(),
1264            "target/".to_string(),
1265            "node_modules/".to_string(),
1266            ".git/".to_string(),
1267        ]);
1268
1269        // Test file patterns
1270        assert!(ignore.is_ignored("test.tmp"));
1271        assert!(ignore.is_ignored("path/to/file.tmp"));
1272        assert!(!ignore.is_ignored("test.txt"));
1273
1274        // Test directory patterns
1275        assert!(ignore.is_ignored("target"));
1276        assert!(ignore.is_ignored("target/debug"));
1277        assert!(ignore.is_ignored("target/debug/deps"));
1278        assert!(!ignore.is_ignored("src/target.rs"));
1279
1280        assert!(ignore.is_ignored("node_modules"));
1281        assert!(ignore.is_ignored("node_modules/package"));
1282        assert!(!ignore.is_ignored("my_node_modules"));
1283    }
1284
1285    #[test]
1286    fn test_in_memory_file_system() {
1287        let temp_dir = TempDir::new().unwrap();
1288        let quill_dir = temp_dir.path();
1289
1290        // Create test files
1291        fs::write(
1292            quill_dir.join("Quill.yaml"),
1293            "Quill:\n  name: \"test\"\n  version: \"1.0\"\n  backend: \"typst\"\n  plate_file: \"plate.typ\"\n  description: \"Test quill\"",
1294        )
1295        .unwrap();
1296        fs::write(quill_dir.join("plate.typ"), "test plate").unwrap();
1297
1298        let assets_dir = quill_dir.join("assets");
1299        fs::create_dir_all(&assets_dir).unwrap();
1300        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1301
1302        let packages_dir = quill_dir.join("packages");
1303        fs::create_dir_all(&packages_dir).unwrap();
1304        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1305
1306        // Load quill
1307        let quill = Quill::from_path(quill_dir).unwrap();
1308
1309        // Test file access
1310        assert!(quill.file_exists("plate.typ"));
1311        assert!(quill.file_exists("assets/test.txt"));
1312        assert!(quill.file_exists("packages/package.typ"));
1313        assert!(!quill.file_exists("nonexistent.txt"));
1314
1315        // Test file content
1316        let asset_content = quill.get_file("assets/test.txt").unwrap();
1317        assert_eq!(asset_content, b"asset content");
1318
1319        // Test directory listing
1320        let asset_files = quill.list_directory("assets");
1321        assert_eq!(asset_files.len(), 1);
1322        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1323    }
1324
1325    #[test]
1326    fn test_quillignore_integration() {
1327        let temp_dir = TempDir::new().unwrap();
1328        let quill_dir = temp_dir.path();
1329
1330        // Create .quillignore
1331        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1332
1333        // Create test files
1334        fs::write(
1335            quill_dir.join("Quill.yaml"),
1336            "Quill:\n  name: \"test\"\n  version: \"1.0\"\n  backend: \"typst\"\n  plate_file: \"plate.typ\"\n  description: \"Test quill\"",
1337        )
1338        .unwrap();
1339        fs::write(quill_dir.join("plate.typ"), "test template").unwrap();
1340        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1341
1342        let target_dir = quill_dir.join("target");
1343        fs::create_dir_all(&target_dir).unwrap();
1344        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1345
1346        // Load quill
1347        let quill = Quill::from_path(quill_dir).unwrap();
1348
1349        // Test that ignored files are not loaded
1350        assert!(quill.file_exists("plate.typ"));
1351        assert!(!quill.file_exists("should_ignore.tmp"));
1352        assert!(!quill.file_exists("target/debug.txt"));
1353    }
1354
1355    #[test]
1356    fn test_find_files_pattern() {
1357        let temp_dir = TempDir::new().unwrap();
1358        let quill_dir = temp_dir.path();
1359
1360        // Create test directory structure
1361        fs::write(
1362            quill_dir.join("Quill.yaml"),
1363            "Quill:\n  name: \"test\"\n  version: \"1.0\"\n  backend: \"typst\"\n  plate_file: \"plate.typ\"\n  description: \"Test quill\"",
1364        )
1365        .unwrap();
1366        fs::write(quill_dir.join("plate.typ"), "template").unwrap();
1367
1368        let assets_dir = quill_dir.join("assets");
1369        fs::create_dir_all(&assets_dir).unwrap();
1370        fs::write(assets_dir.join("image.png"), "png data").unwrap();
1371        fs::write(assets_dir.join("data.json"), "json data").unwrap();
1372
1373        let fonts_dir = assets_dir.join("fonts");
1374        fs::create_dir_all(&fonts_dir).unwrap();
1375        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1376
1377        // Load quill
1378        let quill = Quill::from_path(quill_dir).unwrap();
1379
1380        // Test pattern matching
1381        let all_assets = quill.find_files("assets/*");
1382        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
1383
1384        let typ_files = quill.find_files("*.typ");
1385        assert_eq!(typ_files.len(), 1);
1386        assert!(typ_files.contains(&PathBuf::from("plate.typ")));
1387    }
1388
1389    #[test]
1390    fn test_new_standardized_yaml_format() {
1391        let temp_dir = TempDir::new().unwrap();
1392        let quill_dir = temp_dir.path();
1393
1394        // Create test files using new standardized format
1395        let yaml_content = r#"
1396Quill:
1397  name: my-custom-quill
1398  version: "1.0"
1399  backend: typst
1400  plate_file: custom_plate.typ
1401  description: Test quill with new format
1402  author: Test Author
1403"#;
1404        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1405        fs::write(
1406            quill_dir.join("custom_plate.typ"),
1407            "= Custom Template\n\nThis is a custom template.",
1408        )
1409        .unwrap();
1410
1411        // Load quill
1412        let quill = Quill::from_path(quill_dir).unwrap();
1413
1414        // Test that name comes from YAML, not directory
1415        assert_eq!(quill.name, "my-custom-quill");
1416
1417        // Test that backend is in metadata
1418        assert!(quill.metadata.contains_key("backend"));
1419        if let Some(backend_val) = quill.metadata.get("backend") {
1420            if let Some(backend_str) = backend_val.as_str() {
1421                assert_eq!(backend_str, "typst");
1422            } else {
1423                panic!("Backend value is not a string");
1424            }
1425        }
1426
1427        // Test that other fields are in metadata including version
1428        assert!(quill.metadata.contains_key("description"));
1429        assert!(quill.metadata.contains_key("author"));
1430        assert!(quill.metadata.contains_key("version")); // version should now be included
1431        if let Some(version_val) = quill.metadata.get("version") {
1432            if let Some(version_str) = version_val.as_str() {
1433                assert_eq!(version_str, "1.0");
1434            }
1435        }
1436
1437        // Test that plate template content is loaded correctly
1438        assert!(quill.plate.unwrap().contains("Custom Template"));
1439    }
1440
1441    #[test]
1442    fn test_typst_packages_parsing() {
1443        let temp_dir = TempDir::new().unwrap();
1444        let quill_dir = temp_dir.path();
1445
1446        let yaml_content = r#"
1447Quill:
1448  name: "test-quill"
1449  version: "1.0"
1450  backend: "typst"
1451  plate_file: "plate.typ"
1452  description: "Test quill for packages"
1453
1454typst:
1455  packages:
1456    - "@preview/bubble:0.2.2"
1457    - "@preview/example:1.0.0"
1458"#;
1459
1460        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1461        fs::write(quill_dir.join("plate.typ"), "test").unwrap();
1462
1463        let quill = Quill::from_path(quill_dir).unwrap();
1464        let packages = quill.typst_packages();
1465
1466        assert_eq!(packages.len(), 2);
1467        assert_eq!(packages[0], "@preview/bubble:0.2.2");
1468        assert_eq!(packages[1], "@preview/example:1.0.0");
1469    }
1470
1471    #[test]
1472    fn test_template_loading() {
1473        let temp_dir = TempDir::new().unwrap();
1474        let quill_dir = temp_dir.path();
1475
1476        // Create test files with example specified
1477        let yaml_content = r#"Quill:
1478  name: "test-with-template"
1479  version: "1.0"
1480  backend: "typst"
1481  plate_file: "plate.typ"
1482  example_file: "example.md"
1483  description: "Test quill with template"
1484"#;
1485        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1486        fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1487        fs::write(
1488            quill_dir.join("example.md"),
1489            "---\ntitle: Test\n---\n\nThis is a test template.",
1490        )
1491        .unwrap();
1492
1493        // Load quill
1494        let quill = Quill::from_path(quill_dir).unwrap();
1495
1496        // Test that example content is loaded and includes some the text
1497        assert!(quill.example.is_some());
1498        let example = quill.example.unwrap();
1499        assert!(example.contains("title: Test"));
1500        assert!(example.contains("This is a test template"));
1501
1502        // Test that plate template is still loaded
1503        assert_eq!(quill.plate.unwrap(), "plate content");
1504    }
1505
1506    #[test]
1507    fn test_template_smart_default() {
1508        let temp_dir = TempDir::new().unwrap();
1509        let quill_dir = temp_dir.path();
1510
1511        // Create test files without example specified
1512        let yaml_content = r#"Quill:
1513  name: "test-smart-default"
1514  version: "1.0"
1515  backend: "typst"
1516  plate_file: "plate.typ"
1517  description: "Test quill with smart default"
1518"#;
1519        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1520        fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1521        // Create example.md which should be picked up automatically
1522        fs::write(
1523            quill_dir.join("example.md"),
1524            "---\ntitle: Smart Default\n---\n\nPicked up automatically.",
1525        )
1526        .unwrap();
1527
1528        // Load quill
1529        let quill = Quill::from_path(quill_dir).unwrap();
1530
1531        // Test that example content is loaded
1532        assert!(quill.example.is_some());
1533        let example = quill.example.unwrap();
1534        assert!(example.contains("title: Smart Default"));
1535        assert!(example.contains("Picked up automatically"));
1536    }
1537
1538    #[test]
1539    fn test_template_optional() {
1540        let temp_dir = TempDir::new().unwrap();
1541        let quill_dir = temp_dir.path();
1542
1543        // Create test files without example specified
1544        let yaml_content = r#"Quill:
1545  name: "test-without-template"
1546  version: "1.0"
1547  backend: "typst"
1548  plate_file: "plate.typ"
1549  description: "Test quill without template"
1550"#;
1551        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1552        fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1553
1554        // Load quill
1555        let quill = Quill::from_path(quill_dir).unwrap();
1556
1557        // Test that example fields are None
1558        assert_eq!(quill.example, None);
1559
1560        // Test that plate template is still loaded
1561        assert_eq!(quill.plate.unwrap(), "plate content");
1562    }
1563
1564    #[test]
1565    fn test_from_tree() {
1566        // Create a simple in-memory file tree
1567        let mut root_files = HashMap::new();
1568
1569        // Add Quill.yaml
1570        let quill_yaml = r#"Quill:
1571  name: "test-from-tree"
1572  version: "1.0"
1573  backend: "typst"
1574  plate_file: "plate.typ"
1575  description: "A test quill from tree"
1576"#;
1577        root_files.insert(
1578            "Quill.yaml".to_string(),
1579            FileTreeNode::File {
1580                contents: quill_yaml.as_bytes().to_vec(),
1581            },
1582        );
1583
1584        // Add plate file
1585        let plate_content = "= Test Template\n\nThis is a test.";
1586        root_files.insert(
1587            "plate.typ".to_string(),
1588            FileTreeNode::File {
1589                contents: plate_content.as_bytes().to_vec(),
1590            },
1591        );
1592
1593        let root = FileTreeNode::Directory { files: root_files };
1594
1595        // Create Quill from tree
1596        let quill = Quill::from_tree(root).unwrap();
1597
1598        // Validate the quill
1599        assert_eq!(quill.name, "test-from-tree");
1600        assert_eq!(quill.plate.unwrap(), plate_content);
1601        assert!(quill.metadata.contains_key("backend"));
1602        assert!(quill.metadata.contains_key("description"));
1603    }
1604
1605    #[test]
1606    fn test_from_tree_with_template() {
1607        let mut root_files = HashMap::new();
1608
1609        // Add Quill.yaml with example specified
1610        // Add Quill.yaml with example specified
1611        let quill_yaml = r#"
1612Quill:
1613  name: test-tree-template
1614  version: "1.0"
1615  backend: typst
1616  plate_file: plate.typ
1617  example_file: template.md
1618  description: Test tree with template
1619"#;
1620        root_files.insert(
1621            "Quill.yaml".to_string(),
1622            FileTreeNode::File {
1623                contents: quill_yaml.as_bytes().to_vec(),
1624            },
1625        );
1626
1627        // Add plate file
1628        root_files.insert(
1629            "plate.typ".to_string(),
1630            FileTreeNode::File {
1631                contents: b"plate content".to_vec(),
1632            },
1633        );
1634
1635        // Add template file
1636        let template_content = "# {{ title }}\n\n{{ body }}";
1637        root_files.insert(
1638            "template.md".to_string(),
1639            FileTreeNode::File {
1640                contents: template_content.as_bytes().to_vec(),
1641            },
1642        );
1643
1644        let root = FileTreeNode::Directory { files: root_files };
1645
1646        // Create Quill from tree
1647        let quill = Quill::from_tree(root).unwrap();
1648
1649        // Validate template is loaded
1650        assert_eq!(quill.example, Some(template_content.to_string()));
1651    }
1652
1653    #[test]
1654    fn test_from_json() {
1655        // Create JSON representation of a Quill using new format
1656        let json_str = r#"{
1657            "metadata": {
1658                "name": "test_from_json"
1659            },
1660            "files": {
1661                "Quill.yaml": {
1662                    "contents": "Quill:\n  name: test_from_json\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Test quill from JSON\n"
1663                },
1664                "plate.typ": {
1665                    "contents": "= Test Plate\n\nThis is test content."
1666                }
1667            }
1668        }"#;
1669
1670        // Create Quill from JSON
1671        let quill = Quill::from_json(json_str).unwrap();
1672
1673        // Validate the quill
1674        assert_eq!(quill.name, "test_from_json");
1675        assert!(quill.plate.unwrap().contains("Test Plate"));
1676        assert!(quill.metadata.contains_key("backend"));
1677    }
1678
1679    #[test]
1680    fn test_from_json_with_byte_array() {
1681        // Create JSON with byte array representation using new format
1682        let json_str = r#"{
1683            "files": {
1684                "Quill.yaml": {
1685                    "contents": "Quill:\n  name: test\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Test quill\n"
1686                },
1687                "plate.typ": {
1688                    "contents": "test plate"
1689                }
1690            }
1691        }"#;
1692
1693        // Create Quill from JSON
1694        let quill = Quill::from_json(json_str).unwrap();
1695
1696        // Validate the quill was created
1697        assert_eq!(quill.name, "test");
1698        assert_eq!(quill.plate.unwrap(), "test plate");
1699    }
1700
1701    #[test]
1702    fn test_from_json_missing_files() {
1703        // JSON without files field should fail
1704        let json_str = r#"{
1705            "metadata": {
1706                "name": "test"
1707            }
1708        }"#;
1709
1710        let result = Quill::from_json(json_str);
1711        assert!(result.is_err());
1712        // Should fail because there's no 'files' key
1713        assert!(result.unwrap_err().to_string().contains("files"));
1714    }
1715
1716    #[test]
1717    fn test_from_json_tree_structure() {
1718        // Test the new tree structure format
1719        let json_str = r#"{
1720            "files": {
1721                "Quill.yaml": {
1722                    "contents": "Quill:\n  name: test_tree_json\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Test tree JSON\n"
1723                },
1724                "plate.typ": {
1725                    "contents": "= Test Plate\n\nTree structure content."
1726                }
1727            }
1728        }"#;
1729
1730        let quill = Quill::from_json(json_str).unwrap();
1731
1732        assert_eq!(quill.name, "test_tree_json");
1733        assert!(quill.plate.unwrap().contains("Tree structure content"));
1734        assert!(quill.metadata.contains_key("backend"));
1735    }
1736
1737    #[test]
1738    fn test_from_json_nested_tree_structure() {
1739        // Test nested directories in tree structure
1740        let json_str = r#"{
1741            "files": {
1742                "Quill.yaml": {
1743                    "contents": "Quill:\n  name: nested_test\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Nested test\n"
1744                },
1745                "plate.typ": {
1746                    "contents": "plate"
1747                },
1748                "src": {
1749                    "main.rs": {
1750                        "contents": "fn main() {}"
1751                    },
1752                    "lib.rs": {
1753                        "contents": "// lib"
1754                    }
1755                }
1756            }
1757        }"#;
1758
1759        let quill = Quill::from_json(json_str).unwrap();
1760
1761        assert_eq!(quill.name, "nested_test");
1762        // Verify nested files are accessible
1763        assert!(quill.file_exists("src/main.rs"));
1764        assert!(quill.file_exists("src/lib.rs"));
1765
1766        let main_rs = quill.get_file("src/main.rs").unwrap();
1767        assert_eq!(main_rs, b"fn main() {}");
1768    }
1769
1770    #[test]
1771    fn test_from_tree_structure_direct() {
1772        // Test using from_tree_structure directly
1773        let mut root_files = HashMap::new();
1774
1775        root_files.insert(
1776            "Quill.yaml".to_string(),
1777            FileTreeNode::File {
1778                contents:
1779                    b"Quill:\n  name: direct_tree\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Direct tree test\n"
1780                        .to_vec(),
1781            },
1782        );
1783
1784        root_files.insert(
1785            "plate.typ".to_string(),
1786            FileTreeNode::File {
1787                contents: b"plate content".to_vec(),
1788            },
1789        );
1790
1791        // Add a nested directory
1792        let mut src_files = HashMap::new();
1793        src_files.insert(
1794            "main.rs".to_string(),
1795            FileTreeNode::File {
1796                contents: b"fn main() {}".to_vec(),
1797            },
1798        );
1799
1800        root_files.insert(
1801            "src".to_string(),
1802            FileTreeNode::Directory { files: src_files },
1803        );
1804
1805        let root = FileTreeNode::Directory { files: root_files };
1806
1807        let quill = Quill::from_tree(root).unwrap();
1808
1809        assert_eq!(quill.name, "direct_tree");
1810        assert!(quill.file_exists("src/main.rs"));
1811        assert!(quill.file_exists("plate.typ"));
1812    }
1813
1814    #[test]
1815    fn test_from_json_with_metadata_override() {
1816        // Test that metadata key overrides name from Quill.yaml
1817        let json_str = r#"{
1818            "metadata": {
1819                "name": "override_name"
1820            },
1821            "files": {
1822                "Quill.yaml": {
1823                    "contents": "Quill:\n  name: toml_name\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: TOML name test\n"
1824                },
1825                "plate.typ": {
1826                    "contents": "= plate"
1827                }
1828            }
1829        }"#;
1830
1831        let quill = Quill::from_json(json_str).unwrap();
1832        // Metadata name should be used as default, but Quill.yaml takes precedence
1833        // when from_tree is called
1834        assert_eq!(quill.name, "toml_name");
1835    }
1836
1837    #[test]
1838    fn test_from_json_empty_directory() {
1839        // Test that empty directories are supported
1840        let json_str = r#"{
1841            "files": {
1842                "Quill.yaml": {
1843                    "contents": "Quill:\n  name: empty_dir_test\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Empty directory test\n"
1844                },
1845                "plate.typ": {
1846                    "contents": "plate"
1847                },
1848                "empty_dir": {}
1849            }
1850        }"#;
1851
1852        let quill = Quill::from_json(json_str).unwrap();
1853        assert_eq!(quill.name, "empty_dir_test");
1854        assert!(quill.dir_exists("empty_dir"));
1855        assert!(!quill.file_exists("empty_dir"));
1856    }
1857
1858    #[test]
1859    fn test_dir_exists_and_list_apis() {
1860        let mut root_files = HashMap::new();
1861
1862        // Add Quill.yaml
1863        root_files.insert(
1864            "Quill.yaml".to_string(),
1865            FileTreeNode::File {
1866                contents: b"Quill:\n  name: test\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Test quill\n"
1867                    .to_vec(),
1868            },
1869        );
1870
1871        // Add plate file
1872        root_files.insert(
1873            "plate.typ".to_string(),
1874            FileTreeNode::File {
1875                contents: b"plate content".to_vec(),
1876            },
1877        );
1878
1879        // Add assets directory with files
1880        let mut assets_files = HashMap::new();
1881        assets_files.insert(
1882            "logo.png".to_string(),
1883            FileTreeNode::File {
1884                contents: vec![137, 80, 78, 71],
1885            },
1886        );
1887        assets_files.insert(
1888            "icon.svg".to_string(),
1889            FileTreeNode::File {
1890                contents: b"<svg></svg>".to_vec(),
1891            },
1892        );
1893
1894        // Add subdirectory in assets
1895        let mut fonts_files = HashMap::new();
1896        fonts_files.insert(
1897            "font.ttf".to_string(),
1898            FileTreeNode::File {
1899                contents: b"font data".to_vec(),
1900            },
1901        );
1902        assets_files.insert(
1903            "fonts".to_string(),
1904            FileTreeNode::Directory { files: fonts_files },
1905        );
1906
1907        root_files.insert(
1908            "assets".to_string(),
1909            FileTreeNode::Directory {
1910                files: assets_files,
1911            },
1912        );
1913
1914        // Add empty directory
1915        root_files.insert(
1916            "empty".to_string(),
1917            FileTreeNode::Directory {
1918                files: HashMap::new(),
1919            },
1920        );
1921
1922        let root = FileTreeNode::Directory { files: root_files };
1923        let quill = Quill::from_tree(root).unwrap();
1924
1925        // Test dir_exists
1926        assert!(quill.dir_exists("assets"));
1927        assert!(quill.dir_exists("assets/fonts"));
1928        assert!(quill.dir_exists("empty"));
1929        assert!(!quill.dir_exists("nonexistent"));
1930        assert!(!quill.dir_exists("plate.typ")); // file, not directory
1931
1932        // Test file_exists
1933        assert!(quill.file_exists("plate.typ"));
1934        assert!(quill.file_exists("assets/logo.png"));
1935        assert!(quill.file_exists("assets/fonts/font.ttf"));
1936        assert!(!quill.file_exists("assets")); // directory, not file
1937
1938        // Test list_files
1939        let root_files_list = quill.list_files("");
1940        assert_eq!(root_files_list.len(), 2); // Quill.yaml and plate.typ
1941        assert!(root_files_list.contains(&"Quill.yaml".to_string()));
1942        assert!(root_files_list.contains(&"plate.typ".to_string()));
1943
1944        let assets_files_list = quill.list_files("assets");
1945        assert_eq!(assets_files_list.len(), 2); // logo.png and icon.svg
1946        assert!(assets_files_list.contains(&"logo.png".to_string()));
1947        assert!(assets_files_list.contains(&"icon.svg".to_string()));
1948
1949        // Test list_subdirectories
1950        let root_subdirs = quill.list_subdirectories("");
1951        assert_eq!(root_subdirs.len(), 2); // assets and empty
1952        assert!(root_subdirs.contains(&"assets".to_string()));
1953        assert!(root_subdirs.contains(&"empty".to_string()));
1954
1955        let assets_subdirs = quill.list_subdirectories("assets");
1956        assert_eq!(assets_subdirs.len(), 1); // fonts
1957        assert!(assets_subdirs.contains(&"fonts".to_string()));
1958
1959        let empty_subdirs = quill.list_subdirectories("empty");
1960        assert_eq!(empty_subdirs.len(), 0);
1961    }
1962
1963    #[test]
1964    fn test_field_schemas_parsing() {
1965        let mut root_files = HashMap::new();
1966
1967        // Add Quill.yaml with field schemas
1968        let quill_yaml = r#"Quill:
1969  name: "taro"
1970  version: "1.0"
1971  backend: "typst"
1972  plate_file: "plate.typ"
1973  example_file: "taro.md"
1974  description: "Test template for field schemas"
1975
1976fields:
1977  author:
1978    type: "string"
1979    description: "Author of document"
1980  ice_cream:
1981    type: "string"
1982    description: "favorite ice cream flavor"
1983  title:
1984    type: "string"
1985    description: "title of document"
1986"#;
1987        root_files.insert(
1988            "Quill.yaml".to_string(),
1989            FileTreeNode::File {
1990                contents: quill_yaml.as_bytes().to_vec(),
1991            },
1992        );
1993
1994        // Add plate file
1995        let plate_content = "= Test Template\n\nThis is a test.";
1996        root_files.insert(
1997            "plate.typ".to_string(),
1998            FileTreeNode::File {
1999                contents: plate_content.as_bytes().to_vec(),
2000            },
2001        );
2002
2003        // Add template file
2004        root_files.insert(
2005            "taro.md".to_string(),
2006            FileTreeNode::File {
2007                contents: b"# Template".to_vec(),
2008            },
2009        );
2010
2011        let root = FileTreeNode::Directory { files: root_files };
2012
2013        // Create Quill from tree
2014        let quill = Quill::from_tree(root).unwrap();
2015
2016        // Validate field schemas were parsed (author, ice_cream, title, BODY)
2017        assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 4);
2018        assert!(quill.schema["properties"]
2019            .as_object()
2020            .unwrap()
2021            .contains_key("author"));
2022        assert!(quill.schema["properties"]
2023            .as_object()
2024            .unwrap()
2025            .contains_key("ice_cream"));
2026        assert!(quill.schema["properties"]
2027            .as_object()
2028            .unwrap()
2029            .contains_key("title"));
2030        assert!(quill.schema["properties"]
2031            .as_object()
2032            .unwrap()
2033            .contains_key("BODY"));
2034
2035        // Verify author field schema
2036        let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
2037        assert_eq!(author_schema["description"], "Author of document");
2038
2039        // Verify ice_cream field schema (no required field, should default to false)
2040        let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
2041        assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
2042
2043        // Verify title field schema
2044        let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
2045        assert_eq!(title_schema["description"], "title of document");
2046    }
2047
2048    #[test]
2049    fn test_field_schema_struct() {
2050        // Test creating FieldSchema with minimal fields
2051        let schema1 = FieldSchema::new(
2052            "test_name".to_string(),
2053            FieldType::String,
2054            Some("Test description".to_string()),
2055        );
2056        assert_eq!(schema1.description, Some("Test description".to_string()));
2057        assert_eq!(schema1.r#type, FieldType::String);
2058        assert_eq!(schema1.examples, None);
2059        assert_eq!(schema1.default, None);
2060
2061        // Test parsing FieldSchema from YAML with all fields
2062        let yaml_str = r#"
2063description: "Full field schema"
2064type: "string"
2065examples:
2066  - "Example value"
2067default: "Default value"
2068"#;
2069        let quill_value = QuillValue::from_yaml_str(yaml_str).unwrap();
2070        let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
2071        assert_eq!(schema2.name, "test_name");
2072        assert_eq!(schema2.description, Some("Full field schema".to_string()));
2073        assert_eq!(schema2.r#type, FieldType::String);
2074        assert_eq!(
2075            schema2
2076                .examples
2077                .as_ref()
2078                .and_then(|v| v.as_array())
2079                .and_then(|arr| arr.first())
2080                .and_then(|v| v.as_str()),
2081            Some("Example value")
2082        );
2083        assert_eq!(
2084            schema2.default.as_ref().and_then(|v| v.as_str()),
2085            Some("Default value")
2086        );
2087    }
2088
2089    #[test]
2090    fn test_quill_without_plate_file() {
2091        // Test creating a Quill without specifying a plate file
2092        let mut root_files = HashMap::new();
2093
2094        // Add Quill.yaml without plate field
2095        let quill_yaml = r#"Quill:
2096  name: "test-no-plate"
2097  version: "1.0"
2098  backend: "typst"
2099  description: "Test quill without plate file"
2100"#;
2101        root_files.insert(
2102            "Quill.yaml".to_string(),
2103            FileTreeNode::File {
2104                contents: quill_yaml.as_bytes().to_vec(),
2105            },
2106        );
2107
2108        let root = FileTreeNode::Directory { files: root_files };
2109
2110        // Create Quill from tree
2111        let quill = Quill::from_tree(root).unwrap();
2112
2113        // Validate that plate is null (will use auto plate)
2114        assert!(quill.plate.clone().is_none());
2115        assert_eq!(quill.name, "test-no-plate");
2116    }
2117
2118    #[test]
2119    fn test_quill_config_from_yaml() {
2120        // Test parsing QuillConfig from YAML content
2121        let yaml_content = r#"
2122Quill:
2123  name: test_config
2124  version: "1.0"
2125  backend: typst
2126  description: Test configuration parsing
2127  author: Test Author
2128  plate_file: plate.typ
2129  example_file: example.md
2130
2131typst:
2132  packages: 
2133    - "@preview/bubble:0.2.2"
2134
2135fields:
2136  title:
2137    description: Document title
2138    type: string
2139  author:
2140    type: string
2141    description: Document author
2142"#;
2143
2144        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2145
2146        // Verify required fields
2147        assert_eq!(config.document.name, "test_config");
2148        assert_eq!(config.backend, "typst");
2149        assert_eq!(
2150            config.document.description,
2151            Some("Test configuration parsing".to_string())
2152        );
2153
2154        // Verify optional fields
2155        assert_eq!(config.version, "1.0");
2156        assert_eq!(config.author, "Test Author");
2157        assert_eq!(config.plate_file, Some("plate.typ".to_string()));
2158        assert_eq!(config.example_file, Some("example.md".to_string()));
2159
2160        // Verify typst config
2161        assert!(config.typst_config.contains_key("packages"));
2162
2163        // Verify field schemas
2164        assert_eq!(config.document.fields.len(), 2);
2165        assert!(config.document.fields.contains_key("title"));
2166        assert!(config.document.fields.contains_key("author"));
2167
2168        let title_field = &config.document.fields["title"];
2169        assert_eq!(title_field.description, Some("Document title".to_string()));
2170        assert_eq!(title_field.r#type, FieldType::String);
2171    }
2172
2173    #[test]
2174    fn test_quill_config_missing_required_fields() {
2175        // Test that missing required fields result in error
2176        let yaml_missing_name = r#"
2177Quill:
2178  backend: typst
2179  description: Missing name
2180"#;
2181        let result = QuillConfig::from_yaml(yaml_missing_name);
2182        assert!(result.is_err());
2183        assert!(result
2184            .unwrap_err()
2185            .to_string()
2186            .contains("Missing required 'name'"));
2187
2188        let yaml_missing_backend = r#"
2189Quill:
2190  name: test
2191  description: Missing backend
2192"#;
2193        let result = QuillConfig::from_yaml(yaml_missing_backend);
2194        assert!(result.is_err());
2195        assert!(result
2196            .unwrap_err()
2197            .to_string()
2198            .contains("Missing required 'backend'"));
2199
2200        let yaml_missing_description = r#"
2201Quill:
2202  name: test
2203  version: "1.0"
2204  backend: typst
2205"#;
2206        let result = QuillConfig::from_yaml(yaml_missing_description);
2207        assert!(result.is_err());
2208        assert!(result
2209            .unwrap_err()
2210            .to_string()
2211            .contains("Missing required 'description'"));
2212    }
2213
2214    #[test]
2215    fn test_quill_config_empty_description() {
2216        // Test that empty description results in error
2217        let yaml_empty_description = r#"
2218Quill:
2219  name: test
2220  version: "1.0"
2221  backend: typst
2222  description: "   "
2223"#;
2224        let result = QuillConfig::from_yaml(yaml_empty_description);
2225        assert!(result.is_err());
2226        assert!(result
2227            .unwrap_err()
2228            .to_string()
2229            .contains("description' field in 'Quill' section cannot be empty"));
2230    }
2231
2232    #[test]
2233    fn test_quill_config_missing_quill_section() {
2234        // Test that missing [Quill] section results in error
2235        let yaml_no_section = r#"
2236fields:
2237  title:
2238    description: Title
2239"#;
2240        let result = QuillConfig::from_yaml(yaml_no_section);
2241        assert!(result.is_err());
2242        assert!(result
2243            .unwrap_err()
2244            .to_string()
2245            .contains("Missing required 'Quill' section"));
2246    }
2247
2248    #[test]
2249    fn test_quill_from_config_metadata() {
2250        // Test that QuillConfig metadata flows through to Quill
2251        let mut root_files = HashMap::new();
2252
2253        let quill_yaml = r#"
2254Quill:
2255  name: metadata-test
2256  version: "1.0"
2257  backend: typst
2258  description: Test metadata flow
2259  author: Test Author
2260  custom_field: custom_value
2261
2262typst:
2263  packages: 
2264    - "@preview/bubble:0.2.2"
2265"#;
2266        root_files.insert(
2267            "Quill.yaml".to_string(),
2268            FileTreeNode::File {
2269                contents: quill_yaml.as_bytes().to_vec(),
2270            },
2271        );
2272
2273        let root = FileTreeNode::Directory { files: root_files };
2274        let quill = Quill::from_tree(root).unwrap();
2275
2276        // Verify metadata includes backend and description
2277        assert!(quill.metadata.contains_key("backend"));
2278        assert!(quill.metadata.contains_key("description"));
2279        assert!(quill.metadata.contains_key("author"));
2280
2281        // Verify custom field is in metadata
2282        assert!(quill.metadata.contains_key("custom_field"));
2283        assert_eq!(
2284            quill.metadata.get("custom_field").unwrap().as_str(),
2285            Some("custom_value")
2286        );
2287
2288        // Verify typst config with typst_ prefix
2289        assert!(quill.metadata.contains_key("typst_packages"));
2290    }
2291
2292    #[test]
2293    fn test_extract_defaults_method() {
2294        // Test the extract_defaults method on Quill
2295        let mut root_files = HashMap::new();
2296
2297        let quill_yaml = r#"
2298Quill:
2299  name: metadata-test-yaml
2300  version: "1.0"
2301  backend: typst
2302  description: Test metadata flow
2303  author: Test Author
2304  custom_field: custom_value
2305
2306typst:
2307  packages: 
2308    - "@preview/bubble:0.2.2"
2309
2310fields:
2311  author:
2312    type: string
2313    default: Anonymous
2314  status:
2315    type: string
2316    default: draft
2317  title:
2318    type: string
2319"#;
2320        root_files.insert(
2321            "Quill.yaml".to_string(),
2322            FileTreeNode::File {
2323                contents: quill_yaml.as_bytes().to_vec(),
2324            },
2325        );
2326
2327        let root = FileTreeNode::Directory { files: root_files };
2328        let quill = Quill::from_tree(root).unwrap();
2329
2330        // Extract defaults
2331        let defaults = quill.extract_defaults();
2332
2333        // Verify only fields with defaults are returned
2334        assert_eq!(defaults.len(), 2);
2335        assert!(!defaults.contains_key("title")); // no default
2336        assert!(defaults.contains_key("author"));
2337        assert!(defaults.contains_key("status"));
2338
2339        // Verify default values
2340        assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2341        assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2342    }
2343
2344    #[test]
2345    fn test_field_order_preservation() {
2346        let yaml_content = r#"
2347Quill:
2348  name: order-test
2349  version: "1.0"
2350  backend: typst
2351  description: Test field order
2352
2353fields:
2354  first:
2355    type: string
2356    description: First field
2357  second:
2358    type: string
2359    description: Second field
2360  third:
2361    type: string
2362    description: Third field
2363    ui:
2364      group: Test Group
2365  fourth:
2366    type: string
2367    description: Fourth field
2368"#;
2369
2370        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2371
2372        // Check that fields have correct order based on TOML position
2373        // Order is automatically generated based on field position
2374
2375        let first = config.document.fields.get("first").unwrap();
2376        assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2377
2378        let second = config.document.fields.get("second").unwrap();
2379        assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2380
2381        let third = config.document.fields.get("third").unwrap();
2382        assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2383        assert_eq!(
2384            third.ui.as_ref().unwrap().group,
2385            Some("Test Group".to_string())
2386        );
2387
2388        let fourth = config.document.fields.get("fourth").unwrap();
2389        assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2390    }
2391
2392    #[test]
2393    fn test_quill_with_all_ui_properties() {
2394        let yaml_content = r#"
2395Quill:
2396  name: full-ui-test
2397  version: "1.0"
2398  backend: typst
2399  description: Test all UI properties
2400
2401fields:
2402  author:
2403    description: The full name of the document author
2404    type: str
2405    ui:
2406      group: Author Info
2407"#;
2408
2409        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2410
2411        let author_field = &config.document.fields["author"];
2412        let ui = author_field.ui.as_ref().unwrap();
2413        assert_eq!(ui.group, Some("Author Info".to_string()));
2414        assert_eq!(ui.order, Some(0)); // First field should have order 0
2415    }
2416    #[test]
2417    fn test_field_schema_with_title_and_description() {
2418        // Test parsing field with new schema format (title + description, no tooltip)
2419        let yaml = r#"
2420title: "Field Title"
2421description: "Detailed field description"
2422type: "string"
2423examples:
2424  - "Example value"
2425ui:
2426  group: "Test Group"
2427"#;
2428        let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2429        let schema = FieldSchema::from_quill_value("test_field".to_string(), &quill_value).unwrap();
2430
2431        assert_eq!(schema.title, Some("Field Title".to_string()));
2432        assert_eq!(
2433            schema.description,
2434            Some("Detailed field description".to_string())
2435        );
2436
2437        assert_eq!(
2438            schema
2439                .examples
2440                .as_ref()
2441                .and_then(|v| v.as_array())
2442                .and_then(|arr| arr.first())
2443                .and_then(|v| v.as_str()),
2444            Some("Example value")
2445        );
2446
2447        let ui = schema.ui.as_ref().unwrap();
2448        assert_eq!(ui.group, Some("Test Group".to_string()));
2449    }
2450
2451    #[test]
2452    fn test_parse_card_field_type() {
2453        // Test that FieldSchema no longer supports type = "card" (cards are in CardSchema now)
2454        let yaml = r#"
2455type: "string"
2456title: "Simple Field"
2457description: "A simple string field"
2458"#;
2459        let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2460        let schema =
2461            FieldSchema::from_quill_value("simple_field".to_string(), &quill_value).unwrap();
2462
2463        assert_eq!(schema.name, "simple_field");
2464        assert_eq!(schema.r#type, FieldType::String);
2465        assert_eq!(schema.title, Some("Simple Field".to_string()));
2466        assert_eq!(
2467            schema.description,
2468            Some("A simple string field".to_string())
2469        );
2470    }
2471
2472    #[test]
2473    fn test_parse_card_with_fields_in_yaml() {
2474        // Test parsing [cards] section with [cards.X.fields.Y] syntax
2475        let yaml_content = r#"
2476Quill:
2477  name: cards-fields-test
2478  version: "1.0"
2479  backend: typst
2480  description: Test [cards.X.fields.Y] syntax
2481
2482cards:
2483  endorsements:
2484    title: Endorsements
2485    description: Chain of endorsements
2486    fields:
2487      name:
2488        type: string
2489        title: Endorser Name
2490        description: Name of the endorsing official
2491        required: true
2492      org:
2493        type: string
2494        title: Organization
2495        description: Endorser's organization
2496        default: Unknown
2497"#;
2498
2499        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2500
2501        // Verify the card was parsed into config.cards
2502        assert!(config.cards.contains_key("endorsements"));
2503        let card = config.cards.get("endorsements").unwrap();
2504
2505        assert_eq!(card.name, "endorsements");
2506        assert_eq!(card.title, Some("Endorsements".to_string()));
2507        assert_eq!(card.description, Some("Chain of endorsements".to_string()));
2508
2509        // Verify card fields
2510        assert_eq!(card.fields.len(), 2);
2511
2512        let name_field = card.fields.get("name").unwrap();
2513        assert_eq!(name_field.r#type, FieldType::String);
2514        assert_eq!(name_field.title, Some("Endorser Name".to_string()));
2515        assert!(name_field.required);
2516
2517        let org_field = card.fields.get("org").unwrap();
2518        assert_eq!(org_field.r#type, FieldType::String);
2519        assert!(org_field.default.is_some());
2520        assert_eq!(
2521            org_field.default.as_ref().unwrap().as_str(),
2522            Some("Unknown")
2523        );
2524    }
2525
2526    #[test]
2527    fn test_field_schema_rejects_unknown_keys() {
2528        // Test that unknown keys like "invalid_key" are rejected (strict mode)
2529        let yaml = r#"
2530type: "string"
2531description: "A string field"
2532invalid_key:
2533  sub_field:
2534    type: "string"
2535    description: "Nested field"
2536"#;
2537        let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2538
2539        let result = FieldSchema::from_quill_value("author".to_string(), &quill_value);
2540
2541        // The parsing should fail due to deny_unknown_fields
2542        assert!(result.is_err());
2543        let err = result.unwrap_err();
2544        assert!(
2545            err.contains("unknown field `invalid_key`"),
2546            "Error was: {}",
2547            err
2548        );
2549    }
2550
2551    #[test]
2552    fn test_quill_config_with_cards_section() {
2553        let yaml_content = r#"
2554Quill:
2555  name: cards-test
2556  version: "1.0"
2557  backend: typst
2558  description: Test [cards] section
2559
2560fields:
2561  regular:
2562    description: Regular field
2563    type: string
2564
2565cards:
2566  indorsements:
2567    title: Routing Indorsements
2568    description: Chain of endorsements
2569    fields:
2570      name:
2571        title: Name
2572        type: string
2573        description: Name field
2574"#;
2575
2576        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2577
2578        // Check regular field
2579        assert!(config.document.fields.contains_key("regular"));
2580        let regular = config.document.fields.get("regular").unwrap();
2581        assert_eq!(regular.r#type, FieldType::String);
2582
2583        // Check card is in config.cards (not config.document.fields)
2584        assert!(config.cards.contains_key("indorsements"));
2585        let card = config.cards.get("indorsements").unwrap();
2586        assert_eq!(card.title, Some("Routing Indorsements".to_string()));
2587        assert_eq!(card.description, Some("Chain of endorsements".to_string()));
2588        assert!(card.fields.contains_key("name"));
2589    }
2590
2591    #[test]
2592    fn test_quill_config_cards_empty_fields() {
2593        // Test that cards with no fields section are valid
2594        let yaml_content = r#"
2595Quill:
2596  name: cards-empty-fields-test
2597  version: "1.0"
2598  backend: typst
2599  description: Test cards without fields
2600
2601cards:
2602  myscope:
2603    description: My scope
2604"#;
2605
2606        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2607        let card = config.cards.get("myscope").unwrap();
2608        assert_eq!(card.name, "myscope");
2609        assert_eq!(card.description, Some("My scope".to_string()));
2610        assert!(card.fields.is_empty());
2611    }
2612
2613    #[test]
2614    fn test_quill_config_allows_card_collision() {
2615        // Test that scope name colliding with field name is ALLOWED
2616        let yaml_content = r#"
2617Quill:
2618  name: collision-test
2619  version: "1.0"
2620  backend: typst
2621  description: Test collision
2622
2623fields:
2624  conflict:
2625    description: Field
2626    type: string
2627
2628cards:
2629  conflict:
2630    description: Card
2631"#;
2632
2633        let result = QuillConfig::from_yaml(yaml_content);
2634        if let Err(e) = &result {
2635            panic!(
2636                "Card name collision should be allowed, but got error: {}",
2637                e
2638            );
2639        }
2640        assert!(result.is_ok());
2641
2642        let config = result.unwrap();
2643        assert!(config.document.fields.contains_key("conflict"));
2644        assert!(config.cards.contains_key("conflict"));
2645    }
2646
2647    #[test]
2648    fn test_quill_config_ordering_with_cards() {
2649        // Test that fields have proper UI ordering (cards no longer have card-level ordering)
2650        let yaml_content = r#"
2651Quill:
2652  name: ordering-test
2653  version: "1.0"
2654  backend: typst
2655  description: Test ordering
2656
2657fields:
2658  first:
2659    type: string
2660    description: First
2661  zero:
2662    type: string
2663    description: Zero
2664
2665cards:
2666  second:
2667    description: Second
2668    fields:
2669      card_field:
2670        type: string
2671        description: A card field
2672"#;
2673
2674        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2675
2676        let first = config.document.fields.get("first").unwrap();
2677        let zero = config.document.fields.get("zero").unwrap();
2678        let second = config.cards.get("second").unwrap();
2679
2680        // Check field ordering
2681        let ord_first = first.ui.as_ref().unwrap().order.unwrap();
2682        let ord_zero = zero.ui.as_ref().unwrap().order.unwrap();
2683
2684        // Within fields, "first" is before "zero"
2685        assert!(ord_first < ord_zero);
2686        assert_eq!(ord_first, 0);
2687        assert_eq!(ord_zero, 1);
2688
2689        // Card fields should also have ordering
2690        let card_field = second.fields.get("card_field").unwrap();
2691        let ord_card_field = card_field.ui.as_ref().unwrap().order.unwrap();
2692        assert_eq!(ord_card_field, 0); // First (and only) field in this card
2693    }
2694    #[test]
2695    fn test_card_field_order_preservation() {
2696        // Test that card fields preserve definition order (not alphabetical)
2697        // defined: z_first, then a_second
2698        // alphabetical: a_second, then z_first
2699        let yaml_content = r#"
2700Quill:
2701  name: card-order-test
2702  version: "1.0"
2703  backend: typst
2704  description: Test card field order
2705
2706cards:
2707  mycard:
2708    description: Test card
2709    fields:
2710      z_first:
2711        type: string
2712        description: Defined first
2713      a_second:
2714        type: string
2715        description: Defined second
2716"#;
2717
2718        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2719        let card = config.cards.get("mycard").unwrap();
2720
2721        let z_first = card.fields.get("z_first").unwrap();
2722        let a_second = card.fields.get("a_second").unwrap();
2723
2724        // Check orders
2725        let z_order = z_first.ui.as_ref().unwrap().order.unwrap();
2726        let a_order = a_second.ui.as_ref().unwrap().order.unwrap();
2727
2728        // If strict file order is preserved:
2729        // z_first should be 0, a_second should be 1
2730        assert_eq!(z_order, 0, "z_first should be 0 (defined first)");
2731        assert_eq!(a_order, 1, "a_second should be 1 (defined second)");
2732    }
2733    #[test]
2734    fn test_nested_schema_parsing() {
2735        let yaml_content = r#"
2736Quill:
2737  name: nested-test
2738  version: "1.0"
2739  backend: typst
2740  description: Test nested elements
2741
2742fields:
2743  my_list:
2744    type: array
2745    description: List of objects
2746    items:
2747      type: object
2748      properties:
2749        sub_a:
2750          type: string
2751          description: Subfield A
2752        sub_b:
2753          type: number
2754          description: Subfield B
2755  my_obj:
2756    type: object
2757    description: Single object
2758    properties:
2759      child:
2760        type: boolean
2761        description: Child field
2762"#;
2763
2764        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2765
2766        // Check array with items
2767        let list_field = config.document.fields.get("my_list").unwrap();
2768        assert_eq!(list_field.r#type, FieldType::Array);
2769        assert!(list_field.items.is_some());
2770
2771        let items_schema = list_field.items.as_ref().unwrap();
2772        assert_eq!(items_schema.r#type, FieldType::Object);
2773        assert!(items_schema.properties.is_some());
2774
2775        let props = items_schema.properties.as_ref().unwrap();
2776        assert!(props.contains_key("sub_a"));
2777        assert!(props.contains_key("sub_b"));
2778        assert_eq!(props["sub_a"].r#type, FieldType::String);
2779        assert_eq!(props["sub_b"].r#type, FieldType::Number);
2780
2781        // Check object with properties
2782        let obj_field = config.document.fields.get("my_obj").unwrap();
2783        assert_eq!(obj_field.r#type, FieldType::Object);
2784        assert!(obj_field.properties.is_some());
2785
2786        let obj_props = obj_field.properties.as_ref().unwrap();
2787        assert!(obj_props.contains_key("child"));
2788        assert_eq!(obj_props["child"].r#type, FieldType::Boolean);
2789    }
2790}