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        let example_content = if let Some(ref example_file_name) = config.example_file {
969            root.get_file(example_file_name).and_then(|bytes| {
970                String::from_utf8(bytes.to_vec())
971                    .map_err(|e| {
972                        eprintln!(
973                            "Warning: Example file '{}' is not valid UTF-8: {}",
974                            example_file_name, e
975                        );
976                        e
977                    })
978                    .ok()
979            })
980        } else {
981            None
982        };
983
984        // Extract and cache defaults and examples from schema for performance
985        let defaults = crate::schema::extract_defaults_from_schema(&schema);
986        let examples = crate::schema::extract_examples_from_schema(&schema);
987
988        let quill = Quill {
989            metadata,
990            name: config.document.name,
991            backend: config.backend,
992            plate: plate_content,
993            example: example_content,
994            schema,
995            defaults,
996            examples,
997            files: root,
998        };
999
1000        Ok(quill)
1001    }
1002
1003    /// Create a Quill from a JSON representation
1004    ///
1005    /// Parses a JSON string into an in-memory file tree and validates it. The
1006    /// precise JSON contract is documented in `designs/QUILL.md`.
1007    /// The JSON format MUST have a root object with a `files` key. The optional
1008    /// `metadata` key provides additional metadata that overrides defaults.
1009    pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
1010        use serde_json::Value as JsonValue;
1011
1012        let json: JsonValue =
1013            serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1014
1015        let obj = json.as_object().ok_or("Root must be an object")?;
1016
1017        // Extract files (required)
1018        let files_obj = obj
1019            .get("files")
1020            .and_then(|v| v.as_object())
1021            .ok_or("Missing or invalid 'files' key")?;
1022
1023        // Parse file tree
1024        let mut root_files = HashMap::new();
1025        for (key, value) in files_obj {
1026            root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
1027        }
1028
1029        let root = FileTreeNode::Directory { files: root_files };
1030
1031        // Create Quill from tree
1032        Self::from_tree(root)
1033    }
1034
1035    /// Recursively load all files from a directory into a tree structure
1036    fn load_directory_as_tree(
1037        current_dir: &Path,
1038        base_dir: &Path,
1039        ignore: &QuillIgnore,
1040    ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
1041        use std::fs;
1042
1043        if !current_dir.exists() {
1044            return Ok(FileTreeNode::Directory {
1045                files: HashMap::new(),
1046            });
1047        }
1048
1049        let mut files = HashMap::new();
1050
1051        for entry in fs::read_dir(current_dir)? {
1052            let entry = entry?;
1053            let path = entry.path();
1054            let relative_path = path
1055                .strip_prefix(base_dir)
1056                .map_err(|e| format!("Failed to get relative path: {}", e))?
1057                .to_path_buf();
1058
1059            // Check if this path should be ignored
1060            if ignore.is_ignored(&relative_path) {
1061                continue;
1062            }
1063
1064            // Get the filename
1065            let filename = path
1066                .file_name()
1067                .and_then(|n| n.to_str())
1068                .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
1069                .to_string();
1070
1071            if path.is_file() {
1072                let contents = fs::read(&path)
1073                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
1074
1075                files.insert(filename, FileTreeNode::File { contents });
1076            } else if path.is_dir() {
1077                // Recursively process subdirectory
1078                let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
1079                files.insert(filename, subdir_tree);
1080            }
1081        }
1082
1083        Ok(FileTreeNode::Directory { files })
1084    }
1085
1086    /// Get the list of typst packages to download, if specified in Quill.yaml
1087    pub fn typst_packages(&self) -> Vec<String> {
1088        self.metadata
1089            .get("typst_packages")
1090            .and_then(|v| v.as_array())
1091            .map(|arr| {
1092                arr.iter()
1093                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
1094                    .collect()
1095            })
1096            .unwrap_or_default()
1097    }
1098
1099    /// Get default values from the cached schema defaults
1100    ///
1101    /// Returns a reference to the pre-computed defaults HashMap that was extracted
1102    /// during Quill construction. This is more efficient than re-parsing the schema.
1103    ///
1104    /// This is used by `ParsedDocument::with_defaults()` to apply default values
1105    /// to missing fields.
1106    pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
1107        &self.defaults
1108    }
1109
1110    /// Get example values from the cached schema examples
1111    ///
1112    /// Returns a reference to the pre-computed examples HashMap that was extracted
1113    /// during Quill construction. This is more efficient than re-parsing the schema.
1114    pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
1115        &self.examples
1116    }
1117
1118    /// Get file contents by path (relative to quill root)
1119    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
1120        self.files.get_file(path)
1121    }
1122
1123    /// Check if a file exists in memory
1124    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1125        self.files.file_exists(path)
1126    }
1127
1128    /// Check if a directory exists in memory
1129    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1130        self.files.dir_exists(path)
1131    }
1132
1133    /// List files in a directory (non-recursive, returns file names only)
1134    pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1135        self.files.list_files(path)
1136    }
1137
1138    /// List subdirectories in a directory (non-recursive, returns directory names only)
1139    pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1140        self.files.list_subdirectories(path)
1141    }
1142
1143    /// List all files in a directory (returns paths relative to quill root)
1144    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1145        let dir_path = dir_path.as_ref();
1146        let filenames = self.files.list_files(dir_path);
1147
1148        // Convert filenames to full paths
1149        filenames
1150            .iter()
1151            .map(|name| {
1152                if dir_path == Path::new("") {
1153                    PathBuf::from(name)
1154                } else {
1155                    dir_path.join(name)
1156                }
1157            })
1158            .collect()
1159    }
1160
1161    /// List all directories in a directory (returns paths relative to quill root)
1162    pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1163        let dir_path = dir_path.as_ref();
1164        let subdirs = self.files.list_subdirectories(dir_path);
1165
1166        // Convert subdirectory names to full paths
1167        subdirs
1168            .iter()
1169            .map(|name| {
1170                if dir_path == Path::new("") {
1171                    PathBuf::from(name)
1172                } else {
1173                    dir_path.join(name)
1174                }
1175            })
1176            .collect()
1177    }
1178
1179    /// Get all files matching a pattern (supports glob-style wildcards)
1180    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1181        let pattern_str = pattern.as_ref().to_string_lossy();
1182        let mut matches = Vec::new();
1183
1184        // Compile the glob pattern
1185        let glob_pattern = match glob::Pattern::new(&pattern_str) {
1186            Ok(pat) => pat,
1187            Err(_) => return matches, // Invalid pattern returns empty results
1188        };
1189
1190        // Recursively search the tree for matching files
1191        Self::find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1192
1193        matches.sort();
1194        matches
1195    }
1196
1197    /// Helper method to recursively search for files matching a pattern
1198    fn find_files_recursive(
1199        node: &FileTreeNode,
1200        current_path: &Path,
1201        pattern: &glob::Pattern,
1202        matches: &mut Vec<PathBuf>,
1203    ) {
1204        match node {
1205            FileTreeNode::File { .. } => {
1206                let path_str = current_path.to_string_lossy();
1207                if pattern.matches(&path_str) {
1208                    matches.push(current_path.to_path_buf());
1209                }
1210            }
1211            FileTreeNode::Directory { files } => {
1212                for (name, child_node) in files {
1213                    let child_path = if current_path == Path::new("") {
1214                        PathBuf::from(name)
1215                    } else {
1216                        current_path.join(name)
1217                    };
1218                    Self::find_files_recursive(child_node, &child_path, pattern, matches);
1219                }
1220            }
1221        }
1222    }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228    use std::fs;
1229    use tempfile::TempDir;
1230
1231    #[test]
1232    fn test_quillignore_parsing() {
1233        let ignore_content = r#"
1234# This is a comment
1235*.tmp
1236target/
1237node_modules/
1238.git/
1239"#;
1240        let ignore = QuillIgnore::from_content(ignore_content);
1241        assert_eq!(ignore.patterns.len(), 4);
1242        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1243        assert!(ignore.patterns.contains(&"target/".to_string()));
1244    }
1245
1246    #[test]
1247    fn test_quillignore_matching() {
1248        let ignore = QuillIgnore::new(vec![
1249            "*.tmp".to_string(),
1250            "target/".to_string(),
1251            "node_modules/".to_string(),
1252            ".git/".to_string(),
1253        ]);
1254
1255        // Test file patterns
1256        assert!(ignore.is_ignored("test.tmp"));
1257        assert!(ignore.is_ignored("path/to/file.tmp"));
1258        assert!(!ignore.is_ignored("test.txt"));
1259
1260        // Test directory patterns
1261        assert!(ignore.is_ignored("target"));
1262        assert!(ignore.is_ignored("target/debug"));
1263        assert!(ignore.is_ignored("target/debug/deps"));
1264        assert!(!ignore.is_ignored("src/target.rs"));
1265
1266        assert!(ignore.is_ignored("node_modules"));
1267        assert!(ignore.is_ignored("node_modules/package"));
1268        assert!(!ignore.is_ignored("my_node_modules"));
1269    }
1270
1271    #[test]
1272    fn test_in_memory_file_system() {
1273        let temp_dir = TempDir::new().unwrap();
1274        let quill_dir = temp_dir.path();
1275
1276        // Create test files
1277        fs::write(
1278            quill_dir.join("Quill.yaml"),
1279            "Quill:\n  name: \"test\"\n  version: \"1.0\"\n  backend: \"typst\"\n  plate_file: \"plate.typ\"\n  description: \"Test quill\"",
1280        )
1281        .unwrap();
1282        fs::write(quill_dir.join("plate.typ"), "test plate").unwrap();
1283
1284        let assets_dir = quill_dir.join("assets");
1285        fs::create_dir_all(&assets_dir).unwrap();
1286        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1287
1288        let packages_dir = quill_dir.join("packages");
1289        fs::create_dir_all(&packages_dir).unwrap();
1290        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1291
1292        // Load quill
1293        let quill = Quill::from_path(quill_dir).unwrap();
1294
1295        // Test file access
1296        assert!(quill.file_exists("plate.typ"));
1297        assert!(quill.file_exists("assets/test.txt"));
1298        assert!(quill.file_exists("packages/package.typ"));
1299        assert!(!quill.file_exists("nonexistent.txt"));
1300
1301        // Test file content
1302        let asset_content = quill.get_file("assets/test.txt").unwrap();
1303        assert_eq!(asset_content, b"asset content");
1304
1305        // Test directory listing
1306        let asset_files = quill.list_directory("assets");
1307        assert_eq!(asset_files.len(), 1);
1308        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1309    }
1310
1311    #[test]
1312    fn test_quillignore_integration() {
1313        let temp_dir = TempDir::new().unwrap();
1314        let quill_dir = temp_dir.path();
1315
1316        // Create .quillignore
1317        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1318
1319        // Create test files
1320        fs::write(
1321            quill_dir.join("Quill.yaml"),
1322            "Quill:\n  name: \"test\"\n  version: \"1.0\"\n  backend: \"typst\"\n  plate_file: \"plate.typ\"\n  description: \"Test quill\"",
1323        )
1324        .unwrap();
1325        fs::write(quill_dir.join("plate.typ"), "test template").unwrap();
1326        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1327
1328        let target_dir = quill_dir.join("target");
1329        fs::create_dir_all(&target_dir).unwrap();
1330        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1331
1332        // Load quill
1333        let quill = Quill::from_path(quill_dir).unwrap();
1334
1335        // Test that ignored files are not loaded
1336        assert!(quill.file_exists("plate.typ"));
1337        assert!(!quill.file_exists("should_ignore.tmp"));
1338        assert!(!quill.file_exists("target/debug.txt"));
1339    }
1340
1341    #[test]
1342    fn test_find_files_pattern() {
1343        let temp_dir = TempDir::new().unwrap();
1344        let quill_dir = temp_dir.path();
1345
1346        // Create test directory structure
1347        fs::write(
1348            quill_dir.join("Quill.yaml"),
1349            "Quill:\n  name: \"test\"\n  version: \"1.0\"\n  backend: \"typst\"\n  plate_file: \"plate.typ\"\n  description: \"Test quill\"",
1350        )
1351        .unwrap();
1352        fs::write(quill_dir.join("plate.typ"), "template").unwrap();
1353
1354        let assets_dir = quill_dir.join("assets");
1355        fs::create_dir_all(&assets_dir).unwrap();
1356        fs::write(assets_dir.join("image.png"), "png data").unwrap();
1357        fs::write(assets_dir.join("data.json"), "json data").unwrap();
1358
1359        let fonts_dir = assets_dir.join("fonts");
1360        fs::create_dir_all(&fonts_dir).unwrap();
1361        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1362
1363        // Load quill
1364        let quill = Quill::from_path(quill_dir).unwrap();
1365
1366        // Test pattern matching
1367        let all_assets = quill.find_files("assets/*");
1368        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
1369
1370        let typ_files = quill.find_files("*.typ");
1371        assert_eq!(typ_files.len(), 1);
1372        assert!(typ_files.contains(&PathBuf::from("plate.typ")));
1373    }
1374
1375    #[test]
1376    fn test_new_standardized_yaml_format() {
1377        let temp_dir = TempDir::new().unwrap();
1378        let quill_dir = temp_dir.path();
1379
1380        // Create test files using new standardized format
1381        let yaml_content = r#"
1382Quill:
1383  name: my-custom-quill
1384  version: "1.0"
1385  backend: typst
1386  plate_file: custom_plate.typ
1387  description: Test quill with new format
1388  author: Test Author
1389"#;
1390        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1391        fs::write(
1392            quill_dir.join("custom_plate.typ"),
1393            "= Custom Template\n\nThis is a custom template.",
1394        )
1395        .unwrap();
1396
1397        // Load quill
1398        let quill = Quill::from_path(quill_dir).unwrap();
1399
1400        // Test that name comes from YAML, not directory
1401        assert_eq!(quill.name, "my-custom-quill");
1402
1403        // Test that backend is in metadata
1404        assert!(quill.metadata.contains_key("backend"));
1405        if let Some(backend_val) = quill.metadata.get("backend") {
1406            if let Some(backend_str) = backend_val.as_str() {
1407                assert_eq!(backend_str, "typst");
1408            } else {
1409                panic!("Backend value is not a string");
1410            }
1411        }
1412
1413        // Test that other fields are in metadata including version
1414        assert!(quill.metadata.contains_key("description"));
1415        assert!(quill.metadata.contains_key("author"));
1416        assert!(quill.metadata.contains_key("version")); // version should now be included
1417        if let Some(version_val) = quill.metadata.get("version") {
1418            if let Some(version_str) = version_val.as_str() {
1419                assert_eq!(version_str, "1.0");
1420            }
1421        }
1422
1423        // Test that plate template content is loaded correctly
1424        assert!(quill.plate.unwrap().contains("Custom Template"));
1425    }
1426
1427    #[test]
1428    fn test_typst_packages_parsing() {
1429        let temp_dir = TempDir::new().unwrap();
1430        let quill_dir = temp_dir.path();
1431
1432        let yaml_content = r#"
1433Quill:
1434  name: "test-quill"
1435  version: "1.0"
1436  backend: "typst"
1437  plate_file: "plate.typ"
1438  description: "Test quill for packages"
1439
1440typst:
1441  packages:
1442    - "@preview/bubble:0.2.2"
1443    - "@preview/example:1.0.0"
1444"#;
1445
1446        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1447        fs::write(quill_dir.join("plate.typ"), "test").unwrap();
1448
1449        let quill = Quill::from_path(quill_dir).unwrap();
1450        let packages = quill.typst_packages();
1451
1452        assert_eq!(packages.len(), 2);
1453        assert_eq!(packages[0], "@preview/bubble:0.2.2");
1454        assert_eq!(packages[1], "@preview/example:1.0.0");
1455    }
1456
1457    #[test]
1458    fn test_template_loading() {
1459        let temp_dir = TempDir::new().unwrap();
1460        let quill_dir = temp_dir.path();
1461
1462        // Create test files with example specified
1463        let yaml_content = r#"Quill:
1464  name: "test-with-template"
1465  version: "1.0"
1466  backend: "typst"
1467  plate_file: "plate.typ"
1468  example_file: "example.md"
1469  description: "Test quill with template"
1470"#;
1471        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1472        fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1473        fs::write(
1474            quill_dir.join("example.md"),
1475            "---\ntitle: Test\n---\n\nThis is a test template.",
1476        )
1477        .unwrap();
1478
1479        // Load quill
1480        let quill = Quill::from_path(quill_dir).unwrap();
1481
1482        // Test that example content is loaded and includes some the text
1483        assert!(quill.example.is_some());
1484        let example = quill.example.unwrap();
1485        assert!(example.contains("title: Test"));
1486        assert!(example.contains("This is a test template"));
1487
1488        // Test that plate template is still loaded
1489        assert_eq!(quill.plate.unwrap(), "plate content");
1490    }
1491
1492    #[test]
1493    fn test_template_optional() {
1494        let temp_dir = TempDir::new().unwrap();
1495        let quill_dir = temp_dir.path();
1496
1497        // Create test files without example specified
1498        let yaml_content = r#"Quill:
1499  name: "test-without-template"
1500  version: "1.0"
1501  backend: "typst"
1502  plate_file: "plate.typ"
1503  description: "Test quill without template"
1504"#;
1505        fs::write(quill_dir.join("Quill.yaml"), yaml_content).unwrap();
1506        fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1507
1508        // Load quill
1509        let quill = Quill::from_path(quill_dir).unwrap();
1510
1511        // Test that example fields are None
1512        assert_eq!(quill.example, None);
1513
1514        // Test that plate template is still loaded
1515        assert_eq!(quill.plate.unwrap(), "plate content");
1516    }
1517
1518    #[test]
1519    fn test_from_tree() {
1520        // Create a simple in-memory file tree
1521        let mut root_files = HashMap::new();
1522
1523        // Add Quill.yaml
1524        let quill_yaml = r#"Quill:
1525  name: "test-from-tree"
1526  version: "1.0"
1527  backend: "typst"
1528  plate_file: "plate.typ"
1529  description: "A test quill from tree"
1530"#;
1531        root_files.insert(
1532            "Quill.yaml".to_string(),
1533            FileTreeNode::File {
1534                contents: quill_yaml.as_bytes().to_vec(),
1535            },
1536        );
1537
1538        // Add plate file
1539        let plate_content = "= Test Template\n\nThis is a test.";
1540        root_files.insert(
1541            "plate.typ".to_string(),
1542            FileTreeNode::File {
1543                contents: plate_content.as_bytes().to_vec(),
1544            },
1545        );
1546
1547        let root = FileTreeNode::Directory { files: root_files };
1548
1549        // Create Quill from tree
1550        let quill = Quill::from_tree(root).unwrap();
1551
1552        // Validate the quill
1553        assert_eq!(quill.name, "test-from-tree");
1554        assert_eq!(quill.plate.unwrap(), plate_content);
1555        assert!(quill.metadata.contains_key("backend"));
1556        assert!(quill.metadata.contains_key("description"));
1557    }
1558
1559    #[test]
1560    fn test_from_tree_with_template() {
1561        let mut root_files = HashMap::new();
1562
1563        // Add Quill.yaml with example specified
1564        // Add Quill.yaml with example specified
1565        let quill_yaml = r#"
1566Quill:
1567  name: test-tree-template
1568  version: "1.0"
1569  backend: typst
1570  plate_file: plate.typ
1571  example_file: template.md
1572  description: Test tree with template
1573"#;
1574        root_files.insert(
1575            "Quill.yaml".to_string(),
1576            FileTreeNode::File {
1577                contents: quill_yaml.as_bytes().to_vec(),
1578            },
1579        );
1580
1581        // Add plate file
1582        root_files.insert(
1583            "plate.typ".to_string(),
1584            FileTreeNode::File {
1585                contents: b"plate content".to_vec(),
1586            },
1587        );
1588
1589        // Add template file
1590        let template_content = "# {{ title }}\n\n{{ body }}";
1591        root_files.insert(
1592            "template.md".to_string(),
1593            FileTreeNode::File {
1594                contents: template_content.as_bytes().to_vec(),
1595            },
1596        );
1597
1598        let root = FileTreeNode::Directory { files: root_files };
1599
1600        // Create Quill from tree
1601        let quill = Quill::from_tree(root).unwrap();
1602
1603        // Validate template is loaded
1604        assert_eq!(quill.example, Some(template_content.to_string()));
1605    }
1606
1607    #[test]
1608    fn test_from_json() {
1609        // Create JSON representation of a Quill using new format
1610        let json_str = r#"{
1611            "metadata": {
1612                "name": "test_from_json"
1613            },
1614            "files": {
1615                "Quill.yaml": {
1616                    "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"
1617                },
1618                "plate.typ": {
1619                    "contents": "= Test Plate\n\nThis is test content."
1620                }
1621            }
1622        }"#;
1623
1624        // Create Quill from JSON
1625        let quill = Quill::from_json(json_str).unwrap();
1626
1627        // Validate the quill
1628        assert_eq!(quill.name, "test_from_json");
1629        assert!(quill.plate.unwrap().contains("Test Plate"));
1630        assert!(quill.metadata.contains_key("backend"));
1631    }
1632
1633    #[test]
1634    fn test_from_json_with_byte_array() {
1635        // Create JSON with byte array representation using new format
1636        let json_str = r#"{
1637            "files": {
1638                "Quill.yaml": {
1639                    "contents": "Quill:\n  name: test\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Test quill\n"
1640                },
1641                "plate.typ": {
1642                    "contents": "test plate"
1643                }
1644            }
1645        }"#;
1646
1647        // Create Quill from JSON
1648        let quill = Quill::from_json(json_str).unwrap();
1649
1650        // Validate the quill was created
1651        assert_eq!(quill.name, "test");
1652        assert_eq!(quill.plate.unwrap(), "test plate");
1653    }
1654
1655    #[test]
1656    fn test_from_json_missing_files() {
1657        // JSON without files field should fail
1658        let json_str = r#"{
1659            "metadata": {
1660                "name": "test"
1661            }
1662        }"#;
1663
1664        let result = Quill::from_json(json_str);
1665        assert!(result.is_err());
1666        // Should fail because there's no 'files' key
1667        assert!(result.unwrap_err().to_string().contains("files"));
1668    }
1669
1670    #[test]
1671    fn test_from_json_tree_structure() {
1672        // Test the new tree structure format
1673        let json_str = r#"{
1674            "files": {
1675                "Quill.yaml": {
1676                    "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"
1677                },
1678                "plate.typ": {
1679                    "contents": "= Test Plate\n\nTree structure content."
1680                }
1681            }
1682        }"#;
1683
1684        let quill = Quill::from_json(json_str).unwrap();
1685
1686        assert_eq!(quill.name, "test_tree_json");
1687        assert!(quill.plate.unwrap().contains("Tree structure content"));
1688        assert!(quill.metadata.contains_key("backend"));
1689    }
1690
1691    #[test]
1692    fn test_from_json_nested_tree_structure() {
1693        // Test nested directories in tree structure
1694        let json_str = r#"{
1695            "files": {
1696                "Quill.yaml": {
1697                    "contents": "Quill:\n  name: nested_test\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Nested test\n"
1698                },
1699                "plate.typ": {
1700                    "contents": "plate"
1701                },
1702                "src": {
1703                    "main.rs": {
1704                        "contents": "fn main() {}"
1705                    },
1706                    "lib.rs": {
1707                        "contents": "// lib"
1708                    }
1709                }
1710            }
1711        }"#;
1712
1713        let quill = Quill::from_json(json_str).unwrap();
1714
1715        assert_eq!(quill.name, "nested_test");
1716        // Verify nested files are accessible
1717        assert!(quill.file_exists("src/main.rs"));
1718        assert!(quill.file_exists("src/lib.rs"));
1719
1720        let main_rs = quill.get_file("src/main.rs").unwrap();
1721        assert_eq!(main_rs, b"fn main() {}");
1722    }
1723
1724    #[test]
1725    fn test_from_tree_structure_direct() {
1726        // Test using from_tree_structure directly
1727        let mut root_files = HashMap::new();
1728
1729        root_files.insert(
1730            "Quill.yaml".to_string(),
1731            FileTreeNode::File {
1732                contents:
1733                    b"Quill:\n  name: direct_tree\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Direct tree test\n"
1734                        .to_vec(),
1735            },
1736        );
1737
1738        root_files.insert(
1739            "plate.typ".to_string(),
1740            FileTreeNode::File {
1741                contents: b"plate content".to_vec(),
1742            },
1743        );
1744
1745        // Add a nested directory
1746        let mut src_files = HashMap::new();
1747        src_files.insert(
1748            "main.rs".to_string(),
1749            FileTreeNode::File {
1750                contents: b"fn main() {}".to_vec(),
1751            },
1752        );
1753
1754        root_files.insert(
1755            "src".to_string(),
1756            FileTreeNode::Directory { files: src_files },
1757        );
1758
1759        let root = FileTreeNode::Directory { files: root_files };
1760
1761        let quill = Quill::from_tree(root).unwrap();
1762
1763        assert_eq!(quill.name, "direct_tree");
1764        assert!(quill.file_exists("src/main.rs"));
1765        assert!(quill.file_exists("plate.typ"));
1766    }
1767
1768    #[test]
1769    fn test_from_json_with_metadata_override() {
1770        // Test that metadata key overrides name from Quill.yaml
1771        let json_str = r#"{
1772            "metadata": {
1773                "name": "override_name"
1774            },
1775            "files": {
1776                "Quill.yaml": {
1777                    "contents": "Quill:\n  name: toml_name\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: TOML name test\n"
1778                },
1779                "plate.typ": {
1780                    "contents": "= plate"
1781                }
1782            }
1783        }"#;
1784
1785        let quill = Quill::from_json(json_str).unwrap();
1786        // Metadata name should be used as default, but Quill.yaml takes precedence
1787        // when from_tree is called
1788        assert_eq!(quill.name, "toml_name");
1789    }
1790
1791    #[test]
1792    fn test_from_json_empty_directory() {
1793        // Test that empty directories are supported
1794        let json_str = r#"{
1795            "files": {
1796                "Quill.yaml": {
1797                    "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"
1798                },
1799                "plate.typ": {
1800                    "contents": "plate"
1801                },
1802                "empty_dir": {}
1803            }
1804        }"#;
1805
1806        let quill = Quill::from_json(json_str).unwrap();
1807        assert_eq!(quill.name, "empty_dir_test");
1808        assert!(quill.dir_exists("empty_dir"));
1809        assert!(!quill.file_exists("empty_dir"));
1810    }
1811
1812    #[test]
1813    fn test_dir_exists_and_list_apis() {
1814        let mut root_files = HashMap::new();
1815
1816        // Add Quill.yaml
1817        root_files.insert(
1818            "Quill.yaml".to_string(),
1819            FileTreeNode::File {
1820                contents: b"Quill:\n  name: test\n  version: \"1.0\"\n  backend: typst\n  plate_file: plate.typ\n  description: Test quill\n"
1821                    .to_vec(),
1822            },
1823        );
1824
1825        // Add plate file
1826        root_files.insert(
1827            "plate.typ".to_string(),
1828            FileTreeNode::File {
1829                contents: b"plate content".to_vec(),
1830            },
1831        );
1832
1833        // Add assets directory with files
1834        let mut assets_files = HashMap::new();
1835        assets_files.insert(
1836            "logo.png".to_string(),
1837            FileTreeNode::File {
1838                contents: vec![137, 80, 78, 71],
1839            },
1840        );
1841        assets_files.insert(
1842            "icon.svg".to_string(),
1843            FileTreeNode::File {
1844                contents: b"<svg></svg>".to_vec(),
1845            },
1846        );
1847
1848        // Add subdirectory in assets
1849        let mut fonts_files = HashMap::new();
1850        fonts_files.insert(
1851            "font.ttf".to_string(),
1852            FileTreeNode::File {
1853                contents: b"font data".to_vec(),
1854            },
1855        );
1856        assets_files.insert(
1857            "fonts".to_string(),
1858            FileTreeNode::Directory { files: fonts_files },
1859        );
1860
1861        root_files.insert(
1862            "assets".to_string(),
1863            FileTreeNode::Directory {
1864                files: assets_files,
1865            },
1866        );
1867
1868        // Add empty directory
1869        root_files.insert(
1870            "empty".to_string(),
1871            FileTreeNode::Directory {
1872                files: HashMap::new(),
1873            },
1874        );
1875
1876        let root = FileTreeNode::Directory { files: root_files };
1877        let quill = Quill::from_tree(root).unwrap();
1878
1879        // Test dir_exists
1880        assert!(quill.dir_exists("assets"));
1881        assert!(quill.dir_exists("assets/fonts"));
1882        assert!(quill.dir_exists("empty"));
1883        assert!(!quill.dir_exists("nonexistent"));
1884        assert!(!quill.dir_exists("plate.typ")); // file, not directory
1885
1886        // Test file_exists
1887        assert!(quill.file_exists("plate.typ"));
1888        assert!(quill.file_exists("assets/logo.png"));
1889        assert!(quill.file_exists("assets/fonts/font.ttf"));
1890        assert!(!quill.file_exists("assets")); // directory, not file
1891
1892        // Test list_files
1893        let root_files_list = quill.list_files("");
1894        assert_eq!(root_files_list.len(), 2); // Quill.yaml and plate.typ
1895        assert!(root_files_list.contains(&"Quill.yaml".to_string()));
1896        assert!(root_files_list.contains(&"plate.typ".to_string()));
1897
1898        let assets_files_list = quill.list_files("assets");
1899        assert_eq!(assets_files_list.len(), 2); // logo.png and icon.svg
1900        assert!(assets_files_list.contains(&"logo.png".to_string()));
1901        assert!(assets_files_list.contains(&"icon.svg".to_string()));
1902
1903        // Test list_subdirectories
1904        let root_subdirs = quill.list_subdirectories("");
1905        assert_eq!(root_subdirs.len(), 2); // assets and empty
1906        assert!(root_subdirs.contains(&"assets".to_string()));
1907        assert!(root_subdirs.contains(&"empty".to_string()));
1908
1909        let assets_subdirs = quill.list_subdirectories("assets");
1910        assert_eq!(assets_subdirs.len(), 1); // fonts
1911        assert!(assets_subdirs.contains(&"fonts".to_string()));
1912
1913        let empty_subdirs = quill.list_subdirectories("empty");
1914        assert_eq!(empty_subdirs.len(), 0);
1915    }
1916
1917    #[test]
1918    fn test_field_schemas_parsing() {
1919        let mut root_files = HashMap::new();
1920
1921        // Add Quill.yaml with field schemas
1922        let quill_yaml = r#"Quill:
1923  name: "taro"
1924  version: "1.0"
1925  backend: "typst"
1926  plate_file: "plate.typ"
1927  example_file: "taro.md"
1928  description: "Test template for field schemas"
1929
1930fields:
1931  author:
1932    type: "string"
1933    description: "Author of document"
1934  ice_cream:
1935    type: "string"
1936    description: "favorite ice cream flavor"
1937  title:
1938    type: "string"
1939    description: "title of document"
1940"#;
1941        root_files.insert(
1942            "Quill.yaml".to_string(),
1943            FileTreeNode::File {
1944                contents: quill_yaml.as_bytes().to_vec(),
1945            },
1946        );
1947
1948        // Add plate file
1949        let plate_content = "= Test Template\n\nThis is a test.";
1950        root_files.insert(
1951            "plate.typ".to_string(),
1952            FileTreeNode::File {
1953                contents: plate_content.as_bytes().to_vec(),
1954            },
1955        );
1956
1957        // Add template file
1958        root_files.insert(
1959            "taro.md".to_string(),
1960            FileTreeNode::File {
1961                contents: b"# Template".to_vec(),
1962            },
1963        );
1964
1965        let root = FileTreeNode::Directory { files: root_files };
1966
1967        // Create Quill from tree
1968        let quill = Quill::from_tree(root).unwrap();
1969
1970        // Validate field schemas were parsed (author, ice_cream, title, BODY)
1971        assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 4);
1972        assert!(quill.schema["properties"]
1973            .as_object()
1974            .unwrap()
1975            .contains_key("author"));
1976        assert!(quill.schema["properties"]
1977            .as_object()
1978            .unwrap()
1979            .contains_key("ice_cream"));
1980        assert!(quill.schema["properties"]
1981            .as_object()
1982            .unwrap()
1983            .contains_key("title"));
1984        assert!(quill.schema["properties"]
1985            .as_object()
1986            .unwrap()
1987            .contains_key("BODY"));
1988
1989        // Verify author field schema
1990        let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1991        assert_eq!(author_schema["description"], "Author of document");
1992
1993        // Verify ice_cream field schema (no required field, should default to false)
1994        let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1995        assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1996
1997        // Verify title field schema
1998        let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1999        assert_eq!(title_schema["description"], "title of document");
2000    }
2001
2002    #[test]
2003    fn test_field_schema_struct() {
2004        // Test creating FieldSchema with minimal fields
2005        let schema1 = FieldSchema::new(
2006            "test_name".to_string(),
2007            FieldType::String,
2008            Some("Test description".to_string()),
2009        );
2010        assert_eq!(schema1.description, Some("Test description".to_string()));
2011        assert_eq!(schema1.r#type, FieldType::String);
2012        assert_eq!(schema1.examples, None);
2013        assert_eq!(schema1.default, None);
2014
2015        // Test parsing FieldSchema from YAML with all fields
2016        let yaml_str = r#"
2017description: "Full field schema"
2018type: "string"
2019examples:
2020  - "Example value"
2021default: "Default value"
2022"#;
2023        let quill_value = QuillValue::from_yaml_str(yaml_str).unwrap();
2024        let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
2025        assert_eq!(schema2.name, "test_name");
2026        assert_eq!(schema2.description, Some("Full field schema".to_string()));
2027        assert_eq!(schema2.r#type, FieldType::String);
2028        assert_eq!(
2029            schema2
2030                .examples
2031                .as_ref()
2032                .and_then(|v| v.as_array())
2033                .and_then(|arr| arr.first())
2034                .and_then(|v| v.as_str()),
2035            Some("Example value")
2036        );
2037        assert_eq!(
2038            schema2.default.as_ref().and_then(|v| v.as_str()),
2039            Some("Default value")
2040        );
2041    }
2042
2043    #[test]
2044    fn test_quill_without_plate_file() {
2045        // Test creating a Quill without specifying a plate file
2046        let mut root_files = HashMap::new();
2047
2048        // Add Quill.yaml without plate field
2049        let quill_yaml = r#"Quill:
2050  name: "test-no-plate"
2051  version: "1.0"
2052  backend: "typst"
2053  description: "Test quill without plate file"
2054"#;
2055        root_files.insert(
2056            "Quill.yaml".to_string(),
2057            FileTreeNode::File {
2058                contents: quill_yaml.as_bytes().to_vec(),
2059            },
2060        );
2061
2062        let root = FileTreeNode::Directory { files: root_files };
2063
2064        // Create Quill from tree
2065        let quill = Quill::from_tree(root).unwrap();
2066
2067        // Validate that plate is null (will use auto plate)
2068        assert!(quill.plate.clone().is_none());
2069        assert_eq!(quill.name, "test-no-plate");
2070    }
2071
2072    #[test]
2073    fn test_quill_config_from_yaml() {
2074        // Test parsing QuillConfig from YAML content
2075        let yaml_content = r#"
2076Quill:
2077  name: test_config
2078  version: "1.0"
2079  backend: typst
2080  description: Test configuration parsing
2081  author: Test Author
2082  plate_file: plate.typ
2083  example_file: example.md
2084
2085typst:
2086  packages: 
2087    - "@preview/bubble:0.2.2"
2088
2089fields:
2090  title:
2091    description: Document title
2092    type: string
2093  author:
2094    type: string
2095    description: Document author
2096"#;
2097
2098        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2099
2100        // Verify required fields
2101        assert_eq!(config.document.name, "test_config");
2102        assert_eq!(config.backend, "typst");
2103        assert_eq!(
2104            config.document.description,
2105            Some("Test configuration parsing".to_string())
2106        );
2107
2108        // Verify optional fields
2109        assert_eq!(config.version, "1.0");
2110        assert_eq!(config.author, "Test Author");
2111        assert_eq!(config.plate_file, Some("plate.typ".to_string()));
2112        assert_eq!(config.example_file, Some("example.md".to_string()));
2113
2114        // Verify typst config
2115        assert!(config.typst_config.contains_key("packages"));
2116
2117        // Verify field schemas
2118        assert_eq!(config.document.fields.len(), 2);
2119        assert!(config.document.fields.contains_key("title"));
2120        assert!(config.document.fields.contains_key("author"));
2121
2122        let title_field = &config.document.fields["title"];
2123        assert_eq!(title_field.description, Some("Document title".to_string()));
2124        assert_eq!(title_field.r#type, FieldType::String);
2125    }
2126
2127    #[test]
2128    fn test_quill_config_missing_required_fields() {
2129        // Test that missing required fields result in error
2130        let yaml_missing_name = r#"
2131Quill:
2132  backend: typst
2133  description: Missing name
2134"#;
2135        let result = QuillConfig::from_yaml(yaml_missing_name);
2136        assert!(result.is_err());
2137        assert!(result
2138            .unwrap_err()
2139            .to_string()
2140            .contains("Missing required 'name'"));
2141
2142        let yaml_missing_backend = r#"
2143Quill:
2144  name: test
2145  description: Missing backend
2146"#;
2147        let result = QuillConfig::from_yaml(yaml_missing_backend);
2148        assert!(result.is_err());
2149        assert!(result
2150            .unwrap_err()
2151            .to_string()
2152            .contains("Missing required 'backend'"));
2153
2154        let yaml_missing_description = r#"
2155Quill:
2156  name: test
2157  version: "1.0"
2158  backend: typst
2159"#;
2160        let result = QuillConfig::from_yaml(yaml_missing_description);
2161        assert!(result.is_err());
2162        assert!(result
2163            .unwrap_err()
2164            .to_string()
2165            .contains("Missing required 'description'"));
2166    }
2167
2168    #[test]
2169    fn test_quill_config_empty_description() {
2170        // Test that empty description results in error
2171        let yaml_empty_description = r#"
2172Quill:
2173  name: test
2174  version: "1.0"
2175  backend: typst
2176  description: "   "
2177"#;
2178        let result = QuillConfig::from_yaml(yaml_empty_description);
2179        assert!(result.is_err());
2180        assert!(result
2181            .unwrap_err()
2182            .to_string()
2183            .contains("description' field in 'Quill' section cannot be empty"));
2184    }
2185
2186    #[test]
2187    fn test_quill_config_missing_quill_section() {
2188        // Test that missing [Quill] section results in error
2189        let yaml_no_section = r#"
2190fields:
2191  title:
2192    description: Title
2193"#;
2194        let result = QuillConfig::from_yaml(yaml_no_section);
2195        assert!(result.is_err());
2196        assert!(result
2197            .unwrap_err()
2198            .to_string()
2199            .contains("Missing required 'Quill' section"));
2200    }
2201
2202    #[test]
2203    fn test_quill_from_config_metadata() {
2204        // Test that QuillConfig metadata flows through to Quill
2205        let mut root_files = HashMap::new();
2206
2207        let quill_yaml = r#"
2208Quill:
2209  name: metadata-test
2210  version: "1.0"
2211  backend: typst
2212  description: Test metadata flow
2213  author: Test Author
2214  custom_field: custom_value
2215
2216typst:
2217  packages: 
2218    - "@preview/bubble:0.2.2"
2219"#;
2220        root_files.insert(
2221            "Quill.yaml".to_string(),
2222            FileTreeNode::File {
2223                contents: quill_yaml.as_bytes().to_vec(),
2224            },
2225        );
2226
2227        let root = FileTreeNode::Directory { files: root_files };
2228        let quill = Quill::from_tree(root).unwrap();
2229
2230        // Verify metadata includes backend and description
2231        assert!(quill.metadata.contains_key("backend"));
2232        assert!(quill.metadata.contains_key("description"));
2233        assert!(quill.metadata.contains_key("author"));
2234
2235        // Verify custom field is in metadata
2236        assert!(quill.metadata.contains_key("custom_field"));
2237        assert_eq!(
2238            quill.metadata.get("custom_field").unwrap().as_str(),
2239            Some("custom_value")
2240        );
2241
2242        // Verify typst config with typst_ prefix
2243        assert!(quill.metadata.contains_key("typst_packages"));
2244    }
2245
2246    #[test]
2247    fn test_extract_defaults_method() {
2248        // Test the extract_defaults method on Quill
2249        let mut root_files = HashMap::new();
2250
2251        let quill_yaml = r#"
2252Quill:
2253  name: metadata-test-yaml
2254  version: "1.0"
2255  backend: typst
2256  description: Test metadata flow
2257  author: Test Author
2258  custom_field: custom_value
2259
2260typst:
2261  packages: 
2262    - "@preview/bubble:0.2.2"
2263
2264fields:
2265  author:
2266    type: string
2267    default: Anonymous
2268  status:
2269    type: string
2270    default: draft
2271  title:
2272    type: string
2273"#;
2274        root_files.insert(
2275            "Quill.yaml".to_string(),
2276            FileTreeNode::File {
2277                contents: quill_yaml.as_bytes().to_vec(),
2278            },
2279        );
2280
2281        let root = FileTreeNode::Directory { files: root_files };
2282        let quill = Quill::from_tree(root).unwrap();
2283
2284        // Extract defaults
2285        let defaults = quill.extract_defaults();
2286
2287        // Verify only fields with defaults are returned
2288        assert_eq!(defaults.len(), 2);
2289        assert!(!defaults.contains_key("title")); // no default
2290        assert!(defaults.contains_key("author"));
2291        assert!(defaults.contains_key("status"));
2292
2293        // Verify default values
2294        assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2295        assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2296    }
2297
2298    #[test]
2299    fn test_field_order_preservation() {
2300        let yaml_content = r#"
2301Quill:
2302  name: order-test
2303  version: "1.0"
2304  backend: typst
2305  description: Test field order
2306
2307fields:
2308  first:
2309    type: string
2310    description: First field
2311  second:
2312    type: string
2313    description: Second field
2314  third:
2315    type: string
2316    description: Third field
2317    ui:
2318      group: Test Group
2319  fourth:
2320    type: string
2321    description: Fourth field
2322"#;
2323
2324        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2325
2326        // Check that fields have correct order based on TOML position
2327        // Order is automatically generated based on field position
2328
2329        let first = config.document.fields.get("first").unwrap();
2330        assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2331
2332        let second = config.document.fields.get("second").unwrap();
2333        assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2334
2335        let third = config.document.fields.get("third").unwrap();
2336        assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2337        assert_eq!(
2338            third.ui.as_ref().unwrap().group,
2339            Some("Test Group".to_string())
2340        );
2341
2342        let fourth = config.document.fields.get("fourth").unwrap();
2343        assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2344    }
2345
2346    #[test]
2347    fn test_quill_with_all_ui_properties() {
2348        let yaml_content = r#"
2349Quill:
2350  name: full-ui-test
2351  version: "1.0"
2352  backend: typst
2353  description: Test all UI properties
2354
2355fields:
2356  author:
2357    description: The full name of the document author
2358    type: str
2359    ui:
2360      group: Author Info
2361"#;
2362
2363        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2364
2365        let author_field = &config.document.fields["author"];
2366        let ui = author_field.ui.as_ref().unwrap();
2367        assert_eq!(ui.group, Some("Author Info".to_string()));
2368        assert_eq!(ui.order, Some(0)); // First field should have order 0
2369    }
2370    #[test]
2371    fn test_field_schema_with_title_and_description() {
2372        // Test parsing field with new schema format (title + description, no tooltip)
2373        let yaml = r#"
2374title: "Field Title"
2375description: "Detailed field description"
2376type: "string"
2377examples:
2378  - "Example value"
2379ui:
2380  group: "Test Group"
2381"#;
2382        let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2383        let schema = FieldSchema::from_quill_value("test_field".to_string(), &quill_value).unwrap();
2384
2385        assert_eq!(schema.title, Some("Field Title".to_string()));
2386        assert_eq!(
2387            schema.description,
2388            Some("Detailed field description".to_string())
2389        );
2390
2391        assert_eq!(
2392            schema
2393                .examples
2394                .as_ref()
2395                .and_then(|v| v.as_array())
2396                .and_then(|arr| arr.first())
2397                .and_then(|v| v.as_str()),
2398            Some("Example value")
2399        );
2400
2401        let ui = schema.ui.as_ref().unwrap();
2402        assert_eq!(ui.group, Some("Test Group".to_string()));
2403    }
2404
2405    #[test]
2406    fn test_parse_card_field_type() {
2407        // Test that FieldSchema no longer supports type = "card" (cards are in CardSchema now)
2408        let yaml = r#"
2409type: "string"
2410title: "Simple Field"
2411description: "A simple string field"
2412"#;
2413        let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2414        let schema =
2415            FieldSchema::from_quill_value("simple_field".to_string(), &quill_value).unwrap();
2416
2417        assert_eq!(schema.name, "simple_field");
2418        assert_eq!(schema.r#type, FieldType::String);
2419        assert_eq!(schema.title, Some("Simple Field".to_string()));
2420        assert_eq!(
2421            schema.description,
2422            Some("A simple string field".to_string())
2423        );
2424    }
2425
2426    #[test]
2427    fn test_parse_card_with_fields_in_yaml() {
2428        // Test parsing [cards] section with [cards.X.fields.Y] syntax
2429        let yaml_content = r#"
2430Quill:
2431  name: cards-fields-test
2432  version: "1.0"
2433  backend: typst
2434  description: Test [cards.X.fields.Y] syntax
2435
2436cards:
2437  endorsements:
2438    title: Endorsements
2439    description: Chain of endorsements
2440    fields:
2441      name:
2442        type: string
2443        title: Endorser Name
2444        description: Name of the endorsing official
2445        required: true
2446      org:
2447        type: string
2448        title: Organization
2449        description: Endorser's organization
2450        default: Unknown
2451"#;
2452
2453        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2454
2455        // Verify the card was parsed into config.cards
2456        assert!(config.cards.contains_key("endorsements"));
2457        let card = config.cards.get("endorsements").unwrap();
2458
2459        assert_eq!(card.name, "endorsements");
2460        assert_eq!(card.title, Some("Endorsements".to_string()));
2461        assert_eq!(card.description, Some("Chain of endorsements".to_string()));
2462
2463        // Verify card fields
2464        assert_eq!(card.fields.len(), 2);
2465
2466        let name_field = card.fields.get("name").unwrap();
2467        assert_eq!(name_field.r#type, FieldType::String);
2468        assert_eq!(name_field.title, Some("Endorser Name".to_string()));
2469        assert!(name_field.required);
2470
2471        let org_field = card.fields.get("org").unwrap();
2472        assert_eq!(org_field.r#type, FieldType::String);
2473        assert!(org_field.default.is_some());
2474        assert_eq!(
2475            org_field.default.as_ref().unwrap().as_str(),
2476            Some("Unknown")
2477        );
2478    }
2479
2480    #[test]
2481    fn test_field_schema_rejects_unknown_keys() {
2482        // Test that unknown keys like "invalid_key" are rejected (strict mode)
2483        let yaml = r#"
2484type: "string"
2485description: "A string field"
2486invalid_key:
2487  sub_field:
2488    type: "string"
2489    description: "Nested field"
2490"#;
2491        let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2492
2493        let result = FieldSchema::from_quill_value("author".to_string(), &quill_value);
2494
2495        // The parsing should fail due to deny_unknown_fields
2496        assert!(result.is_err());
2497        let err = result.unwrap_err();
2498        assert!(
2499            err.contains("unknown field `invalid_key`"),
2500            "Error was: {}",
2501            err
2502        );
2503    }
2504
2505    #[test]
2506    fn test_quill_config_with_cards_section() {
2507        let yaml_content = r#"
2508Quill:
2509  name: cards-test
2510  version: "1.0"
2511  backend: typst
2512  description: Test [cards] section
2513
2514fields:
2515  regular:
2516    description: Regular field
2517    type: string
2518
2519cards:
2520  indorsements:
2521    title: Routing Indorsements
2522    description: Chain of endorsements
2523    fields:
2524      name:
2525        title: Name
2526        type: string
2527        description: Name field
2528"#;
2529
2530        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2531
2532        // Check regular field
2533        assert!(config.document.fields.contains_key("regular"));
2534        let regular = config.document.fields.get("regular").unwrap();
2535        assert_eq!(regular.r#type, FieldType::String);
2536
2537        // Check card is in config.cards (not config.document.fields)
2538        assert!(config.cards.contains_key("indorsements"));
2539        let card = config.cards.get("indorsements").unwrap();
2540        assert_eq!(card.title, Some("Routing Indorsements".to_string()));
2541        assert_eq!(card.description, Some("Chain of endorsements".to_string()));
2542        assert!(card.fields.contains_key("name"));
2543    }
2544
2545    #[test]
2546    fn test_quill_config_cards_empty_fields() {
2547        // Test that cards with no fields section are valid
2548        let yaml_content = r#"
2549Quill:
2550  name: cards-empty-fields-test
2551  version: "1.0"
2552  backend: typst
2553  description: Test cards without fields
2554
2555cards:
2556  myscope:
2557    description: My scope
2558"#;
2559
2560        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2561        let card = config.cards.get("myscope").unwrap();
2562        assert_eq!(card.name, "myscope");
2563        assert_eq!(card.description, Some("My scope".to_string()));
2564        assert!(card.fields.is_empty());
2565    }
2566
2567    #[test]
2568    fn test_quill_config_allows_card_collision() {
2569        // Test that scope name colliding with field name is ALLOWED
2570        let yaml_content = r#"
2571Quill:
2572  name: collision-test
2573  version: "1.0"
2574  backend: typst
2575  description: Test collision
2576
2577fields:
2578  conflict:
2579    description: Field
2580    type: string
2581
2582cards:
2583  conflict:
2584    description: Card
2585"#;
2586
2587        let result = QuillConfig::from_yaml(yaml_content);
2588        if let Err(e) = &result {
2589            panic!(
2590                "Card name collision should be allowed, but got error: {}",
2591                e
2592            );
2593        }
2594        assert!(result.is_ok());
2595
2596        let config = result.unwrap();
2597        assert!(config.document.fields.contains_key("conflict"));
2598        assert!(config.cards.contains_key("conflict"));
2599    }
2600
2601    #[test]
2602    fn test_quill_config_ordering_with_cards() {
2603        // Test that fields have proper UI ordering (cards no longer have card-level ordering)
2604        let yaml_content = r#"
2605Quill:
2606  name: ordering-test
2607  version: "1.0"
2608  backend: typst
2609  description: Test ordering
2610
2611fields:
2612  first:
2613    type: string
2614    description: First
2615  zero:
2616    type: string
2617    description: Zero
2618
2619cards:
2620  second:
2621    description: Second
2622    fields:
2623      card_field:
2624        type: string
2625        description: A card field
2626"#;
2627
2628        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2629
2630        let first = config.document.fields.get("first").unwrap();
2631        let zero = config.document.fields.get("zero").unwrap();
2632        let second = config.cards.get("second").unwrap();
2633
2634        // Check field ordering
2635        let ord_first = first.ui.as_ref().unwrap().order.unwrap();
2636        let ord_zero = zero.ui.as_ref().unwrap().order.unwrap();
2637
2638        // Within fields, "first" is before "zero"
2639        assert!(ord_first < ord_zero);
2640        assert_eq!(ord_first, 0);
2641        assert_eq!(ord_zero, 1);
2642
2643        // Card fields should also have ordering
2644        let card_field = second.fields.get("card_field").unwrap();
2645        let ord_card_field = card_field.ui.as_ref().unwrap().order.unwrap();
2646        assert_eq!(ord_card_field, 0); // First (and only) field in this card
2647    }
2648    #[test]
2649    fn test_card_field_order_preservation() {
2650        // Test that card fields preserve definition order (not alphabetical)
2651        // defined: z_first, then a_second
2652        // alphabetical: a_second, then z_first
2653        let yaml_content = r#"
2654Quill:
2655  name: card-order-test
2656  version: "1.0"
2657  backend: typst
2658  description: Test card field order
2659
2660cards:
2661  mycard:
2662    description: Test card
2663    fields:
2664      z_first:
2665        type: string
2666        description: Defined first
2667      a_second:
2668        type: string
2669        description: Defined second
2670"#;
2671
2672        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2673        let card = config.cards.get("mycard").unwrap();
2674
2675        let z_first = card.fields.get("z_first").unwrap();
2676        let a_second = card.fields.get("a_second").unwrap();
2677
2678        // Check orders
2679        let z_order = z_first.ui.as_ref().unwrap().order.unwrap();
2680        let a_order = a_second.ui.as_ref().unwrap().order.unwrap();
2681
2682        // If strict file order is preserved:
2683        // z_first should be 0, a_second should be 1
2684        assert_eq!(z_order, 0, "z_first should be 0 (defined first)");
2685        assert_eq!(a_order, 1, "a_second should be 1 (defined second)");
2686    }
2687    #[test]
2688    fn test_nested_schema_parsing() {
2689        let yaml_content = r#"
2690Quill:
2691  name: nested-test
2692  version: "1.0"
2693  backend: typst
2694  description: Test nested elements
2695
2696fields:
2697  my_list:
2698    type: array
2699    description: List of objects
2700    items:
2701      type: object
2702      properties:
2703        sub_a:
2704          type: string
2705          description: Subfield A
2706        sub_b:
2707          type: number
2708          description: Subfield B
2709  my_obj:
2710    type: object
2711    description: Single object
2712    properties:
2713      child:
2714        type: boolean
2715        description: Child field
2716"#;
2717
2718        let config = QuillConfig::from_yaml(yaml_content).unwrap();
2719
2720        // Check array with items
2721        let list_field = config.document.fields.get("my_list").unwrap();
2722        assert_eq!(list_field.r#type, FieldType::Array);
2723        assert!(list_field.items.is_some());
2724
2725        let items_schema = list_field.items.as_ref().unwrap();
2726        assert_eq!(items_schema.r#type, FieldType::Object);
2727        assert!(items_schema.properties.is_some());
2728
2729        let props = items_schema.properties.as_ref().unwrap();
2730        assert!(props.contains_key("sub_a"));
2731        assert!(props.contains_key("sub_b"));
2732        assert_eq!(props["sub_a"].r#type, FieldType::String);
2733        assert_eq!(props["sub_b"].r#type, FieldType::Number);
2734
2735        // Check object with properties
2736        let obj_field = config.document.fields.get("my_obj").unwrap();
2737        assert_eq!(obj_field.r#type, FieldType::Object);
2738        assert!(obj_field.properties.is_some());
2739
2740        let obj_props = obj_field.properties.as_ref().unwrap();
2741        assert!(obj_props.contains_key("child"));
2742        assert_eq!(obj_props["child"].r#type, FieldType::Boolean);
2743    }
2744}