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        // Read Quill.toml (capitalized)
206        let quill_toml_path = path.join("Quill.toml");
207        let quill_toml_content = fs::read_to_string(&quill_toml_path)
208            .map_err(|e| format!("Failed to read Quill.toml: {}", e))?;
209
210        let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
211            .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
212
213        let mut metadata = HashMap::new();
214        let mut glue_file = "glue.typ".to_string(); // default
215        let mut template_file: Option<String> = None;
216        let mut quill_name = name; // default to directory name
217
218        // Extract fields from [Quill] section
219        if let Some(quill_section) = quill_toml.get("Quill") {
220            // Extract required fields: name, backend, glue, template
221            if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
222                quill_name = name_val.to_string();
223            }
224
225            if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
226                match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
227                    Ok(yaml_value) => {
228                        metadata.insert("backend".to_string(), yaml_value);
229                    }
230                    Err(e) => {
231                        eprintln!("Warning: Failed to convert backend field: {}", e);
232                    }
233                }
234            }
235
236            if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
237                glue_file = glue_val.to_string();
238            }
239
240            if let Some(template_val) = quill_section.get("template").and_then(|v| v.as_str()) {
241                template_file = Some(template_val.to_string());
242            }
243
244            // Add other fields to metadata (excluding special fields and version)
245            if let toml::Value::Table(table) = quill_section {
246                for (key, value) in table {
247                    if key != "name"
248                        && key != "backend"
249                        && key != "glue"
250                        && key != "template"
251                        && key != "version"
252                    {
253                        match Self::toml_to_yaml_value(value) {
254                            Ok(yaml_value) => {
255                                metadata.insert(key.clone(), yaml_value);
256                            }
257                            Err(e) => {
258                                eprintln!("Warning: Failed to convert field '{}': {}", key, e);
259                            }
260                        }
261                    }
262                }
263            }
264        }
265
266        // Extract fields from [typst] section
267        if let Some(typst_section) = quill_toml.get("typst") {
268            if let toml::Value::Table(table) = typst_section {
269                for (key, value) in table {
270                    match Self::toml_to_yaml_value(value) {
271                        Ok(yaml_value) => {
272                            metadata.insert(format!("typst_{}", key), yaml_value);
273                        }
274                        Err(e) => {
275                            eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
276                        }
277                    }
278                }
279            }
280        }
281
282        // Read the template content from glue file
283        let glue_path = path.join(&glue_file);
284        let template_content = fs::read_to_string(&glue_path)
285            .map_err(|e| format!("Failed to read glue file '{}': {}", glue_file, e))?;
286
287        // Read the markdown template content if specified
288        let template_content_opt = if let Some(ref template_file_name) = template_file {
289            let template_path = path.join(template_file_name);
290            match fs::read_to_string(&template_path) {
291                Ok(content) => Some(content),
292                Err(e) => {
293                    eprintln!(
294                        "Warning: Failed to read template file '{}': {}",
295                        template_file_name, e
296                    );
297                    None
298                }
299            }
300        } else {
301            None
302        };
303
304        // Load .quillignore if it exists
305        let quillignore_path = path.join(".quillignore");
306        let ignore = if quillignore_path.exists() {
307            let ignore_content = fs::read_to_string(&quillignore_path)
308                .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
309            QuillIgnore::from_content(&ignore_content)
310        } else {
311            // Default ignore patterns
312            QuillIgnore::new(vec![
313                ".git/".to_string(),
314                ".gitignore".to_string(),
315                ".quillignore".to_string(),
316                "target/".to_string(),
317                "node_modules/".to_string(),
318            ])
319        };
320
321        // Load all files into memory
322        let mut files = HashMap::new();
323        Self::load_directory_recursive(path, path, &mut files, &ignore)?;
324
325        let quill = Quill {
326            glue_template: template_content,
327            metadata,
328            base_path: path.to_path_buf(),
329            name: quill_name,
330            glue_file,
331            template_file,
332            template: template_content_opt,
333            files,
334        };
335
336        // Automatically validate the quill upon creation
337        quill.validate()?;
338
339        Ok(quill)
340    }
341
342    /// Recursively load all files from a directory into memory
343    fn load_directory_recursive(
344        current_dir: &Path,
345        base_dir: &Path,
346        files: &mut HashMap<PathBuf, FileEntry>,
347        ignore: &QuillIgnore,
348    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
349        use std::fs;
350
351        if !current_dir.exists() {
352            return Ok(());
353        }
354
355        for entry in fs::read_dir(current_dir)? {
356            let entry = entry?;
357            let path = entry.path();
358            let relative_path = path
359                .strip_prefix(base_dir)
360                .map_err(|e| format!("Failed to get relative path: {}", e))?
361                .to_path_buf();
362
363            // Check if this path should be ignored
364            if ignore.is_ignored(&relative_path) {
365                continue;
366            }
367
368            if path.is_file() {
369                let contents = fs::read(&path)
370                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
371
372                files.insert(
373                    relative_path.clone(),
374                    FileEntry {
375                        contents,
376                        path: relative_path,
377                        is_dir: false,
378                    },
379                );
380            } else if path.is_dir() {
381                // Add directory entry
382                files.insert(
383                    relative_path.clone(),
384                    FileEntry {
385                        contents: Vec::new(),
386                        path: relative_path,
387                        is_dir: true,
388                    },
389                );
390
391                // Recursively process subdirectory
392                Self::load_directory_recursive(&path, base_dir, files, ignore)?;
393            }
394        }
395
396        Ok(())
397    }
398
399    /// Convert TOML value to YAML value
400    pub fn toml_to_yaml_value(
401        toml_val: &toml::Value,
402    ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
403        let json_val = serde_json::to_value(toml_val)?;
404        let yaml_val = serde_yaml::to_value(json_val)?;
405        Ok(yaml_val)
406    }
407
408    /// Get the path to the assets directory
409    pub fn assets_path(&self) -> PathBuf {
410        self.base_path.join("assets")
411    }
412
413    /// Get the path to the packages directory
414    pub fn packages_path(&self) -> PathBuf {
415        self.base_path.join("packages")
416    }
417
418    /// Get the path to the glue file
419    pub fn glue_path(&self) -> PathBuf {
420        self.base_path.join(&self.glue_file)
421    }
422
423    /// Get the list of typst packages to download, if specified in Quill.toml
424    pub fn typst_packages(&self) -> Vec<String> {
425        self.metadata
426            .get("typst_packages")
427            .and_then(|v| v.as_sequence())
428            .map(|seq| {
429                seq.iter()
430                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
431                    .collect()
432            })
433            .unwrap_or_default()
434    }
435
436    /// Validate the quill structure
437    pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
438        // Check that glue file exists in memory
439        let glue_path = PathBuf::from(&self.glue_file);
440        if !self.files.contains_key(&glue_path) {
441            return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
442        }
443        Ok(())
444    }
445
446    /// Get file contents by path (relative to quill root)
447    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
448        let path = path.as_ref();
449        self.files.get(path).map(|entry| entry.contents.as_slice())
450    }
451
452    /// Get file entry by path (includes metadata)
453    pub fn get_file_entry<P: AsRef<Path>>(&self, path: P) -> Option<&FileEntry> {
454        let path = path.as_ref();
455        self.files.get(path)
456    }
457
458    /// Check if a file exists in memory
459    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
460        let path = path.as_ref();
461        self.files.contains_key(path)
462    }
463
464    /// List all files in a directory (returns paths relative to quill root)
465    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
466        let dir_path = dir_path.as_ref();
467        let mut entries = Vec::new();
468
469        for (path, entry) in &self.files {
470            if let Some(parent) = path.parent() {
471                if parent == dir_path && !entry.is_dir {
472                    entries.push(path.clone());
473                }
474            } else if dir_path == Path::new("") && !entry.is_dir {
475                // Files in root directory
476                entries.push(path.clone());
477            }
478        }
479
480        entries.sort();
481        entries
482    }
483
484    /// List all directories in a directory (returns paths relative to quill root)
485    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
486        let dir_path = dir_path.as_ref();
487        let mut entries = Vec::new();
488
489        for (path, entry) in &self.files {
490            if entry.is_dir {
491                if let Some(parent) = path.parent() {
492                    if parent == dir_path {
493                        entries.push(path.clone());
494                    }
495                } else if dir_path == Path::new("") {
496                    // Directories in root
497                    entries.push(path.clone());
498                }
499            }
500        }
501
502        entries.sort();
503        entries
504    }
505
506    /// Get all files matching a pattern (supports simple wildcards)
507    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
508        let pattern_str = pattern.as_ref().to_string_lossy();
509        let mut matches = Vec::new();
510
511        for (path, entry) in &self.files {
512            if !entry.is_dir {
513                let path_str = path.to_string_lossy();
514                if self.matches_simple_pattern(&pattern_str, &path_str) {
515                    matches.push(path.clone());
516                }
517            }
518        }
519
520        matches.sort();
521        matches
522    }
523
524    /// Simple pattern matching helper
525    fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
526        if pattern == "*" {
527            return true;
528        }
529
530        if !pattern.contains('*') {
531            return path == pattern;
532        }
533
534        // Handle directory/* patterns
535        if pattern.ends_with("/*") {
536            let dir_pattern = &pattern[..pattern.len() - 2];
537            return path.starts_with(&format!("{}/", dir_pattern));
538        }
539
540        let parts: Vec<&str> = pattern.split('*').collect();
541        if parts.len() == 2 {
542            let (prefix, suffix) = (parts[0], parts[1]);
543            if prefix.is_empty() {
544                return path.ends_with(suffix);
545            } else if suffix.is_empty() {
546                return path.starts_with(prefix);
547            } else {
548                return path.starts_with(prefix) && path.ends_with(suffix);
549            }
550        }
551
552        false
553    }
554}
555#[cfg(test)]
556mod quill_tests {
557    use super::*;
558    use std::fs;
559    use tempfile::TempDir;
560
561    #[test]
562    fn test_quillignore_parsing() {
563        let ignore_content = r#"
564# This is a comment
565*.tmp
566target/
567node_modules/
568.git/
569"#;
570        let ignore = QuillIgnore::from_content(ignore_content);
571        assert_eq!(ignore.patterns.len(), 4);
572        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
573        assert!(ignore.patterns.contains(&"target/".to_string()));
574    }
575
576    #[test]
577    fn test_quillignore_matching() {
578        let ignore = QuillIgnore::new(vec![
579            "*.tmp".to_string(),
580            "target/".to_string(),
581            "node_modules/".to_string(),
582            ".git/".to_string(),
583        ]);
584
585        // Test file patterns
586        assert!(ignore.is_ignored("test.tmp"));
587        assert!(ignore.is_ignored("path/to/file.tmp"));
588        assert!(!ignore.is_ignored("test.txt"));
589
590        // Test directory patterns
591        assert!(ignore.is_ignored("target"));
592        assert!(ignore.is_ignored("target/debug"));
593        assert!(ignore.is_ignored("target/debug/deps"));
594        assert!(!ignore.is_ignored("src/target.rs"));
595
596        assert!(ignore.is_ignored("node_modules"));
597        assert!(ignore.is_ignored("node_modules/package"));
598        assert!(!ignore.is_ignored("my_node_modules"));
599    }
600
601    #[test]
602    fn test_in_memory_file_system() {
603        let temp_dir = TempDir::new().unwrap();
604        let quill_dir = temp_dir.path();
605
606        // Create test files
607        fs::write(
608            quill_dir.join("Quill.toml"),
609            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
610        )
611        .unwrap();
612        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
613
614        let assets_dir = quill_dir.join("assets");
615        fs::create_dir_all(&assets_dir).unwrap();
616        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
617
618        let packages_dir = quill_dir.join("packages");
619        fs::create_dir_all(&packages_dir).unwrap();
620        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
621
622        // Load quill
623        let quill = Quill::from_path(quill_dir).unwrap();
624
625        // Test file access
626        assert!(quill.file_exists("glue.typ"));
627        assert!(quill.file_exists("assets/test.txt"));
628        assert!(quill.file_exists("packages/package.typ"));
629        assert!(!quill.file_exists("nonexistent.txt"));
630
631        // Test file content
632        let asset_content = quill.get_file("assets/test.txt").unwrap();
633        assert_eq!(asset_content, b"asset content");
634
635        // Test directory listing
636        let asset_files = quill.list_directory("assets");
637        assert_eq!(asset_files.len(), 1);
638        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
639    }
640
641    #[test]
642    fn test_quillignore_integration() {
643        let temp_dir = TempDir::new().unwrap();
644        let quill_dir = temp_dir.path();
645
646        // Create .quillignore
647        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
648
649        // Create test files
650        fs::write(
651            quill_dir.join("Quill.toml"),
652            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
653        )
654        .unwrap();
655        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
656        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
657
658        let target_dir = quill_dir.join("target");
659        fs::create_dir_all(&target_dir).unwrap();
660        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
661
662        // Load quill
663        let quill = Quill::from_path(quill_dir).unwrap();
664
665        // Test that ignored files are not loaded
666        assert!(quill.file_exists("glue.typ"));
667        assert!(!quill.file_exists("should_ignore.tmp"));
668        assert!(!quill.file_exists("target/debug.txt"));
669    }
670
671    #[test]
672    fn test_find_files_pattern() {
673        let temp_dir = TempDir::new().unwrap();
674        let quill_dir = temp_dir.path();
675
676        // Create test directory structure
677        fs::write(
678            quill_dir.join("Quill.toml"),
679            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
680        )
681        .unwrap();
682        fs::write(quill_dir.join("glue.typ"), "template").unwrap();
683
684        let assets_dir = quill_dir.join("assets");
685        fs::create_dir_all(&assets_dir).unwrap();
686        fs::write(assets_dir.join("image.png"), "png data").unwrap();
687        fs::write(assets_dir.join("data.json"), "json data").unwrap();
688
689        let fonts_dir = assets_dir.join("fonts");
690        fs::create_dir_all(&fonts_dir).unwrap();
691        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
692
693        // Load quill
694        let quill = Quill::from_path(quill_dir).unwrap();
695
696        // Test pattern matching
697        let all_assets = quill.find_files("assets/*");
698        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
699
700        let typ_files = quill.find_files("*.typ");
701        assert_eq!(typ_files.len(), 1);
702        assert!(typ_files.contains(&PathBuf::from("glue.typ")));
703    }
704
705    #[test]
706    fn test_new_standardized_toml_format() {
707        let temp_dir = TempDir::new().unwrap();
708        let quill_dir = temp_dir.path();
709
710        // Create test files using new standardized format
711        let toml_content = r#"[Quill]
712name = "my-custom-quill"
713backend = "typst"
714glue = "custom_glue.typ"
715description = "Test quill with new format"
716author = "Test Author"
717"#;
718        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
719        fs::write(
720            quill_dir.join("custom_glue.typ"),
721            "= Custom Template\n\nThis is a custom template.",
722        )
723        .unwrap();
724
725        // Load quill
726        let quill = Quill::from_path(quill_dir).unwrap();
727
728        // Test that name comes from TOML, not directory
729        assert_eq!(quill.name, "my-custom-quill");
730
731        // Test that glue file is set correctly
732        assert_eq!(quill.glue_file, "custom_glue.typ");
733
734        // Test that backend is in metadata
735        assert!(quill.metadata.contains_key("backend"));
736        if let Some(backend_val) = quill.metadata.get("backend") {
737            if let Some(backend_str) = backend_val.as_str() {
738                assert_eq!(backend_str, "typst");
739            } else {
740                panic!("Backend value is not a string");
741            }
742        }
743
744        // Test that other fields are in metadata (but not version)
745        assert!(quill.metadata.contains_key("description"));
746        assert!(quill.metadata.contains_key("author"));
747        assert!(!quill.metadata.contains_key("version")); // version should be excluded
748
749        // Test that glue template content is loaded correctly
750        assert!(quill.glue_template.contains("Custom Template"));
751        assert!(quill.glue_template.contains("custom template"));
752    }
753
754    #[test]
755    fn test_typst_packages_parsing() {
756        let temp_dir = TempDir::new().unwrap();
757        let quill_dir = temp_dir.path();
758
759        let toml_content = r#"
760[Quill]
761name = "test-quill"
762backend = "typst"
763glue = "glue.typ"
764
765[typst]
766packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
767"#;
768
769        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
770        fs::write(quill_dir.join("glue.typ"), "test").unwrap();
771
772        let quill = Quill::from_path(quill_dir).unwrap();
773        let packages = quill.typst_packages();
774
775        assert_eq!(packages.len(), 2);
776        assert_eq!(packages[0], "@preview/bubble:0.2.2");
777        assert_eq!(packages[1], "@preview/example:1.0.0");
778    }
779
780    #[test]
781    fn test_template_loading() {
782        let temp_dir = TempDir::new().unwrap();
783        let quill_dir = temp_dir.path();
784
785        // Create test files with template specified
786        let toml_content = r#"[Quill]
787name = "test-with-template"
788backend = "typst"
789glue = "glue.typ"
790template = "example.md"
791"#;
792        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
793        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
794        fs::write(
795            quill_dir.join("example.md"),
796            "---\ntitle: Test\n---\n\nThis is a test template.",
797        )
798        .unwrap();
799
800        // Load quill
801        let quill = Quill::from_path(quill_dir).unwrap();
802
803        // Test that template file name is set
804        assert_eq!(quill.template_file, Some("example.md".to_string()));
805
806        // Test that template content is loaded
807        assert!(quill.template.is_some());
808        let template = quill.template.unwrap();
809        assert!(template.contains("title: Test"));
810        assert!(template.contains("This is a test template"));
811
812        // Test that glue template is still loaded
813        assert_eq!(quill.glue_template, "glue content");
814    }
815
816    #[test]
817    fn test_template_optional() {
818        let temp_dir = TempDir::new().unwrap();
819        let quill_dir = temp_dir.path();
820
821        // Create test files without template specified
822        let toml_content = r#"[Quill]
823name = "test-without-template"
824backend = "typst"
825glue = "glue.typ"
826"#;
827        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
828        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
829
830        // Load quill
831        let quill = Quill::from_path(quill_dir).unwrap();
832
833        // Test that template fields are None
834        assert_eq!(quill.template_file, None);
835        assert_eq!(quill.template, None);
836
837        // Test that glue template is still loaded
838        assert_eq!(quill.glue_template, "glue content");
839    }
840}