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