quillmark_core/
lib.rs

1use std::collections::HashMap;
2use std::error::Error as StdError;
3use std::path::{Path, PathBuf};
4
5// Re-export parsing functionality
6pub mod parse;
7pub use parse::{decompose, ParsedDocument, BODY_FIELD};
8
9// Re-export templating functionality
10pub mod templating;
11pub use templating::{Glue, TemplateError};
12
13// Re-export backend trait
14pub mod backend;
15pub use backend::Backend;
16
17// Re-export error types
18pub mod error;
19pub use error::{Diagnostic, Location, RenderError, RenderResult, Severity};
20
21/// Output formats supported by backends
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
23pub enum OutputFormat {
24    Txt,
25    Svg,
26    Pdf,
27}
28
29/// An artifact produced by rendering
30#[derive(Debug)]
31pub struct Artifact {
32    pub bytes: Vec<u8>,
33    pub output_format: OutputFormat,
34}
35
36/// Internal rendering options used by engine orchestration
37#[derive(Debug)]
38pub struct RenderOptions {
39    pub output_format: Option<OutputFormat>,
40}
41
42/// A file entry in the in-memory file system
43#[derive(Debug, Clone)]
44pub struct FileEntry {
45    /// The file contents as bytes
46    pub contents: Vec<u8>,
47    /// The file path relative to the quill root
48    pub path: PathBuf,
49    /// Whether this is a directory entry
50    pub is_dir: bool,
51}
52
53/// Simple gitignore-style pattern matcher for .quillignore
54#[derive(Debug, Clone)]
55pub struct QuillIgnore {
56    patterns: Vec<String>,
57}
58
59impl QuillIgnore {
60    /// Create a new QuillIgnore from pattern strings
61    pub fn new(patterns: Vec<String>) -> Self {
62        Self { patterns }
63    }
64
65    /// Parse .quillignore content into patterns
66    pub fn from_content(content: &str) -> Self {
67        let patterns = content
68            .lines()
69            .map(|line| line.trim())
70            .filter(|line| !line.is_empty() && !line.starts_with('#'))
71            .map(|line| line.to_string())
72            .collect();
73        Self::new(patterns)
74    }
75
76    /// Check if a path should be ignored
77    pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
78        let path = path.as_ref();
79        let path_str = path.to_string_lossy();
80
81        for pattern in &self.patterns {
82            if self.matches_pattern(pattern, &path_str) {
83                return true;
84            }
85        }
86        false
87    }
88
89    /// Simple pattern matching (supports * wildcard and directory patterns)
90    fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
91        // Handle directory patterns
92        if pattern.ends_with('/') {
93            let pattern_prefix = &pattern[..pattern.len() - 1];
94            return path.starts_with(pattern_prefix)
95                && (path.len() == pattern_prefix.len()
96                    || path.chars().nth(pattern_prefix.len()) == Some('/'));
97        }
98
99        // Handle exact matches
100        if !pattern.contains('*') {
101            return path == pattern || path.ends_with(&format!("/{}", pattern));
102        }
103
104        // Simple wildcard matching
105        if pattern == "*" {
106            return true;
107        }
108
109        // Handle patterns with wildcards
110        let pattern_parts: Vec<&str> = pattern.split('*').collect();
111        if pattern_parts.len() == 2 {
112            let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
113            if prefix.is_empty() {
114                return path.ends_with(suffix);
115            } else if suffix.is_empty() {
116                return path.starts_with(prefix);
117            } else {
118                return path.starts_with(prefix) && path.ends_with(suffix);
119            }
120        }
121
122        false
123    }
124}
125
126/// A quill template containing the template content and metadata with file management capabilities
127#[derive(Debug, Clone)]
128pub struct Quill {
129    /// The template content
130    pub glue_template: String,
131    /// Quill-specific data that backends might need
132    pub metadata: HashMap<String, serde_yaml::Value>,
133    /// Base path for resolving relative paths
134    pub base_path: PathBuf,
135    /// Name of the quill (derived from directory name)
136    pub name: String,
137    /// Glue template file name
138    pub glue_file: String,
139    /// In-memory file system - all files loaded during initialization
140    pub files: HashMap<PathBuf, FileEntry>,
141}
142
143impl Quill {
144    /// Create a Quill from a directory path
145    pub fn from_path<P: AsRef<std::path::Path>>(
146        path: P,
147    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
148        use std::fs;
149
150        let path = path.as_ref();
151        let name = path
152            .file_name()
153            .and_then(|n| n.to_str())
154            .unwrap_or("unnamed")
155            .to_string();
156
157        // Read Quill.toml (capitalized)
158        let quill_toml_path = path.join("Quill.toml");
159        let quill_toml_content = fs::read_to_string(&quill_toml_path)
160            .map_err(|e| format!("Failed to read Quill.toml: {}", e))?;
161
162        let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
163            .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
164
165        let mut metadata = HashMap::new();
166        let mut glue_file = "glue.typ".to_string(); // default
167        let mut quill_name = name; // default to directory name
168
169        // Extract fields from [Quill] section
170        if let Some(quill_section) = quill_toml.get("Quill") {
171            // Extract required fields: name, backend, glue
172            if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
173                quill_name = name_val.to_string();
174            }
175
176            if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
177                match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
178                    Ok(yaml_value) => {
179                        metadata.insert("backend".to_string(), yaml_value);
180                    }
181                    Err(e) => {
182                        eprintln!("Warning: Failed to convert backend field: {}", e);
183                    }
184                }
185            }
186
187            if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
188                glue_file = glue_val.to_string();
189            }
190
191            // Add other fields to metadata (excluding special fields and version)
192            if let toml::Value::Table(table) = quill_section {
193                for (key, value) in table {
194                    if key != "name" && key != "backend" && key != "glue" && key != "version" {
195                        match Self::toml_to_yaml_value(value) {
196                            Ok(yaml_value) => {
197                                metadata.insert(key.clone(), yaml_value);
198                            }
199                            Err(e) => {
200                                eprintln!("Warning: Failed to convert field '{}': {}", key, e);
201                            }
202                        }
203                    }
204                }
205            }
206        }
207
208        // Extract fields from [typst] section
209        if let Some(typst_section) = quill_toml.get("typst") {
210            if let toml::Value::Table(table) = typst_section {
211                for (key, value) in table {
212                    match Self::toml_to_yaml_value(value) {
213                        Ok(yaml_value) => {
214                            metadata.insert(format!("typst_{}", key), yaml_value);
215                        }
216                        Err(e) => {
217                            eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
218                        }
219                    }
220                }
221            }
222        }
223
224        // Read the template content from glue file
225        let glue_path = path.join(&glue_file);
226        let template_content = fs::read_to_string(&glue_path)
227            .map_err(|e| format!("Failed to read glue file '{}': {}", glue_file, e))?;
228
229        // Load .quillignore if it exists
230        let quillignore_path = path.join(".quillignore");
231        let ignore = if quillignore_path.exists() {
232            let ignore_content = fs::read_to_string(&quillignore_path)
233                .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
234            QuillIgnore::from_content(&ignore_content)
235        } else {
236            // Default ignore patterns
237            QuillIgnore::new(vec![
238                ".git/".to_string(),
239                ".gitignore".to_string(),
240                ".quillignore".to_string(),
241                "target/".to_string(),
242                "node_modules/".to_string(),
243            ])
244        };
245
246        // Load all files into memory
247        let mut files = HashMap::new();
248        Self::load_directory_recursive(path, path, &mut files, &ignore)?;
249
250        let quill = Quill {
251            glue_template: template_content,
252            metadata,
253            base_path: path.to_path_buf(),
254            name: quill_name,
255            glue_file,
256            files,
257        };
258
259        // Automatically validate the quill upon creation
260        quill.validate()?;
261
262        Ok(quill)
263    }
264
265    /// Recursively load all files from a directory into memory
266    fn load_directory_recursive(
267        current_dir: &Path,
268        base_dir: &Path,
269        files: &mut HashMap<PathBuf, FileEntry>,
270        ignore: &QuillIgnore,
271    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
272        use std::fs;
273
274        if !current_dir.exists() {
275            return Ok(());
276        }
277
278        for entry in fs::read_dir(current_dir)? {
279            let entry = entry?;
280            let path = entry.path();
281            let relative_path = path
282                .strip_prefix(base_dir)
283                .map_err(|e| format!("Failed to get relative path: {}", e))?
284                .to_path_buf();
285
286            // Check if this path should be ignored
287            if ignore.is_ignored(&relative_path) {
288                continue;
289            }
290
291            if path.is_file() {
292                let contents = fs::read(&path)
293                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
294
295                files.insert(
296                    relative_path.clone(),
297                    FileEntry {
298                        contents,
299                        path: relative_path,
300                        is_dir: false,
301                    },
302                );
303            } else if path.is_dir() {
304                // Add directory entry
305                files.insert(
306                    relative_path.clone(),
307                    FileEntry {
308                        contents: Vec::new(),
309                        path: relative_path,
310                        is_dir: true,
311                    },
312                );
313
314                // Recursively process subdirectory
315                Self::load_directory_recursive(&path, base_dir, files, ignore)?;
316            }
317        }
318
319        Ok(())
320    }
321
322    /// Convert TOML value to YAML value
323    pub fn toml_to_yaml_value(
324        toml_val: &toml::Value,
325    ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
326        let json_val = serde_json::to_value(toml_val)?;
327        let yaml_val = serde_yaml::to_value(json_val)?;
328        Ok(yaml_val)
329    }
330
331    /// Get the path to the assets directory
332    pub fn assets_path(&self) -> PathBuf {
333        self.base_path.join("assets")
334    }
335
336    /// Get the path to the packages directory
337    pub fn packages_path(&self) -> PathBuf {
338        self.base_path.join("packages")
339    }
340
341    /// Get the path to the glue file
342    pub fn glue_path(&self) -> PathBuf {
343        self.base_path.join(&self.glue_file)
344    }
345
346    /// Get the list of typst packages to download, if specified in Quill.toml
347    pub fn typst_packages(&self) -> Vec<String> {
348        self.metadata
349            .get("typst_packages")
350            .and_then(|v| v.as_sequence())
351            .map(|seq| {
352                seq.iter()
353                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
354                    .collect()
355            })
356            .unwrap_or_default()
357    }
358
359    /// Validate the quill structure
360    pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
361        // Check that glue file exists in memory
362        let glue_path = PathBuf::from(&self.glue_file);
363        if !self.files.contains_key(&glue_path) {
364            return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
365        }
366        Ok(())
367    }
368
369    /// Get file contents by path (relative to quill root)
370    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
371        let path = path.as_ref();
372        self.files.get(path).map(|entry| entry.contents.as_slice())
373    }
374
375    /// Get file entry by path (includes metadata)
376    pub fn get_file_entry<P: AsRef<Path>>(&self, path: P) -> Option<&FileEntry> {
377        let path = path.as_ref();
378        self.files.get(path)
379    }
380
381    /// Check if a file exists in memory
382    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
383        let path = path.as_ref();
384        self.files.contains_key(path)
385    }
386
387    /// List all files in a directory (returns paths relative to quill root)
388    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
389        let dir_path = dir_path.as_ref();
390        let mut entries = Vec::new();
391
392        for (path, entry) in &self.files {
393            if let Some(parent) = path.parent() {
394                if parent == dir_path && !entry.is_dir {
395                    entries.push(path.clone());
396                }
397            } else if dir_path == Path::new("") && !entry.is_dir {
398                // Files in root directory
399                entries.push(path.clone());
400            }
401        }
402
403        entries.sort();
404        entries
405    }
406
407    /// List all directories in a directory (returns paths relative to quill root)
408    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
409        let dir_path = dir_path.as_ref();
410        let mut entries = Vec::new();
411
412        for (path, entry) in &self.files {
413            if entry.is_dir {
414                if let Some(parent) = path.parent() {
415                    if parent == dir_path {
416                        entries.push(path.clone());
417                    }
418                } else if dir_path == Path::new("") {
419                    // Directories in root
420                    entries.push(path.clone());
421                }
422            }
423        }
424
425        entries.sort();
426        entries
427    }
428
429    /// Get all files matching a pattern (supports simple wildcards)
430    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
431        let pattern_str = pattern.as_ref().to_string_lossy();
432        let mut matches = Vec::new();
433
434        for (path, entry) in &self.files {
435            if !entry.is_dir {
436                let path_str = path.to_string_lossy();
437                if self.matches_simple_pattern(&pattern_str, &path_str) {
438                    matches.push(path.clone());
439                }
440            }
441        }
442
443        matches.sort();
444        matches
445    }
446
447    /// Simple pattern matching helper
448    fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
449        if pattern == "*" {
450            return true;
451        }
452
453        if !pattern.contains('*') {
454            return path == pattern;
455        }
456
457        // Handle directory/* patterns
458        if pattern.ends_with("/*") {
459            let dir_pattern = &pattern[..pattern.len() - 2];
460            return path.starts_with(&format!("{}/", dir_pattern));
461        }
462
463        let parts: Vec<&str> = pattern.split('*').collect();
464        if parts.len() == 2 {
465            let (prefix, suffix) = (parts[0], parts[1]);
466            if prefix.is_empty() {
467                return path.ends_with(suffix);
468            } else if suffix.is_empty() {
469                return path.starts_with(prefix);
470            } else {
471                return path.starts_with(prefix) && path.ends_with(suffix);
472            }
473        }
474
475        false
476    }
477}
478#[cfg(test)]
479mod quill_tests {
480    use super::*;
481    use std::fs;
482    use tempfile::TempDir;
483
484    #[test]
485    fn test_quillignore_parsing() {
486        let ignore_content = r#"
487# This is a comment
488*.tmp
489target/
490node_modules/
491.git/
492"#;
493        let ignore = QuillIgnore::from_content(ignore_content);
494        assert_eq!(ignore.patterns.len(), 4);
495        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
496        assert!(ignore.patterns.contains(&"target/".to_string()));
497    }
498
499    #[test]
500    fn test_quillignore_matching() {
501        let ignore = QuillIgnore::new(vec![
502            "*.tmp".to_string(),
503            "target/".to_string(),
504            "node_modules/".to_string(),
505            ".git/".to_string(),
506        ]);
507
508        // Test file patterns
509        assert!(ignore.is_ignored("test.tmp"));
510        assert!(ignore.is_ignored("path/to/file.tmp"));
511        assert!(!ignore.is_ignored("test.txt"));
512
513        // Test directory patterns
514        assert!(ignore.is_ignored("target"));
515        assert!(ignore.is_ignored("target/debug"));
516        assert!(ignore.is_ignored("target/debug/deps"));
517        assert!(!ignore.is_ignored("src/target.rs"));
518
519        assert!(ignore.is_ignored("node_modules"));
520        assert!(ignore.is_ignored("node_modules/package"));
521        assert!(!ignore.is_ignored("my_node_modules"));
522    }
523
524    #[test]
525    fn test_in_memory_file_system() {
526        let temp_dir = TempDir::new().unwrap();
527        let quill_dir = temp_dir.path();
528
529        // Create test files
530        fs::write(
531            quill_dir.join("Quill.toml"),
532            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
533        )
534        .unwrap();
535        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
536
537        let assets_dir = quill_dir.join("assets");
538        fs::create_dir_all(&assets_dir).unwrap();
539        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
540
541        let packages_dir = quill_dir.join("packages");
542        fs::create_dir_all(&packages_dir).unwrap();
543        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
544
545        // Load quill
546        let quill = Quill::from_path(quill_dir).unwrap();
547
548        // Test file access
549        assert!(quill.file_exists("glue.typ"));
550        assert!(quill.file_exists("assets/test.txt"));
551        assert!(quill.file_exists("packages/package.typ"));
552        assert!(!quill.file_exists("nonexistent.txt"));
553
554        // Test file content
555        let asset_content = quill.get_file("assets/test.txt").unwrap();
556        assert_eq!(asset_content, b"asset content");
557
558        // Test directory listing
559        let asset_files = quill.list_directory("assets");
560        assert_eq!(asset_files.len(), 1);
561        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
562    }
563
564    #[test]
565    fn test_quillignore_integration() {
566        let temp_dir = TempDir::new().unwrap();
567        let quill_dir = temp_dir.path();
568
569        // Create .quillignore
570        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
571
572        // Create test files
573        fs::write(
574            quill_dir.join("Quill.toml"),
575            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
576        )
577        .unwrap();
578        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
579        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
580
581        let target_dir = quill_dir.join("target");
582        fs::create_dir_all(&target_dir).unwrap();
583        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
584
585        // Load quill
586        let quill = Quill::from_path(quill_dir).unwrap();
587
588        // Test that ignored files are not loaded
589        assert!(quill.file_exists("glue.typ"));
590        assert!(!quill.file_exists("should_ignore.tmp"));
591        assert!(!quill.file_exists("target/debug.txt"));
592    }
593
594    #[test]
595    fn test_find_files_pattern() {
596        let temp_dir = TempDir::new().unwrap();
597        let quill_dir = temp_dir.path();
598
599        // Create test directory structure
600        fs::write(
601            quill_dir.join("Quill.toml"),
602            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
603        )
604        .unwrap();
605        fs::write(quill_dir.join("glue.typ"), "template").unwrap();
606
607        let assets_dir = quill_dir.join("assets");
608        fs::create_dir_all(&assets_dir).unwrap();
609        fs::write(assets_dir.join("image.png"), "png data").unwrap();
610        fs::write(assets_dir.join("data.json"), "json data").unwrap();
611
612        let fonts_dir = assets_dir.join("fonts");
613        fs::create_dir_all(&fonts_dir).unwrap();
614        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
615
616        // Load quill
617        let quill = Quill::from_path(quill_dir).unwrap();
618
619        // Test pattern matching
620        let all_assets = quill.find_files("assets/*");
621        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
622
623        let typ_files = quill.find_files("*.typ");
624        assert_eq!(typ_files.len(), 1);
625        assert!(typ_files.contains(&PathBuf::from("glue.typ")));
626    }
627
628    #[test]
629    fn test_new_standardized_toml_format() {
630        let temp_dir = TempDir::new().unwrap();
631        let quill_dir = temp_dir.path();
632
633        // Create test files using new standardized format
634        let toml_content = r#"[Quill]
635name = "my-custom-quill"
636backend = "typst"
637glue = "custom_glue.typ"
638description = "Test quill with new format"
639author = "Test Author"
640"#;
641        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
642        fs::write(
643            quill_dir.join("custom_glue.typ"),
644            "= Custom Template\n\nThis is a custom template.",
645        )
646        .unwrap();
647
648        // Load quill
649        let quill = Quill::from_path(quill_dir).unwrap();
650
651        // Test that name comes from TOML, not directory
652        assert_eq!(quill.name, "my-custom-quill");
653
654        // Test that glue file is set correctly
655        assert_eq!(quill.glue_file, "custom_glue.typ");
656
657        // Test that backend is in metadata
658        assert!(quill.metadata.contains_key("backend"));
659        if let Some(backend_val) = quill.metadata.get("backend") {
660            if let Some(backend_str) = backend_val.as_str() {
661                assert_eq!(backend_str, "typst");
662            } else {
663                panic!("Backend value is not a string");
664            }
665        }
666
667        // Test that other fields are in metadata (but not version)
668        assert!(quill.metadata.contains_key("description"));
669        assert!(quill.metadata.contains_key("author"));
670        assert!(!quill.metadata.contains_key("version")); // version should be excluded
671
672        // Test that glue template content is loaded correctly
673        assert!(quill.glue_template.contains("Custom Template"));
674        assert!(quill.glue_template.contains("custom template"));
675    }
676
677    #[test]
678    fn test_typst_packages_parsing() {
679        let temp_dir = TempDir::new().unwrap();
680        let quill_dir = temp_dir.path();
681
682        let toml_content = r#"
683[Quill]
684name = "test-quill"
685backend = "typst"
686glue = "glue.typ"
687
688[typst]
689packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
690"#;
691
692        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
693        fs::write(quill_dir.join("glue.typ"), "test").unwrap();
694
695        let quill = Quill::from_path(quill_dir).unwrap();
696        let packages = quill.typst_packages();
697
698        assert_eq!(packages.len(), 2);
699        assert_eq!(packages[0], "@preview/bubble:0.2.2");
700        assert_eq!(packages[1], "@preview/example:1.0.0");
701    }
702}