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