quillmark_core/
quill.rs

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