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