quillmark_core/
lib.rs

1//! # Quillmark Core Overview
2//!
3//! Core types and functionality for the Quillmark template-first Markdown rendering system.
4//!
5//! ## Features
6//!
7//! This crate provides the foundational types and traits for Quillmark:
8//!
9//! - **Parsing**: YAML frontmatter extraction with Extended YAML Metadata Standard support
10//! - **Templating**: MiniJinja-based template composition with stable filter API
11//! - **Template model**: [`Quill`] type for managing template bundles with in-memory file system
12//! - **Backend trait**: Extensible interface for implementing output format backends
13//! - **Error handling**: Structured diagnostics with source location tracking
14//! - **Utilities**: TOML⇄YAML conversion helpers
15//!
16//! ## Quick Start
17//!
18//! ```no_run
19//! use quillmark_core::{decompose, Quill};
20//!
21//! // Parse markdown with frontmatter
22//! let markdown = "---\ntitle: Example\n---\n\n# Content";
23//! let doc = decompose(markdown).unwrap();
24//!
25//! // Load a quill template
26//! let quill = Quill::from_path("path/to/quill").unwrap();
27//! ```
28//!
29//! ## Architecture
30//!
31//! The crate is organized into four main modules:
32//!
33//! - [`parse`]: Markdown parsing with YAML frontmatter support
34//! - [`templating`]: Template composition using MiniJinja
35//! - [`backend`]: Backend trait for output format implementations
36//! - [`error`]: Structured error handling and diagnostics
37//!
38//! ## Further Reading
39//!
40//! - [PARSE.md](https://github.com/nibsbin/quillmark/blob/main/quillmark-core/docs/designs/PARSE.md) - Detailed parsing documentation
41//! - [Examples](https://github.com/nibsbin/quillmark/tree/main/examples) - Working examples
42
43use std::collections::HashMap;
44use std::error::Error as StdError;
45use std::path::{Path, PathBuf};
46
47pub mod parse;
48pub use parse::{decompose, ParsedDocument, BODY_FIELD};
49
50pub mod templating;
51pub use templating::{Glue, TemplateError};
52
53pub mod backend;
54pub use backend::Backend;
55
56pub mod error;
57pub use error::{Diagnostic, Location, RenderError, RenderResult, Severity};
58
59/// Output formats supported by backends. See [module docs](self) for examples.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
61pub enum OutputFormat {
62    /// Plain text output
63    Txt,
64    /// Scalable Vector Graphics output
65    Svg,
66    /// Portable Document Format output
67    Pdf,
68}
69
70/// An artifact produced by rendering. See [module docs](self) for examples.
71#[derive(Debug)]
72pub struct Artifact {
73    /// The binary content of the artifact
74    pub bytes: Vec<u8>,
75    /// The format of the output
76    pub output_format: OutputFormat,
77}
78
79/// Internal rendering options. See [module docs](self) for examples.
80#[derive(Debug)]
81pub struct RenderOptions {
82    /// Optional output format specification
83    pub output_format: Option<OutputFormat>,
84}
85
86/// A file entry in the in-memory file system
87#[derive(Debug, Clone)]
88pub struct FileEntry {
89    /// The file contents as bytes
90    pub contents: Vec<u8>,
91    /// The file path relative to the quill root
92    pub path: PathBuf,
93    /// Whether this is a directory entry
94    pub is_dir: bool,
95}
96
97/// Simple gitignore-style pattern matcher for .quillignore
98#[derive(Debug, Clone)]
99pub struct QuillIgnore {
100    patterns: Vec<String>,
101}
102
103impl QuillIgnore {
104    /// Create a new QuillIgnore from pattern strings
105    pub fn new(patterns: Vec<String>) -> Self {
106        Self { patterns }
107    }
108
109    /// Parse .quillignore content into patterns
110    pub fn from_content(content: &str) -> Self {
111        let patterns = content
112            .lines()
113            .map(|line| line.trim())
114            .filter(|line| !line.is_empty() && !line.starts_with('#'))
115            .map(|line| line.to_string())
116            .collect();
117        Self::new(patterns)
118    }
119
120    /// Check if a path should be ignored
121    pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
122        let path = path.as_ref();
123        let path_str = path.to_string_lossy();
124
125        for pattern in &self.patterns {
126            if self.matches_pattern(pattern, &path_str) {
127                return true;
128            }
129        }
130        false
131    }
132
133    /// Simple pattern matching (supports * wildcard and directory patterns)
134    fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
135        // Handle directory patterns
136        if pattern.ends_with('/') {
137            let pattern_prefix = &pattern[..pattern.len() - 1];
138            return path.starts_with(pattern_prefix)
139                && (path.len() == pattern_prefix.len()
140                    || path.chars().nth(pattern_prefix.len()) == Some('/'));
141        }
142
143        // Handle exact matches
144        if !pattern.contains('*') {
145            return path == pattern || path.ends_with(&format!("/{}", pattern));
146        }
147
148        // Simple wildcard matching
149        if pattern == "*" {
150            return true;
151        }
152
153        // Handle patterns with wildcards
154        let pattern_parts: Vec<&str> = pattern.split('*').collect();
155        if pattern_parts.len() == 2 {
156            let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
157            if prefix.is_empty() {
158                return path.ends_with(suffix);
159            } else if suffix.is_empty() {
160                return path.starts_with(prefix);
161            } else {
162                return path.starts_with(prefix) && path.ends_with(suffix);
163            }
164        }
165
166        false
167    }
168}
169
170/// A quill template bundle. See [module docs](self) for examples.
171#[derive(Debug, Clone)]
172pub struct Quill {
173    /// The template content
174    pub glue_template: String,
175    /// Quill-specific metadata
176    pub metadata: HashMap<String, serde_yaml::Value>,
177    /// Base path for resolving relative paths
178    pub base_path: PathBuf,
179    /// Name of the quill
180    pub name: String,
181    /// Glue template file name
182    pub glue_file: String,
183    /// Markdown template file name (optional)
184    pub template_file: Option<String>,
185    /// Markdown template content (optional)
186    pub template: Option<String>,
187    /// In-memory file system
188    pub files: HashMap<PathBuf, FileEntry>,
189}
190
191impl Quill {
192    /// Create a Quill from a directory path
193    pub fn from_path<P: AsRef<std::path::Path>>(
194        path: P,
195    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
196        use std::fs;
197
198        let path = path.as_ref();
199        let name = path
200            .file_name()
201            .and_then(|n| n.to_str())
202            .unwrap_or("unnamed")
203            .to_string();
204
205        // Load .quillignore if it exists
206        let quillignore_path = path.join(".quillignore");
207        let ignore = if quillignore_path.exists() {
208            let ignore_content = fs::read_to_string(&quillignore_path)
209                .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
210            QuillIgnore::from_content(&ignore_content)
211        } else {
212            // Default ignore patterns
213            QuillIgnore::new(vec![
214                ".git/".to_string(),
215                ".gitignore".to_string(),
216                ".quillignore".to_string(),
217                "target/".to_string(),
218                "node_modules/".to_string(),
219            ])
220        };
221
222        // Load all files into memory
223        let mut files = HashMap::new();
224        Self::load_directory_recursive(path, path, &mut files, &ignore)?;
225
226        // Create Quill from the file tree
227        Self::from_tree(files, Some(path.to_path_buf()), Some(name))
228    }
229
230    /// Create a Quill from a tree of files (authoritative method)
231    ///
232    /// This is the authoritative method for creating a Quill from an in-memory file tree.
233    /// Both `from_path` and `from_json` use this method internally.
234    ///
235    /// # Arguments
236    ///
237    /// * `files` - A map of file paths to `FileEntry` objects representing the file tree
238    /// * `base_path` - Optional base path for the Quill (defaults to "/")
239    /// * `default_name` - Optional default name (will be overridden by name in Quill.toml)
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if:
244    /// - Quill.toml is not found in the file tree
245    /// - Quill.toml is not valid UTF-8 or TOML
246    /// - The glue file specified in Quill.toml is not found or not valid UTF-8
247    /// - Validation fails
248    pub fn from_tree(
249        files: HashMap<PathBuf, FileEntry>,
250        base_path: Option<PathBuf>,
251        default_name: Option<String>,
252    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
253        // Read Quill.toml
254        let quill_toml_path = PathBuf::from("Quill.toml");
255        let quill_toml_entry = files
256            .get(&quill_toml_path)
257            .ok_or("Quill.toml not found in file tree")?;
258
259        let quill_toml_content = String::from_utf8(quill_toml_entry.contents.clone())
260            .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
261
262        let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
263            .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
264
265        let mut metadata = HashMap::new();
266        let mut glue_file = "glue.typ".to_string(); // default
267        let mut template_file: Option<String> = None;
268        let mut quill_name = default_name.unwrap_or_else(|| "unnamed".to_string());
269
270        // Extract fields from [Quill] section
271        if let Some(quill_section) = quill_toml.get("Quill") {
272            // Extract required fields: name, backend, glue, template
273            if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
274                quill_name = name_val.to_string();
275            }
276
277            if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
278                match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
279                    Ok(yaml_value) => {
280                        metadata.insert("backend".to_string(), yaml_value);
281                    }
282                    Err(e) => {
283                        eprintln!("Warning: Failed to convert backend field: {}", e);
284                    }
285                }
286            }
287
288            if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
289                glue_file = glue_val.to_string();
290            }
291
292            if let Some(template_val) = quill_section.get("template").and_then(|v| v.as_str()) {
293                template_file = Some(template_val.to_string());
294            }
295
296            // Add other fields to metadata (excluding special fields and version)
297            if let toml::Value::Table(table) = quill_section {
298                for (key, value) in table {
299                    if key != "name"
300                        && key != "backend"
301                        && key != "glue"
302                        && key != "template"
303                        && key != "version"
304                    {
305                        match Self::toml_to_yaml_value(value) {
306                            Ok(yaml_value) => {
307                                metadata.insert(key.clone(), yaml_value);
308                            }
309                            Err(e) => {
310                                eprintln!("Warning: Failed to convert field '{}': {}", key, e);
311                            }
312                        }
313                    }
314                }
315            }
316        }
317
318        // Extract fields from [typst] section
319        if let Some(typst_section) = quill_toml.get("typst") {
320            if let toml::Value::Table(table) = typst_section {
321                for (key, value) in table {
322                    match Self::toml_to_yaml_value(value) {
323                        Ok(yaml_value) => {
324                            metadata.insert(format!("typst_{}", key), yaml_value);
325                        }
326                        Err(e) => {
327                            eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
328                        }
329                    }
330                }
331            }
332        }
333
334        // Read the template content from glue file
335        let glue_path = PathBuf::from(&glue_file);
336        let glue_entry = files
337            .get(&glue_path)
338            .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file))?;
339
340        let template_content = String::from_utf8(glue_entry.contents.clone())
341            .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file, e))?;
342
343        // Read the markdown template content if specified
344        let template_content_opt = if let Some(ref template_file_name) = template_file {
345            let template_path = PathBuf::from(template_file_name);
346            files.get(&template_path).and_then(|entry| {
347                String::from_utf8(entry.contents.clone())
348                    .map_err(|e| {
349                        eprintln!(
350                            "Warning: Template file '{}' is not valid UTF-8: {}",
351                            template_file_name, e
352                        );
353                        e
354                    })
355                    .ok()
356            })
357        } else {
358            None
359        };
360
361        let quill = Quill {
362            glue_template: template_content,
363            metadata,
364            base_path: base_path.unwrap_or_else(|| PathBuf::from("/")),
365            name: quill_name,
366            glue_file,
367            template_file,
368            template: template_content_opt,
369            files,
370        };
371
372        // Automatically validate the quill upon creation
373        quill.validate()?;
374
375        Ok(quill)
376    }
377
378    /// Create a Quill from a JSON representation
379    ///
380    /// Parses a JSON string representing a Quill and creates a Quill instance.
381    /// The JSON should have the following structure:
382    ///
383    /// ```json
384    /// {
385    ///   "name": "optional-default-name",
386    ///   "base_path": "/optional/base/path",
387    ///   "files": {
388    ///     "Quill.toml": {
389    ///       "contents": "...",  // UTF-8 string or byte array
390    ///       "is_dir": false
391    ///     },
392    ///     "glue.typ": {
393    ///       "contents": "...",
394    ///       "is_dir": false
395    ///     }
396    ///   }
397    /// }
398    /// ```
399    ///
400    /// File contents can be either:
401    /// - A UTF-8 string (recommended for text files)
402    /// - An array of byte values (for binary files)
403    ///
404    /// # Errors
405    ///
406    /// Returns an error if:
407    /// - The JSON is malformed
408    /// - The "files" field is missing or not an object
409    /// - Any file contents are invalid
410    /// - Validation fails (via `from_tree`)
411    pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
412        use serde_json::Value as JsonValue;
413
414        let json: JsonValue =
415            serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
416
417        // Parse files from JSON
418        let files_json = json.get("files").ok_or("Missing 'files' field in JSON")?;
419
420        let mut files = HashMap::new();
421        if let JsonValue::Object(files_obj) = files_json {
422            for (path_str, file_data) in files_obj {
423                let path = PathBuf::from(path_str);
424
425                let contents =
426                    if let Some(content_str) = file_data.get("contents").and_then(|v| v.as_str()) {
427                        // Direct UTF-8 string
428                        content_str.as_bytes().to_vec()
429                    } else if let Some(bytes_array) =
430                        file_data.get("contents").and_then(|v| v.as_array())
431                    {
432                        // Array of byte values
433                        bytes_array
434                            .iter()
435                            .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
436                            .collect()
437                    } else {
438                        return Err(format!("Invalid contents for file '{}'", path_str).into());
439                    };
440
441                let is_dir = file_data
442                    .get("is_dir")
443                    .and_then(|v| v.as_bool())
444                    .unwrap_or(false);
445
446                files.insert(
447                    path.clone(),
448                    FileEntry {
449                        contents,
450                        path,
451                        is_dir,
452                    },
453                );
454            }
455        } else {
456            return Err("'files' field must be an object".into());
457        }
458
459        // Extract optional base_path and name from JSON
460        let base_path = json
461            .get("base_path")
462            .and_then(|v| v.as_str())
463            .map(PathBuf::from);
464
465        let default_name = json.get("name").and_then(|v| v.as_str()).map(String::from);
466
467        // Create Quill from the file tree
468        Self::from_tree(files, base_path, default_name)
469    }
470
471    /// Recursively load all files from a directory into memory
472    fn load_directory_recursive(
473        current_dir: &Path,
474        base_dir: &Path,
475        files: &mut HashMap<PathBuf, FileEntry>,
476        ignore: &QuillIgnore,
477    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
478        use std::fs;
479
480        if !current_dir.exists() {
481            return Ok(());
482        }
483
484        for entry in fs::read_dir(current_dir)? {
485            let entry = entry?;
486            let path = entry.path();
487            let relative_path = path
488                .strip_prefix(base_dir)
489                .map_err(|e| format!("Failed to get relative path: {}", e))?
490                .to_path_buf();
491
492            // Check if this path should be ignored
493            if ignore.is_ignored(&relative_path) {
494                continue;
495            }
496
497            if path.is_file() {
498                let contents = fs::read(&path)
499                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
500
501                files.insert(
502                    relative_path.clone(),
503                    FileEntry {
504                        contents,
505                        path: relative_path,
506                        is_dir: false,
507                    },
508                );
509            } else if path.is_dir() {
510                // Add directory entry
511                files.insert(
512                    relative_path.clone(),
513                    FileEntry {
514                        contents: Vec::new(),
515                        path: relative_path,
516                        is_dir: true,
517                    },
518                );
519
520                // Recursively process subdirectory
521                Self::load_directory_recursive(&path, base_dir, files, ignore)?;
522            }
523        }
524
525        Ok(())
526    }
527
528    /// Convert TOML value to YAML value
529    pub fn toml_to_yaml_value(
530        toml_val: &toml::Value,
531    ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
532        let json_val = serde_json::to_value(toml_val)?;
533        let yaml_val = serde_yaml::to_value(json_val)?;
534        Ok(yaml_val)
535    }
536
537    /// Get the path to the assets directory
538    pub fn assets_path(&self) -> PathBuf {
539        self.base_path.join("assets")
540    }
541
542    /// Get the path to the packages directory
543    pub fn packages_path(&self) -> PathBuf {
544        self.base_path.join("packages")
545    }
546
547    /// Get the path to the glue file
548    pub fn glue_path(&self) -> PathBuf {
549        self.base_path.join(&self.glue_file)
550    }
551
552    /// Get the list of typst packages to download, if specified in Quill.toml
553    pub fn typst_packages(&self) -> Vec<String> {
554        self.metadata
555            .get("typst_packages")
556            .and_then(|v| v.as_sequence())
557            .map(|seq| {
558                seq.iter()
559                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
560                    .collect()
561            })
562            .unwrap_or_default()
563    }
564
565    /// Validate the quill structure
566    pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
567        // Check that glue file exists in memory
568        let glue_path = PathBuf::from(&self.glue_file);
569        if !self.files.contains_key(&glue_path) {
570            return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
571        }
572        Ok(())
573    }
574
575    /// Get file contents by path (relative to quill root)
576    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
577        let path = path.as_ref();
578        self.files.get(path).map(|entry| entry.contents.as_slice())
579    }
580
581    /// Get file entry by path (includes metadata)
582    pub fn get_file_entry<P: AsRef<Path>>(&self, path: P) -> Option<&FileEntry> {
583        let path = path.as_ref();
584        self.files.get(path)
585    }
586
587    /// Check if a file exists in memory
588    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
589        let path = path.as_ref();
590        self.files.contains_key(path)
591    }
592
593    /// List all files in a directory (returns paths relative to quill root)
594    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
595        let dir_path = dir_path.as_ref();
596        let mut entries = Vec::new();
597
598        for (path, entry) in &self.files {
599            if let Some(parent) = path.parent() {
600                if parent == dir_path && !entry.is_dir {
601                    entries.push(path.clone());
602                }
603            } else if dir_path == Path::new("") && !entry.is_dir {
604                // Files in root directory
605                entries.push(path.clone());
606            }
607        }
608
609        entries.sort();
610        entries
611    }
612
613    /// List all directories in a directory (returns paths relative to quill root)
614    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
615        let dir_path = dir_path.as_ref();
616        let mut entries = Vec::new();
617
618        for (path, entry) in &self.files {
619            if entry.is_dir {
620                if let Some(parent) = path.parent() {
621                    if parent == dir_path {
622                        entries.push(path.clone());
623                    }
624                } else if dir_path == Path::new("") {
625                    // Directories in root
626                    entries.push(path.clone());
627                }
628            }
629        }
630
631        entries.sort();
632        entries
633    }
634
635    /// Get all files matching a pattern (supports simple wildcards)
636    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
637        let pattern_str = pattern.as_ref().to_string_lossy();
638        let mut matches = Vec::new();
639
640        for (path, entry) in &self.files {
641            if !entry.is_dir {
642                let path_str = path.to_string_lossy();
643                if self.matches_simple_pattern(&pattern_str, &path_str) {
644                    matches.push(path.clone());
645                }
646            }
647        }
648
649        matches.sort();
650        matches
651    }
652
653    /// Simple pattern matching helper
654    fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
655        if pattern == "*" {
656            return true;
657        }
658
659        if !pattern.contains('*') {
660            return path == pattern;
661        }
662
663        // Handle directory/* patterns
664        if pattern.ends_with("/*") {
665            let dir_pattern = &pattern[..pattern.len() - 2];
666            return path.starts_with(&format!("{}/", dir_pattern));
667        }
668
669        let parts: Vec<&str> = pattern.split('*').collect();
670        if parts.len() == 2 {
671            let (prefix, suffix) = (parts[0], parts[1]);
672            if prefix.is_empty() {
673                return path.ends_with(suffix);
674            } else if suffix.is_empty() {
675                return path.starts_with(prefix);
676            } else {
677                return path.starts_with(prefix) && path.ends_with(suffix);
678            }
679        }
680
681        false
682    }
683}
684#[cfg(test)]
685mod quill_tests {
686    use super::*;
687    use std::fs;
688    use tempfile::TempDir;
689
690    #[test]
691    fn test_quillignore_parsing() {
692        let ignore_content = r#"
693# This is a comment
694*.tmp
695target/
696node_modules/
697.git/
698"#;
699        let ignore = QuillIgnore::from_content(ignore_content);
700        assert_eq!(ignore.patterns.len(), 4);
701        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
702        assert!(ignore.patterns.contains(&"target/".to_string()));
703    }
704
705    #[test]
706    fn test_quillignore_matching() {
707        let ignore = QuillIgnore::new(vec![
708            "*.tmp".to_string(),
709            "target/".to_string(),
710            "node_modules/".to_string(),
711            ".git/".to_string(),
712        ]);
713
714        // Test file patterns
715        assert!(ignore.is_ignored("test.tmp"));
716        assert!(ignore.is_ignored("path/to/file.tmp"));
717        assert!(!ignore.is_ignored("test.txt"));
718
719        // Test directory patterns
720        assert!(ignore.is_ignored("target"));
721        assert!(ignore.is_ignored("target/debug"));
722        assert!(ignore.is_ignored("target/debug/deps"));
723        assert!(!ignore.is_ignored("src/target.rs"));
724
725        assert!(ignore.is_ignored("node_modules"));
726        assert!(ignore.is_ignored("node_modules/package"));
727        assert!(!ignore.is_ignored("my_node_modules"));
728    }
729
730    #[test]
731    fn test_in_memory_file_system() {
732        let temp_dir = TempDir::new().unwrap();
733        let quill_dir = temp_dir.path();
734
735        // Create test files
736        fs::write(
737            quill_dir.join("Quill.toml"),
738            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
739        )
740        .unwrap();
741        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
742
743        let assets_dir = quill_dir.join("assets");
744        fs::create_dir_all(&assets_dir).unwrap();
745        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
746
747        let packages_dir = quill_dir.join("packages");
748        fs::create_dir_all(&packages_dir).unwrap();
749        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
750
751        // Load quill
752        let quill = Quill::from_path(quill_dir).unwrap();
753
754        // Test file access
755        assert!(quill.file_exists("glue.typ"));
756        assert!(quill.file_exists("assets/test.txt"));
757        assert!(quill.file_exists("packages/package.typ"));
758        assert!(!quill.file_exists("nonexistent.txt"));
759
760        // Test file content
761        let asset_content = quill.get_file("assets/test.txt").unwrap();
762        assert_eq!(asset_content, b"asset content");
763
764        // Test directory listing
765        let asset_files = quill.list_directory("assets");
766        assert_eq!(asset_files.len(), 1);
767        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
768    }
769
770    #[test]
771    fn test_quillignore_integration() {
772        let temp_dir = TempDir::new().unwrap();
773        let quill_dir = temp_dir.path();
774
775        // Create .quillignore
776        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
777
778        // Create test files
779        fs::write(
780            quill_dir.join("Quill.toml"),
781            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
782        )
783        .unwrap();
784        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
785        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
786
787        let target_dir = quill_dir.join("target");
788        fs::create_dir_all(&target_dir).unwrap();
789        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
790
791        // Load quill
792        let quill = Quill::from_path(quill_dir).unwrap();
793
794        // Test that ignored files are not loaded
795        assert!(quill.file_exists("glue.typ"));
796        assert!(!quill.file_exists("should_ignore.tmp"));
797        assert!(!quill.file_exists("target/debug.txt"));
798    }
799
800    #[test]
801    fn test_find_files_pattern() {
802        let temp_dir = TempDir::new().unwrap();
803        let quill_dir = temp_dir.path();
804
805        // Create test directory structure
806        fs::write(
807            quill_dir.join("Quill.toml"),
808            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
809        )
810        .unwrap();
811        fs::write(quill_dir.join("glue.typ"), "template").unwrap();
812
813        let assets_dir = quill_dir.join("assets");
814        fs::create_dir_all(&assets_dir).unwrap();
815        fs::write(assets_dir.join("image.png"), "png data").unwrap();
816        fs::write(assets_dir.join("data.json"), "json data").unwrap();
817
818        let fonts_dir = assets_dir.join("fonts");
819        fs::create_dir_all(&fonts_dir).unwrap();
820        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
821
822        // Load quill
823        let quill = Quill::from_path(quill_dir).unwrap();
824
825        // Test pattern matching
826        let all_assets = quill.find_files("assets/*");
827        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
828
829        let typ_files = quill.find_files("*.typ");
830        assert_eq!(typ_files.len(), 1);
831        assert!(typ_files.contains(&PathBuf::from("glue.typ")));
832    }
833
834    #[test]
835    fn test_new_standardized_toml_format() {
836        let temp_dir = TempDir::new().unwrap();
837        let quill_dir = temp_dir.path();
838
839        // Create test files using new standardized format
840        let toml_content = r#"[Quill]
841name = "my-custom-quill"
842backend = "typst"
843glue = "custom_glue.typ"
844description = "Test quill with new format"
845author = "Test Author"
846"#;
847        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
848        fs::write(
849            quill_dir.join("custom_glue.typ"),
850            "= Custom Template\n\nThis is a custom template.",
851        )
852        .unwrap();
853
854        // Load quill
855        let quill = Quill::from_path(quill_dir).unwrap();
856
857        // Test that name comes from TOML, not directory
858        assert_eq!(quill.name, "my-custom-quill");
859
860        // Test that glue file is set correctly
861        assert_eq!(quill.glue_file, "custom_glue.typ");
862
863        // Test that backend is in metadata
864        assert!(quill.metadata.contains_key("backend"));
865        if let Some(backend_val) = quill.metadata.get("backend") {
866            if let Some(backend_str) = backend_val.as_str() {
867                assert_eq!(backend_str, "typst");
868            } else {
869                panic!("Backend value is not a string");
870            }
871        }
872
873        // Test that other fields are in metadata (but not version)
874        assert!(quill.metadata.contains_key("description"));
875        assert!(quill.metadata.contains_key("author"));
876        assert!(!quill.metadata.contains_key("version")); // version should be excluded
877
878        // Test that glue template content is loaded correctly
879        assert!(quill.glue_template.contains("Custom Template"));
880        assert!(quill.glue_template.contains("custom template"));
881    }
882
883    #[test]
884    fn test_typst_packages_parsing() {
885        let temp_dir = TempDir::new().unwrap();
886        let quill_dir = temp_dir.path();
887
888        let toml_content = r#"
889[Quill]
890name = "test-quill"
891backend = "typst"
892glue = "glue.typ"
893
894[typst]
895packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
896"#;
897
898        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
899        fs::write(quill_dir.join("glue.typ"), "test").unwrap();
900
901        let quill = Quill::from_path(quill_dir).unwrap();
902        let packages = quill.typst_packages();
903
904        assert_eq!(packages.len(), 2);
905        assert_eq!(packages[0], "@preview/bubble:0.2.2");
906        assert_eq!(packages[1], "@preview/example:1.0.0");
907    }
908
909    #[test]
910    fn test_template_loading() {
911        let temp_dir = TempDir::new().unwrap();
912        let quill_dir = temp_dir.path();
913
914        // Create test files with template specified
915        let toml_content = r#"[Quill]
916name = "test-with-template"
917backend = "typst"
918glue = "glue.typ"
919template = "example.md"
920"#;
921        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
922        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
923        fs::write(
924            quill_dir.join("example.md"),
925            "---\ntitle: Test\n---\n\nThis is a test template.",
926        )
927        .unwrap();
928
929        // Load quill
930        let quill = Quill::from_path(quill_dir).unwrap();
931
932        // Test that template file name is set
933        assert_eq!(quill.template_file, Some("example.md".to_string()));
934
935        // Test that template content is loaded
936        assert!(quill.template.is_some());
937        let template = quill.template.unwrap();
938        assert!(template.contains("title: Test"));
939        assert!(template.contains("This is a test template"));
940
941        // Test that glue template is still loaded
942        assert_eq!(quill.glue_template, "glue content");
943    }
944
945    #[test]
946    fn test_template_optional() {
947        let temp_dir = TempDir::new().unwrap();
948        let quill_dir = temp_dir.path();
949
950        // Create test files without template specified
951        let toml_content = r#"[Quill]
952name = "test-without-template"
953backend = "typst"
954glue = "glue.typ"
955"#;
956        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
957        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
958
959        // Load quill
960        let quill = Quill::from_path(quill_dir).unwrap();
961
962        // Test that template fields are None
963        assert_eq!(quill.template_file, None);
964        assert_eq!(quill.template, None);
965
966        // Test that glue template is still loaded
967        assert_eq!(quill.glue_template, "glue content");
968    }
969
970    #[test]
971    fn test_from_tree() {
972        // Create a simple in-memory file tree
973        let mut files = HashMap::new();
974
975        // Add Quill.toml
976        let quill_toml = r#"[Quill]
977name = "test-from-tree"
978backend = "typst"
979glue = "glue.typ"
980description = "A test quill from tree"
981"#;
982        files.insert(
983            PathBuf::from("Quill.toml"),
984            FileEntry {
985                contents: quill_toml.as_bytes().to_vec(),
986                path: PathBuf::from("Quill.toml"),
987                is_dir: false,
988            },
989        );
990
991        // Add glue file
992        let glue_content = "= Test Template\n\nThis is a test.";
993        files.insert(
994            PathBuf::from("glue.typ"),
995            FileEntry {
996                contents: glue_content.as_bytes().to_vec(),
997                path: PathBuf::from("glue.typ"),
998                is_dir: false,
999            },
1000        );
1001
1002        // Create Quill from tree
1003        let quill = Quill::from_tree(files, Some(PathBuf::from("/test")), None).unwrap();
1004
1005        // Validate the quill
1006        assert_eq!(quill.name, "test-from-tree");
1007        assert_eq!(quill.glue_file, "glue.typ");
1008        assert_eq!(quill.glue_template, glue_content);
1009        assert_eq!(quill.base_path, PathBuf::from("/test"));
1010        assert!(quill.metadata.contains_key("backend"));
1011        assert!(quill.metadata.contains_key("description"));
1012    }
1013
1014    #[test]
1015    fn test_from_tree_with_template() {
1016        let mut files = HashMap::new();
1017
1018        // Add Quill.toml with template specified
1019        let quill_toml = r#"[Quill]
1020name = "test-tree-template"
1021backend = "typst"
1022glue = "glue.typ"
1023template = "template.md"
1024"#;
1025        files.insert(
1026            PathBuf::from("Quill.toml"),
1027            FileEntry {
1028                contents: quill_toml.as_bytes().to_vec(),
1029                path: PathBuf::from("Quill.toml"),
1030                is_dir: false,
1031            },
1032        );
1033
1034        // Add glue file
1035        files.insert(
1036            PathBuf::from("glue.typ"),
1037            FileEntry {
1038                contents: b"glue content".to_vec(),
1039                path: PathBuf::from("glue.typ"),
1040                is_dir: false,
1041            },
1042        );
1043
1044        // Add template file
1045        let template_content = "# {{ title }}\n\n{{ body }}";
1046        files.insert(
1047            PathBuf::from("template.md"),
1048            FileEntry {
1049                contents: template_content.as_bytes().to_vec(),
1050                path: PathBuf::from("template.md"),
1051                is_dir: false,
1052            },
1053        );
1054
1055        // Create Quill from tree
1056        let quill = Quill::from_tree(files, None, None).unwrap();
1057
1058        // Validate template is loaded
1059        assert_eq!(quill.template_file, Some("template.md".to_string()));
1060        assert_eq!(quill.template, Some(template_content.to_string()));
1061    }
1062
1063    #[test]
1064    fn test_from_json() {
1065        // Create JSON representation of a Quill
1066        let json_str = r#"{
1067            "name": "test-from-json",
1068            "base_path": "/test/path",
1069            "files": {
1070                "Quill.toml": {
1071                    "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n",
1072                    "is_dir": false
1073                },
1074                "glue.typ": {
1075                    "contents": "= Test Glue\n\nThis is test content.",
1076                    "is_dir": false
1077                }
1078            }
1079        }"#;
1080
1081        // Create Quill from JSON
1082        let quill = Quill::from_json(json_str).unwrap();
1083
1084        // Validate the quill
1085        assert_eq!(quill.name, "test-from-json");
1086        assert_eq!(quill.base_path, PathBuf::from("/test/path"));
1087        assert_eq!(quill.glue_file, "glue.typ");
1088        assert!(quill.glue_template.contains("Test Glue"));
1089        assert!(quill.metadata.contains_key("backend"));
1090    }
1091
1092    #[test]
1093    fn test_from_json_with_byte_array() {
1094        // Create JSON with byte array representation
1095        let json_str = r#"{
1096            "files": {
1097                "Quill.toml": {
1098                    "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],
1099                    "is_dir": false
1100                },
1101                "glue.typ": {
1102                    "contents": "test glue",
1103                    "is_dir": false
1104                }
1105            }
1106        }"#;
1107
1108        // Create Quill from JSON
1109        let quill = Quill::from_json(json_str).unwrap();
1110
1111        // Validate the quill was created
1112        assert_eq!(quill.name, "test");
1113        assert_eq!(quill.glue_file, "glue.typ");
1114    }
1115
1116    #[test]
1117    fn test_from_json_missing_files() {
1118        // JSON without files field should fail
1119        let json_str = r#"{
1120            "name": "test"
1121        }"#;
1122
1123        let result = Quill::from_json(json_str);
1124        assert!(result.is_err());
1125        assert!(result
1126            .unwrap_err()
1127            .to_string()
1128            .contains("Missing 'files' field"));
1129    }
1130}