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