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