quillmark_core/
quill.rs

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