quillmark_core/
quill.rs

1//! Quill template bundle types and implementations.
2
3use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use crate::value::QuillValue;
8
9/// Schema definition for a template field
10#[derive(Debug, Clone, PartialEq)]
11pub struct FieldSchema {
12    /// Field type hint (e.g., "string", "number", "boolean", "object", "array")
13    pub r#type: Option<String>,
14    /// Whether the field is required
15    pub required: bool,
16    /// Description of the field
17    pub description: String,
18    /// Example value for the field
19    pub example: Option<QuillValue>,
20    /// Default value for the field
21    pub default: Option<QuillValue>,
22}
23
24impl FieldSchema {
25    /// Create a new FieldSchema with default values
26    pub fn new(description: String) -> Self {
27        Self {
28            r#type: None,
29            required: false,
30            description,
31            example: None,
32            default: None,
33        }
34    }
35
36    /// Parse a FieldSchema from a QuillValue
37    pub fn from_quill_value(value: &QuillValue) -> Result<Self, String> {
38        let obj = value
39            .as_object()
40            .ok_or_else(|| "Field schema must be an object".to_string())?;
41
42        let description = obj
43            .get("description")
44            .and_then(|v| v.as_str())
45            .unwrap_or("")
46            .to_string();
47
48        let required = obj
49            .get("required")
50            .and_then(|v| v.as_bool())
51            .unwrap_or(false);
52
53        let field_type = obj
54            .get("type")
55            .and_then(|v| v.as_str())
56            .map(|s| s.to_string());
57
58        let example = obj.get("example").map(|v| QuillValue::from_json(v.clone()));
59
60        let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
61
62        Ok(Self {
63            r#type: field_type,
64            required,
65            description,
66            example,
67            default,
68        })
69    }
70
71    /// Convert the FieldSchema to a QuillValue for serialization
72    pub fn to_quill_value(&self) -> QuillValue {
73        let mut map = serde_json::Map::new();
74
75        map.insert(
76            "description".to_string(),
77            serde_json::Value::String(self.description.clone()),
78        );
79
80        map.insert(
81            "required".to_string(),
82            serde_json::Value::Bool(self.required),
83        );
84
85        if let Some(ref field_type) = self.r#type {
86            map.insert(
87                "type".to_string(),
88                serde_json::Value::String(field_type.clone()),
89            );
90        }
91
92        if let Some(ref example) = self.example {
93            map.insert("example".to_string(), example.as_json().clone());
94        }
95
96        if let Some(ref default) = self.default {
97            map.insert("default".to_string(), default.as_json().clone());
98        }
99
100        QuillValue::from_json(serde_json::Value::Object(map))
101    }
102}
103
104/// A node in the file tree structure
105#[derive(Debug, Clone)]
106pub enum FileTreeNode {
107    /// A file with its contents
108    File {
109        /// The file contents as bytes or UTF-8 string
110        contents: Vec<u8>,
111    },
112    /// A directory containing other files and directories
113    Directory {
114        /// The files and subdirectories in this directory
115        files: HashMap<String, FileTreeNode>,
116    },
117}
118
119impl FileTreeNode {
120    /// Get a file or directory node by path
121    pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
122        let path = path.as_ref();
123
124        // Handle root path
125        if path == Path::new("") {
126            return Some(self);
127        }
128
129        // Split path into components
130        let components: Vec<_> = path
131            .components()
132            .filter_map(|c| {
133                if let std::path::Component::Normal(s) = c {
134                    s.to_str()
135                } else {
136                    None
137                }
138            })
139            .collect();
140
141        if components.is_empty() {
142            return Some(self);
143        }
144
145        // Navigate through the tree
146        let mut current_node = self;
147        for component in components {
148            match current_node {
149                FileTreeNode::Directory { files } => {
150                    current_node = files.get(component)?;
151                }
152                FileTreeNode::File { .. } => {
153                    return None; // Can't traverse into a file
154                }
155            }
156        }
157
158        Some(current_node)
159    }
160
161    /// Get file contents by path
162    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
163        match self.get_node(path)? {
164            FileTreeNode::File { contents } => Some(contents.as_slice()),
165            FileTreeNode::Directory { .. } => None,
166        }
167    }
168
169    /// Check if a file exists at the given path
170    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
171        matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
172    }
173
174    /// Check if a directory exists at the given path
175    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
176        matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
177    }
178
179    /// List all files in a directory (non-recursive)
180    pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
181        match self.get_node(dir_path) {
182            Some(FileTreeNode::Directory { files }) => files
183                .iter()
184                .filter_map(|(name, node)| {
185                    if matches!(node, FileTreeNode::File { .. }) {
186                        Some(name.clone())
187                    } else {
188                        None
189                    }
190                })
191                .collect(),
192            _ => Vec::new(),
193        }
194    }
195
196    /// List all subdirectories in a directory (non-recursive)
197    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
198        match self.get_node(dir_path) {
199            Some(FileTreeNode::Directory { files }) => files
200                .iter()
201                .filter_map(|(name, node)| {
202                    if matches!(node, FileTreeNode::Directory { .. }) {
203                        Some(name.clone())
204                    } else {
205                        None
206                    }
207                })
208                .collect(),
209            _ => Vec::new(),
210        }
211    }
212
213    /// Insert a file or directory at the given path
214    pub fn insert<P: AsRef<Path>>(
215        &mut self,
216        path: P,
217        node: FileTreeNode,
218    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
219        let path = path.as_ref();
220
221        // Split path into components
222        let components: Vec<_> = path
223            .components()
224            .filter_map(|c| {
225                if let std::path::Component::Normal(s) = c {
226                    s.to_str().map(|s| s.to_string())
227                } else {
228                    None
229                }
230            })
231            .collect();
232
233        if components.is_empty() {
234            return Err("Cannot insert at root path".into());
235        }
236
237        // Navigate to parent directory, creating directories as needed
238        let mut current_node = self;
239        for component in &components[..components.len() - 1] {
240            match current_node {
241                FileTreeNode::Directory { files } => {
242                    current_node =
243                        files
244                            .entry(component.clone())
245                            .or_insert_with(|| FileTreeNode::Directory {
246                                files: HashMap::new(),
247                            });
248                }
249                FileTreeNode::File { .. } => {
250                    return Err("Cannot traverse into a file".into());
251                }
252            }
253        }
254
255        // Insert the new node
256        let filename = &components[components.len() - 1];
257        match current_node {
258            FileTreeNode::Directory { files } => {
259                files.insert(filename.clone(), node);
260                Ok(())
261            }
262            FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
263        }
264    }
265
266    /// Parse a tree structure from JSON value
267    fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
268        if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
269            // It's a file with string contents
270            Ok(FileTreeNode::File {
271                contents: contents_str.as_bytes().to_vec(),
272            })
273        } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
274            // It's a file with byte array contents
275            let contents: Vec<u8> = bytes_array
276                .iter()
277                .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
278                .collect();
279            Ok(FileTreeNode::File { contents })
280        } else if let Some(obj) = value.as_object() {
281            // It's a directory (either empty or with nested files)
282            let mut files = HashMap::new();
283            for (name, child_value) in obj {
284                files.insert(name.clone(), Self::from_json_value(child_value)?);
285            }
286            // Empty directories are valid
287            Ok(FileTreeNode::Directory { files })
288        } else {
289            Err(format!("Invalid file tree node: {:?}", value).into())
290        }
291    }
292}
293
294/// Simple gitignore-style pattern matcher for .quillignore
295#[derive(Debug, Clone)]
296pub struct QuillIgnore {
297    patterns: Vec<String>,
298}
299
300impl QuillIgnore {
301    /// Create a new QuillIgnore from pattern strings
302    pub fn new(patterns: Vec<String>) -> Self {
303        Self { patterns }
304    }
305
306    /// Parse .quillignore content into patterns
307    pub fn from_content(content: &str) -> Self {
308        let patterns = content
309            .lines()
310            .map(|line| line.trim())
311            .filter(|line| !line.is_empty() && !line.starts_with('#'))
312            .map(|line| line.to_string())
313            .collect();
314        Self::new(patterns)
315    }
316
317    /// Check if a path should be ignored
318    pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
319        let path = path.as_ref();
320        let path_str = path.to_string_lossy();
321
322        for pattern in &self.patterns {
323            if self.matches_pattern(pattern, &path_str) {
324                return true;
325            }
326        }
327        false
328    }
329
330    /// Simple pattern matching (supports * wildcard and directory patterns)
331    fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
332        // Handle directory patterns
333        if pattern.ends_with('/') {
334            let pattern_prefix = &pattern[..pattern.len() - 1];
335            return path.starts_with(pattern_prefix)
336                && (path.len() == pattern_prefix.len()
337                    || path.chars().nth(pattern_prefix.len()) == Some('/'));
338        }
339
340        // Handle exact matches
341        if !pattern.contains('*') {
342            return path == pattern || path.ends_with(&format!("/{}", pattern));
343        }
344
345        // Simple wildcard matching
346        if pattern == "*" {
347            return true;
348        }
349
350        // Handle patterns with wildcards
351        let pattern_parts: Vec<&str> = pattern.split('*').collect();
352        if pattern_parts.len() == 2 {
353            let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
354            if prefix.is_empty() {
355                return path.ends_with(suffix);
356            } else if suffix.is_empty() {
357                return path.starts_with(prefix);
358            } else {
359                return path.starts_with(prefix) && path.ends_with(suffix);
360            }
361        }
362
363        false
364    }
365}
366
367/// A quill template bundle.
368#[derive(Debug, Clone)]
369pub struct Quill {
370    /// The template content
371    pub glue_template: String,
372    /// Quill-specific metadata
373    pub metadata: HashMap<String, QuillValue>,
374    /// Name of the quill
375    pub name: String,
376    /// Backend identifier (e.g., "typst")
377    pub backend: String,
378    /// Glue template file name (optional)
379    pub glue_file: Option<String>,
380    /// Markdown template content (optional)
381    pub example: Option<String>,
382    /// Field schema documentation (optional)
383    pub field_schemas: HashMap<String, FieldSchema>,
384    /// In-memory file system (tree structure)
385    pub files: FileTreeNode,
386}
387
388impl Quill {
389    /// Create a Quill from a directory path
390    pub fn from_path<P: AsRef<std::path::Path>>(
391        path: P,
392    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
393        use std::fs;
394
395        let path = path.as_ref();
396        let name = path
397            .file_name()
398            .and_then(|n| n.to_str())
399            .unwrap_or("unnamed")
400            .to_string();
401
402        // Load .quillignore if it exists
403        let quillignore_path = path.join(".quillignore");
404        let ignore = if quillignore_path.exists() {
405            let ignore_content = fs::read_to_string(&quillignore_path)
406                .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
407            QuillIgnore::from_content(&ignore_content)
408        } else {
409            // Default ignore patterns
410            QuillIgnore::new(vec![
411                ".git/".to_string(),
412                ".gitignore".to_string(),
413                ".quillignore".to_string(),
414                "target/".to_string(),
415                "node_modules/".to_string(),
416            ])
417        };
418
419        // Load all files into a tree structure
420        let root = Self::load_directory_as_tree(path, path, &ignore)?;
421
422        // Create Quill from the file tree
423        Self::from_tree(root, Some(name))
424    }
425
426    /// Create a Quill from a tree structure
427    ///
428    /// This is the authoritative method for creating a Quill from an in-memory file tree.
429    ///
430    /// # Arguments
431    ///
432    /// * `root` - The root node of the file tree
433    /// * `default_name` - Optional default name (will be overridden by name in Quill.toml)
434    /// * `default_name` - Optional default name (will be overridden by name in Quill.toml)
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if:
439    /// - Quill.toml is not found in the file tree
440    /// - Quill.toml is not valid UTF-8 or TOML
441    /// - The glue file specified in Quill.toml is not found or not valid UTF-8
442    /// - Validation fails
443    pub fn from_tree(
444        root: FileTreeNode,
445        default_name: Option<String>,
446    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
447        // Read Quill.toml
448        let quill_toml_bytes = root
449            .get_file("Quill.toml")
450            .ok_or("Quill.toml not found in file tree")?;
451
452        let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
453            .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
454
455        let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
456            .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
457
458        let mut metadata = HashMap::new();
459        let mut glue_file: Option<String> = None;
460        let mut template_file: Option<String> = None;
461        let mut quill_name = default_name.unwrap_or_else(|| "unnamed".to_string());
462        let mut backend = String::new();
463        let mut field_schemas = HashMap::new();
464
465        // Extract fields from [Quill] section
466        if let Some(quill_section) = quill_toml.get("Quill") {
467            // Extract required fields: name, backend, glue, template
468            if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
469                quill_name = name_val.to_string();
470            }
471
472            if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
473                backend = backend_val.to_string();
474                match QuillValue::from_toml(&toml::Value::String(backend_val.to_string())) {
475                    Ok(quill_value) => {
476                        metadata.insert("backend".to_string(), quill_value);
477                    }
478                    Err(e) => {
479                        eprintln!("Warning: Failed to convert backend field: {}", e);
480                    }
481                }
482            }
483
484            if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
485                glue_file = Some(glue_val.to_string());
486            }
487
488            if let Some(example_val) = quill_section.get("example").and_then(|v| v.as_str()) {
489                template_file = Some(example_val.to_string());
490            }
491
492            // Validate that description is present and non-empty (required field)
493            let description = quill_section
494                .get("description")
495                .and_then(|v| v.as_str())
496                .ok_or("Missing required 'description' field in [Quill] section")?;
497
498            if description.trim().is_empty() {
499                return Err("'description' field in [Quill] section cannot be empty".into());
500            }
501
502            // Add other fields to metadata (excluding special fields and version)
503            if let toml::Value::Table(table) = quill_section {
504                for (key, value) in table {
505                    if key != "name"
506                        && key != "backend"
507                        && key != "glue"
508                        && key != "example"
509                        && key != "version"
510                    {
511                        match QuillValue::from_toml(value) {
512                            Ok(quill_value) => {
513                                metadata.insert(key.clone(), quill_value);
514                            }
515                            Err(e) => {
516                                eprintln!("Warning: Failed to convert field '{}': {}", key, e);
517                            }
518                        }
519                    }
520                }
521            }
522        }
523
524        // Extract fields from [typst] section
525        if let Some(typst_section) = quill_toml.get("typst") {
526            if let toml::Value::Table(table) = typst_section {
527                for (key, value) in table {
528                    match QuillValue::from_toml(value) {
529                        Ok(quill_value) => {
530                            metadata.insert(format!("typst_{}", key), quill_value);
531                        }
532                        Err(e) => {
533                            eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
534                        }
535                    }
536                }
537            }
538        }
539
540        // Parse field schemas from top-level [fields] section
541        if let Some(fields_section) = quill_toml.get("fields") {
542            if let toml::Value::Table(fields_table) = fields_section {
543                for (field_name, field_schema) in fields_table {
544                    match QuillValue::from_toml(field_schema) {
545                        Ok(quill_value) => match FieldSchema::from_quill_value(&quill_value) {
546                            Ok(schema) => {
547                                field_schemas.insert(field_name.clone(), schema);
548                            }
549                            Err(e) => {
550                                eprintln!(
551                                    "Warning: Failed to parse field schema '{}': {}",
552                                    field_name, e
553                                );
554                            }
555                        },
556                        Err(e) => {
557                            eprintln!(
558                                "Warning: Failed to convert field schema '{}': {}",
559                                field_name, e
560                            );
561                        }
562                    }
563                }
564            }
565        }
566
567        // Read the template content from glue file (if specified)
568        let template_content = if let Some(ref glue_file_name) = glue_file {
569            let glue_bytes = root
570                .get_file(glue_file_name)
571                .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
572
573            String::from_utf8(glue_bytes.to_vec())
574                .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?
575        } else {
576            // If no glue file specified, use empty string (JSON glue will be used)
577            String::new()
578        };
579
580        // Read the markdown template content if specified
581        let template_content_opt = if let Some(ref template_file_name) = template_file {
582            root.get_file(template_file_name).and_then(|bytes| {
583                String::from_utf8(bytes.to_vec())
584                    .map_err(|e| {
585                        eprintln!(
586                            "Warning: Template file '{}' is not valid UTF-8: {}",
587                            template_file_name, e
588                        );
589                        e
590                    })
591                    .ok()
592            })
593        } else {
594            None
595        };
596
597        let quill = Quill {
598            glue_template: template_content,
599            metadata,
600            name: quill_name,
601            backend,
602            glue_file,
603            example: template_content_opt,
604            field_schemas,
605            files: root,
606        };
607
608        // Automatically validate the quill upon creation
609        quill.validate()?;
610
611        Ok(quill)
612    }
613
614    /// Create a Quill from a JSON representation
615    ///
616    /// Parses a JSON string into an in-memory file tree and validates it. The
617    /// precise JSON contract is documented in `designs/QUILL_DESIGN.md`.
618    /// The JSON format MUST have a root object with a `files` key. The optional
619    /// `metadata` key provides additional metadata that overrides defaults.
620    pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
621        use serde_json::Value as JsonValue;
622
623        let json: JsonValue =
624            serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
625
626        let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
627
628        // Extract metadata (optional)
629        let default_name = obj
630            .get("metadata")
631            .and_then(|m| m.get("name"))
632            .and_then(|v| v.as_str())
633            .map(String::from);
634
635        // Extract files (required)
636        let files_obj = obj
637            .get("files")
638            .and_then(|v| v.as_object())
639            .ok_or_else(|| "Missing or invalid 'files' key")?;
640
641        // Parse file tree
642        let mut root_files = HashMap::new();
643        for (key, value) in files_obj {
644            root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
645        }
646
647        let root = FileTreeNode::Directory { files: root_files };
648
649        // Create Quill from tree
650        Self::from_tree(root, default_name)
651    }
652
653    /// Recursively load all files from a directory into a tree structure
654    fn load_directory_as_tree(
655        current_dir: &Path,
656        base_dir: &Path,
657        ignore: &QuillIgnore,
658    ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
659        use std::fs;
660
661        if !current_dir.exists() {
662            return Ok(FileTreeNode::Directory {
663                files: HashMap::new(),
664            });
665        }
666
667        let mut files = HashMap::new();
668
669        for entry in fs::read_dir(current_dir)? {
670            let entry = entry?;
671            let path = entry.path();
672            let relative_path = path
673                .strip_prefix(base_dir)
674                .map_err(|e| format!("Failed to get relative path: {}", e))?
675                .to_path_buf();
676
677            // Check if this path should be ignored
678            if ignore.is_ignored(&relative_path) {
679                continue;
680            }
681
682            // Get the filename
683            let filename = path
684                .file_name()
685                .and_then(|n| n.to_str())
686                .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
687                .to_string();
688
689            if path.is_file() {
690                let contents = fs::read(&path)
691                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
692
693                files.insert(filename, FileTreeNode::File { contents });
694            } else if path.is_dir() {
695                // Recursively process subdirectory
696                let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
697                files.insert(filename, subdir_tree);
698            }
699        }
700
701        Ok(FileTreeNode::Directory { files })
702    }
703
704    /// Get the list of typst packages to download, if specified in Quill.toml
705    pub fn typst_packages(&self) -> Vec<String> {
706        self.metadata
707            .get("typst_packages")
708            .and_then(|v| v.as_array())
709            .map(|arr| {
710                arr.iter()
711                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
712                    .collect()
713            })
714            .unwrap_or_default()
715    }
716
717    /// Validate the quill structure
718    pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
719        // Check that glue file exists in memory (if specified)
720        if let Some(ref glue_file) = self.glue_file {
721            if !self.files.file_exists(glue_file) {
722                return Err(format!("Glue file '{}' does not exist", glue_file).into());
723            }
724        }
725        Ok(())
726    }
727
728    /// Get file contents by path (relative to quill root)
729    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
730        self.files.get_file(path)
731    }
732
733    /// Check if a file exists in memory
734    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
735        self.files.file_exists(path)
736    }
737
738    /// Check if a directory exists in memory
739    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
740        self.files.dir_exists(path)
741    }
742
743    /// List files in a directory (non-recursive, returns file names only)
744    pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
745        self.files.list_files(path)
746    }
747
748    /// List subdirectories in a directory (non-recursive, returns directory names only)
749    pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
750        self.files.list_subdirectories(path)
751    }
752
753    /// List all files in a directory (returns paths relative to quill root)
754    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
755        let dir_path = dir_path.as_ref();
756        let filenames = self.files.list_files(dir_path);
757
758        // Convert filenames to full paths
759        filenames
760            .iter()
761            .map(|name| {
762                if dir_path == Path::new("") {
763                    PathBuf::from(name)
764                } else {
765                    dir_path.join(name)
766                }
767            })
768            .collect()
769    }
770
771    /// List all directories in a directory (returns paths relative to quill root)
772    pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
773        let dir_path = dir_path.as_ref();
774        let subdirs = self.files.list_subdirectories(dir_path);
775
776        // Convert subdirectory names to full paths
777        subdirs
778            .iter()
779            .map(|name| {
780                if dir_path == Path::new("") {
781                    PathBuf::from(name)
782                } else {
783                    dir_path.join(name)
784                }
785            })
786            .collect()
787    }
788
789    /// Get all files matching a pattern (supports glob-style wildcards)
790    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
791        let pattern_str = pattern.as_ref().to_string_lossy();
792        let mut matches = Vec::new();
793
794        // Compile the glob pattern
795        let glob_pattern = match glob::Pattern::new(&pattern_str) {
796            Ok(pat) => pat,
797            Err(_) => return matches, // Invalid pattern returns empty results
798        };
799
800        // Recursively search the tree for matching files
801        self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
802
803        matches.sort();
804        matches
805    }
806
807    /// Helper method to recursively search for files matching a pattern
808    fn find_files_recursive(
809        &self,
810        node: &FileTreeNode,
811        current_path: &Path,
812        pattern: &glob::Pattern,
813        matches: &mut Vec<PathBuf>,
814    ) {
815        match node {
816            FileTreeNode::File { .. } => {
817                let path_str = current_path.to_string_lossy();
818                if pattern.matches(&path_str) {
819                    matches.push(current_path.to_path_buf());
820                }
821            }
822            FileTreeNode::Directory { files } => {
823                for (name, child_node) in files {
824                    let child_path = if current_path == Path::new("") {
825                        PathBuf::from(name)
826                    } else {
827                        current_path.join(name)
828                    };
829                    self.find_files_recursive(child_node, &child_path, pattern, matches);
830                }
831            }
832        }
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use std::fs;
840    use tempfile::TempDir;
841
842    #[test]
843    fn test_quillignore_parsing() {
844        let ignore_content = r#"
845# This is a comment
846*.tmp
847target/
848node_modules/
849.git/
850"#;
851        let ignore = QuillIgnore::from_content(ignore_content);
852        assert_eq!(ignore.patterns.len(), 4);
853        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
854        assert!(ignore.patterns.contains(&"target/".to_string()));
855    }
856
857    #[test]
858    fn test_quillignore_matching() {
859        let ignore = QuillIgnore::new(vec![
860            "*.tmp".to_string(),
861            "target/".to_string(),
862            "node_modules/".to_string(),
863            ".git/".to_string(),
864        ]);
865
866        // Test file patterns
867        assert!(ignore.is_ignored("test.tmp"));
868        assert!(ignore.is_ignored("path/to/file.tmp"));
869        assert!(!ignore.is_ignored("test.txt"));
870
871        // Test directory patterns
872        assert!(ignore.is_ignored("target"));
873        assert!(ignore.is_ignored("target/debug"));
874        assert!(ignore.is_ignored("target/debug/deps"));
875        assert!(!ignore.is_ignored("src/target.rs"));
876
877        assert!(ignore.is_ignored("node_modules"));
878        assert!(ignore.is_ignored("node_modules/package"));
879        assert!(!ignore.is_ignored("my_node_modules"));
880    }
881
882    #[test]
883    fn test_in_memory_file_system() {
884        let temp_dir = TempDir::new().unwrap();
885        let quill_dir = temp_dir.path();
886
887        // Create test files
888        fs::write(
889            quill_dir.join("Quill.toml"),
890            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
891        )
892        .unwrap();
893        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
894
895        let assets_dir = quill_dir.join("assets");
896        fs::create_dir_all(&assets_dir).unwrap();
897        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
898
899        let packages_dir = quill_dir.join("packages");
900        fs::create_dir_all(&packages_dir).unwrap();
901        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
902
903        // Load quill
904        let quill = Quill::from_path(quill_dir).unwrap();
905
906        // Test file access
907        assert!(quill.file_exists("glue.typ"));
908        assert!(quill.file_exists("assets/test.txt"));
909        assert!(quill.file_exists("packages/package.typ"));
910        assert!(!quill.file_exists("nonexistent.txt"));
911
912        // Test file content
913        let asset_content = quill.get_file("assets/test.txt").unwrap();
914        assert_eq!(asset_content, b"asset content");
915
916        // Test directory listing
917        let asset_files = quill.list_directory("assets");
918        assert_eq!(asset_files.len(), 1);
919        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
920    }
921
922    #[test]
923    fn test_quillignore_integration() {
924        let temp_dir = TempDir::new().unwrap();
925        let quill_dir = temp_dir.path();
926
927        // Create .quillignore
928        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
929
930        // Create test files
931        fs::write(
932            quill_dir.join("Quill.toml"),
933            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
934        )
935        .unwrap();
936        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
937        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
938
939        let target_dir = quill_dir.join("target");
940        fs::create_dir_all(&target_dir).unwrap();
941        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
942
943        // Load quill
944        let quill = Quill::from_path(quill_dir).unwrap();
945
946        // Test that ignored files are not loaded
947        assert!(quill.file_exists("glue.typ"));
948        assert!(!quill.file_exists("should_ignore.tmp"));
949        assert!(!quill.file_exists("target/debug.txt"));
950    }
951
952    #[test]
953    fn test_find_files_pattern() {
954        let temp_dir = TempDir::new().unwrap();
955        let quill_dir = temp_dir.path();
956
957        // Create test directory structure
958        fs::write(
959            quill_dir.join("Quill.toml"),
960            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
961        )
962        .unwrap();
963        fs::write(quill_dir.join("glue.typ"), "template").unwrap();
964
965        let assets_dir = quill_dir.join("assets");
966        fs::create_dir_all(&assets_dir).unwrap();
967        fs::write(assets_dir.join("image.png"), "png data").unwrap();
968        fs::write(assets_dir.join("data.json"), "json data").unwrap();
969
970        let fonts_dir = assets_dir.join("fonts");
971        fs::create_dir_all(&fonts_dir).unwrap();
972        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
973
974        // Load quill
975        let quill = Quill::from_path(quill_dir).unwrap();
976
977        // Test pattern matching
978        let all_assets = quill.find_files("assets/*");
979        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
980
981        let typ_files = quill.find_files("*.typ");
982        assert_eq!(typ_files.len(), 1);
983        assert!(typ_files.contains(&PathBuf::from("glue.typ")));
984    }
985
986    #[test]
987    fn test_new_standardized_toml_format() {
988        let temp_dir = TempDir::new().unwrap();
989        let quill_dir = temp_dir.path();
990
991        // Create test files using new standardized format
992        let toml_content = r#"[Quill]
993name = "my-custom-quill"
994backend = "typst"
995glue = "custom_glue.typ"
996description = "Test quill with new format"
997author = "Test Author"
998"#;
999        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1000        fs::write(
1001            quill_dir.join("custom_glue.typ"),
1002            "= Custom Template\n\nThis is a custom template.",
1003        )
1004        .unwrap();
1005
1006        // Load quill
1007        let quill = Quill::from_path(quill_dir).unwrap();
1008
1009        // Test that name comes from TOML, not directory
1010        assert_eq!(quill.name, "my-custom-quill");
1011
1012        // Test that glue file is set correctly
1013        assert_eq!(quill.glue_file, Some("custom_glue.typ".to_string()));
1014
1015        // Test that backend is in metadata
1016        assert!(quill.metadata.contains_key("backend"));
1017        if let Some(backend_val) = quill.metadata.get("backend") {
1018            if let Some(backend_str) = backend_val.as_str() {
1019                assert_eq!(backend_str, "typst");
1020            } else {
1021                panic!("Backend value is not a string");
1022            }
1023        }
1024
1025        // Test that other fields are in metadata (but not version)
1026        assert!(quill.metadata.contains_key("description"));
1027        assert!(quill.metadata.contains_key("author"));
1028        assert!(!quill.metadata.contains_key("version")); // version should be excluded
1029
1030        // Test that glue template content is loaded correctly
1031        assert!(quill.glue_template.contains("Custom Template"));
1032        assert!(quill.glue_template.contains("custom template"));
1033    }
1034
1035    #[test]
1036    fn test_typst_packages_parsing() {
1037        let temp_dir = TempDir::new().unwrap();
1038        let quill_dir = temp_dir.path();
1039
1040        let toml_content = r#"
1041[Quill]
1042name = "test-quill"
1043backend = "typst"
1044glue = "glue.typ"
1045description = "Test quill for packages"
1046
1047[typst]
1048packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1049"#;
1050
1051        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1052        fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1053
1054        let quill = Quill::from_path(quill_dir).unwrap();
1055        let packages = quill.typst_packages();
1056
1057        assert_eq!(packages.len(), 2);
1058        assert_eq!(packages[0], "@preview/bubble:0.2.2");
1059        assert_eq!(packages[1], "@preview/example:1.0.0");
1060    }
1061
1062    #[test]
1063    fn test_template_loading() {
1064        let temp_dir = TempDir::new().unwrap();
1065        let quill_dir = temp_dir.path();
1066
1067        // Create test files with example specified
1068        let toml_content = r#"[Quill]
1069name = "test-with-template"
1070backend = "typst"
1071glue = "glue.typ"
1072example = "example.md"
1073description = "Test quill with template"
1074"#;
1075        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1076        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1077        fs::write(
1078            quill_dir.join("example.md"),
1079            "---\ntitle: Test\n---\n\nThis is a test template.",
1080        )
1081        .unwrap();
1082
1083        // Load quill
1084        let quill = Quill::from_path(quill_dir).unwrap();
1085
1086        // Test that example content is loaded and includes some the text
1087        assert!(quill.example.is_some());
1088        let example = quill.example.unwrap();
1089        assert!(example.contains("title: Test"));
1090        assert!(example.contains("This is a test template"));
1091
1092        // Test that glue template is still loaded
1093        assert_eq!(quill.glue_template, "glue content");
1094    }
1095
1096    #[test]
1097    fn test_template_optional() {
1098        let temp_dir = TempDir::new().unwrap();
1099        let quill_dir = temp_dir.path();
1100
1101        // Create test files without example specified
1102        let toml_content = r#"[Quill]
1103name = "test-without-template"
1104backend = "typst"
1105glue = "glue.typ"
1106description = "Test quill without template"
1107"#;
1108        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1109        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1110
1111        // Load quill
1112        let quill = Quill::from_path(quill_dir).unwrap();
1113
1114        // Test that example fields are None
1115        assert_eq!(quill.example, None);
1116
1117        // Test that glue template is still loaded
1118        assert_eq!(quill.glue_template, "glue content");
1119    }
1120
1121    #[test]
1122    fn test_from_tree() {
1123        // Create a simple in-memory file tree
1124        let mut root_files = HashMap::new();
1125
1126        // Add Quill.toml
1127        let quill_toml = r#"[Quill]
1128name = "test-from-tree"
1129backend = "typst"
1130glue = "glue.typ"
1131description = "A test quill from tree"
1132"#;
1133        root_files.insert(
1134            "Quill.toml".to_string(),
1135            FileTreeNode::File {
1136                contents: quill_toml.as_bytes().to_vec(),
1137            },
1138        );
1139
1140        // Add glue file
1141        let glue_content = "= Test Template\n\nThis is a test.";
1142        root_files.insert(
1143            "glue.typ".to_string(),
1144            FileTreeNode::File {
1145                contents: glue_content.as_bytes().to_vec(),
1146            },
1147        );
1148
1149        let root = FileTreeNode::Directory { files: root_files };
1150
1151        // Create Quill from tree
1152        let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1153
1154        // Validate the quill
1155        assert_eq!(quill.name, "test-from-tree");
1156        assert_eq!(quill.glue_file, Some("glue.typ".to_string()));
1157        assert_eq!(quill.glue_template, glue_content);
1158        assert!(quill.metadata.contains_key("backend"));
1159        assert!(quill.metadata.contains_key("description"));
1160    }
1161
1162    #[test]
1163    fn test_from_tree_with_template() {
1164        let mut root_files = HashMap::new();
1165
1166        // Add Quill.toml with example specified
1167        let quill_toml = r#"[Quill]
1168name = "test-tree-template"
1169backend = "typst"
1170glue = "glue.typ"
1171example = "template.md"
1172description = "Test tree with template"
1173"#;
1174        root_files.insert(
1175            "Quill.toml".to_string(),
1176            FileTreeNode::File {
1177                contents: quill_toml.as_bytes().to_vec(),
1178            },
1179        );
1180
1181        // Add glue file
1182        root_files.insert(
1183            "glue.typ".to_string(),
1184            FileTreeNode::File {
1185                contents: b"glue content".to_vec(),
1186            },
1187        );
1188
1189        // Add template file
1190        let template_content = "# {{ title }}\n\n{{ body }}";
1191        root_files.insert(
1192            "template.md".to_string(),
1193            FileTreeNode::File {
1194                contents: template_content.as_bytes().to_vec(),
1195            },
1196        );
1197
1198        let root = FileTreeNode::Directory { files: root_files };
1199
1200        // Create Quill from tree
1201        let quill = Quill::from_tree(root, None).unwrap();
1202
1203        // Validate template is loaded
1204        assert_eq!(quill.example, Some(template_content.to_string()));
1205    }
1206
1207    #[test]
1208    fn test_from_json() {
1209        // Create JSON representation of a Quill using new format
1210        let json_str = r#"{
1211            "metadata": {
1212                "name": "test-from-json"
1213            },
1214            "files": {
1215                "Quill.toml": {
1216                    "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1217                },
1218                "glue.typ": {
1219                    "contents": "= Test Glue\n\nThis is test content."
1220                }
1221            }
1222        }"#;
1223
1224        // Create Quill from JSON
1225        let quill = Quill::from_json(json_str).unwrap();
1226
1227        // Validate the quill
1228        assert_eq!(quill.name, "test-from-json");
1229        assert_eq!(quill.glue_file, Some("glue.typ".to_string()));
1230        assert!(quill.glue_template.contains("Test Glue"));
1231        assert!(quill.metadata.contains_key("backend"));
1232    }
1233
1234    #[test]
1235    fn test_from_json_with_byte_array() {
1236        // Create JSON with byte array representation using new format
1237        let json_str = r#"{
1238            "files": {
1239                "Quill.toml": {
1240                    "contents": [91, 81, 117, 105, 108, 108, 93, 10, 110, 97, 109, 101, 32, 61, 32, 34, 116, 101, 115, 116, 34, 10, 98, 97, 99, 107, 101, 110, 100, 32, 61, 32, 34, 116, 121, 112, 115, 116, 34, 10, 103, 108, 117, 101, 32, 61, 32, 34, 103, 108, 117, 101, 46, 116, 121, 112, 34, 10, 100, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 32, 61, 32, 34, 84, 101, 115, 116, 32, 113, 117, 105, 108, 108, 34, 10]
1241                },
1242                "glue.typ": {
1243                    "contents": "test glue"
1244                }
1245            }
1246        }"#;
1247
1248        // Create Quill from JSON
1249        let quill = Quill::from_json(json_str).unwrap();
1250
1251        // Validate the quill was created
1252        assert_eq!(quill.name, "test");
1253        assert_eq!(quill.glue_file, Some("glue.typ".to_string()));
1254    }
1255
1256    #[test]
1257    fn test_from_json_missing_files() {
1258        // JSON without files field should fail
1259        let json_str = r#"{
1260            "metadata": {
1261                "name": "test"
1262            }
1263        }"#;
1264
1265        let result = Quill::from_json(json_str);
1266        assert!(result.is_err());
1267        // Should fail because there's no 'files' key
1268        assert!(result.unwrap_err().to_string().contains("files"));
1269    }
1270
1271    #[test]
1272    fn test_from_json_tree_structure() {
1273        // Test the new tree structure format
1274        let json_str = r#"{
1275            "files": {
1276                "Quill.toml": {
1277                    "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1278                },
1279                "glue.typ": {
1280                    "contents": "= Test Glue\n\nTree structure content."
1281                }
1282            }
1283        }"#;
1284
1285        let quill = Quill::from_json(json_str).unwrap();
1286
1287        assert_eq!(quill.name, "test-tree-json");
1288        assert!(quill.glue_template.contains("Tree structure content"));
1289        assert!(quill.metadata.contains_key("backend"));
1290    }
1291
1292    #[test]
1293    fn test_from_json_nested_tree_structure() {
1294        // Test nested directories in tree structure
1295        let json_str = r#"{
1296            "files": {
1297                "Quill.toml": {
1298                    "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Nested test\"\n"
1299                },
1300                "glue.typ": {
1301                    "contents": "glue"
1302                },
1303                "src": {
1304                    "main.rs": {
1305                        "contents": "fn main() {}"
1306                    },
1307                    "lib.rs": {
1308                        "contents": "// lib"
1309                    }
1310                }
1311            }
1312        }"#;
1313
1314        let quill = Quill::from_json(json_str).unwrap();
1315
1316        assert_eq!(quill.name, "nested-test");
1317        // Verify nested files are accessible
1318        assert!(quill.file_exists("src/main.rs"));
1319        assert!(quill.file_exists("src/lib.rs"));
1320
1321        let main_rs = quill.get_file("src/main.rs").unwrap();
1322        assert_eq!(main_rs, b"fn main() {}");
1323    }
1324
1325    #[test]
1326    fn test_from_tree_structure_direct() {
1327        // Test using from_tree_structure directly
1328        let mut root_files = HashMap::new();
1329
1330        root_files.insert(
1331            "Quill.toml".to_string(),
1332            FileTreeNode::File {
1333                contents:
1334                    b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1335                        .to_vec(),
1336            },
1337        );
1338
1339        root_files.insert(
1340            "glue.typ".to_string(),
1341            FileTreeNode::File {
1342                contents: b"glue content".to_vec(),
1343            },
1344        );
1345
1346        // Add a nested directory
1347        let mut src_files = HashMap::new();
1348        src_files.insert(
1349            "main.rs".to_string(),
1350            FileTreeNode::File {
1351                contents: b"fn main() {}".to_vec(),
1352            },
1353        );
1354
1355        root_files.insert(
1356            "src".to_string(),
1357            FileTreeNode::Directory { files: src_files },
1358        );
1359
1360        let root = FileTreeNode::Directory { files: root_files };
1361
1362        let quill = Quill::from_tree(root, None).unwrap();
1363
1364        assert_eq!(quill.name, "direct-tree");
1365        assert!(quill.file_exists("src/main.rs"));
1366        assert!(quill.file_exists("glue.typ"));
1367    }
1368
1369    #[test]
1370    fn test_from_json_with_metadata_override() {
1371        // Test that metadata key overrides name from Quill.toml
1372        let json_str = r#"{
1373            "metadata": {
1374                "name": "override-name"
1375            },
1376            "files": {
1377                "Quill.toml": {
1378                    "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1379                },
1380                "glue.typ": {
1381                    "contents": "= glue"
1382                }
1383            }
1384        }"#;
1385
1386        let quill = Quill::from_json(json_str).unwrap();
1387        // Metadata name should be used as default, but Quill.toml takes precedence
1388        // when from_tree is called
1389        assert_eq!(quill.name, "toml-name");
1390    }
1391
1392    #[test]
1393    fn test_from_json_empty_directory() {
1394        // Test that empty directories are supported
1395        let json_str = r#"{
1396            "files": {
1397                "Quill.toml": {
1398                    "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1399                },
1400                "glue.typ": {
1401                    "contents": "glue"
1402                },
1403                "empty_dir": {}
1404            }
1405        }"#;
1406
1407        let quill = Quill::from_json(json_str).unwrap();
1408        assert_eq!(quill.name, "empty-dir-test");
1409        assert!(quill.dir_exists("empty_dir"));
1410        assert!(!quill.file_exists("empty_dir"));
1411    }
1412
1413    #[test]
1414    fn test_dir_exists_and_list_apis() {
1415        let mut root_files = HashMap::new();
1416
1417        // Add Quill.toml
1418        root_files.insert(
1419            "Quill.toml".to_string(),
1420            FileTreeNode::File {
1421                contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"\n"
1422                    .to_vec(),
1423            },
1424        );
1425
1426        // Add glue file
1427        root_files.insert(
1428            "glue.typ".to_string(),
1429            FileTreeNode::File {
1430                contents: b"glue content".to_vec(),
1431            },
1432        );
1433
1434        // Add assets directory with files
1435        let mut assets_files = HashMap::new();
1436        assets_files.insert(
1437            "logo.png".to_string(),
1438            FileTreeNode::File {
1439                contents: vec![137, 80, 78, 71],
1440            },
1441        );
1442        assets_files.insert(
1443            "icon.svg".to_string(),
1444            FileTreeNode::File {
1445                contents: b"<svg></svg>".to_vec(),
1446            },
1447        );
1448
1449        // Add subdirectory in assets
1450        let mut fonts_files = HashMap::new();
1451        fonts_files.insert(
1452            "font.ttf".to_string(),
1453            FileTreeNode::File {
1454                contents: b"font data".to_vec(),
1455            },
1456        );
1457        assets_files.insert(
1458            "fonts".to_string(),
1459            FileTreeNode::Directory { files: fonts_files },
1460        );
1461
1462        root_files.insert(
1463            "assets".to_string(),
1464            FileTreeNode::Directory {
1465                files: assets_files,
1466            },
1467        );
1468
1469        // Add empty directory
1470        root_files.insert(
1471            "empty".to_string(),
1472            FileTreeNode::Directory {
1473                files: HashMap::new(),
1474            },
1475        );
1476
1477        let root = FileTreeNode::Directory { files: root_files };
1478        let quill = Quill::from_tree(root, None).unwrap();
1479
1480        // Test dir_exists
1481        assert!(quill.dir_exists("assets"));
1482        assert!(quill.dir_exists("assets/fonts"));
1483        assert!(quill.dir_exists("empty"));
1484        assert!(!quill.dir_exists("nonexistent"));
1485        assert!(!quill.dir_exists("glue.typ")); // file, not directory
1486
1487        // Test file_exists
1488        assert!(quill.file_exists("glue.typ"));
1489        assert!(quill.file_exists("assets/logo.png"));
1490        assert!(quill.file_exists("assets/fonts/font.ttf"));
1491        assert!(!quill.file_exists("assets")); // directory, not file
1492
1493        // Test list_files
1494        let root_files_list = quill.list_files("");
1495        assert_eq!(root_files_list.len(), 2); // Quill.toml and glue.typ
1496        assert!(root_files_list.contains(&"Quill.toml".to_string()));
1497        assert!(root_files_list.contains(&"glue.typ".to_string()));
1498
1499        let assets_files_list = quill.list_files("assets");
1500        assert_eq!(assets_files_list.len(), 2); // logo.png and icon.svg
1501        assert!(assets_files_list.contains(&"logo.png".to_string()));
1502        assert!(assets_files_list.contains(&"icon.svg".to_string()));
1503
1504        // Test list_subdirectories
1505        let root_subdirs = quill.list_subdirectories("");
1506        assert_eq!(root_subdirs.len(), 2); // assets and empty
1507        assert!(root_subdirs.contains(&"assets".to_string()));
1508        assert!(root_subdirs.contains(&"empty".to_string()));
1509
1510        let assets_subdirs = quill.list_subdirectories("assets");
1511        assert_eq!(assets_subdirs.len(), 1); // fonts
1512        assert!(assets_subdirs.contains(&"fonts".to_string()));
1513
1514        let empty_subdirs = quill.list_subdirectories("empty");
1515        assert_eq!(empty_subdirs.len(), 0);
1516    }
1517
1518    #[test]
1519    fn test_field_schemas_parsing() {
1520        let mut root_files = HashMap::new();
1521
1522        // Add Quill.toml with field schemas
1523        let quill_toml = r#"[Quill]
1524name = "taro"
1525backend = "typst"
1526glue = "glue.typ"
1527example = "taro.md"
1528description = "Test template for field schemas"
1529
1530[fields]
1531author = {description = "Author of document", required = true}
1532ice_cream = {description = "favorite ice cream flavor"}
1533title = {description = "title of document", required = true}
1534"#;
1535        root_files.insert(
1536            "Quill.toml".to_string(),
1537            FileTreeNode::File {
1538                contents: quill_toml.as_bytes().to_vec(),
1539            },
1540        );
1541
1542        // Add glue file
1543        let glue_content = "= Test Template\n\nThis is a test.";
1544        root_files.insert(
1545            "glue.typ".to_string(),
1546            FileTreeNode::File {
1547                contents: glue_content.as_bytes().to_vec(),
1548            },
1549        );
1550
1551        // Add template file
1552        root_files.insert(
1553            "taro.md".to_string(),
1554            FileTreeNode::File {
1555                contents: b"# Template".to_vec(),
1556            },
1557        );
1558
1559        let root = FileTreeNode::Directory { files: root_files };
1560
1561        // Create Quill from tree
1562        let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1563
1564        // Validate field schemas were parsed
1565        assert_eq!(quill.field_schemas.len(), 3);
1566        assert!(quill.field_schemas.contains_key("author"));
1567        assert!(quill.field_schemas.contains_key("ice_cream"));
1568        assert!(quill.field_schemas.contains_key("title"));
1569
1570        // Verify author field schema
1571        let author_schema = quill.field_schemas.get("author").unwrap();
1572        assert_eq!(author_schema.description, "Author of document");
1573        assert_eq!(author_schema.required, true);
1574
1575        // Verify ice_cream field schema (no required field, should default to false)
1576        let ice_cream_schema = quill.field_schemas.get("ice_cream").unwrap();
1577        assert_eq!(ice_cream_schema.description, "favorite ice cream flavor");
1578        assert_eq!(ice_cream_schema.required, false);
1579
1580        // Verify title field schema
1581        let title_schema = quill.field_schemas.get("title").unwrap();
1582        assert_eq!(title_schema.description, "title of document");
1583        assert_eq!(title_schema.required, true);
1584    }
1585
1586    #[test]
1587    fn test_field_schema_struct() {
1588        // Test creating FieldSchema with minimal fields
1589        let schema1 = FieldSchema::new("Test description".to_string());
1590        assert_eq!(schema1.description, "Test description");
1591        assert_eq!(schema1.required, false);
1592        assert_eq!(schema1.r#type, None);
1593        assert_eq!(schema1.example, None);
1594        assert_eq!(schema1.default, None);
1595
1596        // Test parsing FieldSchema from YAML with all fields
1597        let yaml_str = r#"
1598description: "Full field schema"
1599required: true
1600type: "string"
1601example: "Example value"
1602default: "Default value"
1603"#;
1604        let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1605        let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1606        let schema2 = FieldSchema::from_quill_value(&quill_value).unwrap();
1607        assert_eq!(schema2.description, "Full field schema");
1608        assert_eq!(schema2.required, true);
1609        assert_eq!(schema2.r#type, Some("string".to_string()));
1610        assert_eq!(
1611            schema2.example.as_ref().and_then(|v| v.as_str()),
1612            Some("Example value")
1613        );
1614        assert_eq!(
1615            schema2.default.as_ref().and_then(|v| v.as_str()),
1616            Some("Default value")
1617        );
1618
1619        // Test converting FieldSchema back to QuillValue
1620        let quill_value = schema2.to_quill_value();
1621        let obj = quill_value.as_object().unwrap();
1622        assert_eq!(
1623            obj.get("description").unwrap().as_str().unwrap(),
1624            "Full field schema"
1625        );
1626        assert_eq!(obj.get("required").unwrap().as_bool().unwrap(), true);
1627        assert_eq!(obj.get("type").unwrap().as_str().unwrap(), "string");
1628    }
1629
1630    #[test]
1631    fn test_quill_without_glue_file() {
1632        // Test creating a Quill without specifying a glue file
1633        let mut root_files = HashMap::new();
1634
1635        // Add Quill.toml without glue field
1636        let quill_toml = r#"[Quill]
1637name = "test-no-glue"
1638backend = "typst"
1639description = "Test quill without glue file"
1640"#;
1641        root_files.insert(
1642            "Quill.toml".to_string(),
1643            FileTreeNode::File {
1644                contents: quill_toml.as_bytes().to_vec(),
1645            },
1646        );
1647
1648        let root = FileTreeNode::Directory { files: root_files };
1649
1650        // Create Quill from tree
1651        let quill = Quill::from_tree(root, None).unwrap();
1652
1653        // Validate that glue_file is None
1654        assert_eq!(quill.glue_file, None);
1655        // Validate that glue_template is empty (will use JSON glue)
1656        assert_eq!(quill.glue_template, "");
1657        assert_eq!(quill.name, "test-no-glue");
1658    }
1659}