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