Skip to main content

quillmark_core/
quill.rs

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