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