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