1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
61pub enum OutputFormat {
62 Txt,
64 Svg,
66 Pdf,
68}
69
70#[derive(Debug)]
72pub struct Artifact {
73 pub bytes: Vec<u8>,
75 pub output_format: OutputFormat,
77}
78
79#[derive(Debug)]
81pub struct RenderOptions {
82 pub output_format: Option<OutputFormat>,
84}
85
86#[derive(Debug, Clone)]
88pub struct FileEntry {
89 pub contents: Vec<u8>,
91 pub path: PathBuf,
93 pub is_dir: bool,
95}
96
97#[derive(Debug, Clone)]
99pub struct QuillIgnore {
100 patterns: Vec<String>,
101}
102
103impl QuillIgnore {
104 pub fn new(patterns: Vec<String>) -> Self {
106 Self { patterns }
107 }
108
109 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 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 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
135 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 if !pattern.contains('*') {
145 return path == pattern || path.ends_with(&format!("/{}", pattern));
146 }
147
148 if pattern == "*" {
150 return true;
151 }
152
153 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#[derive(Debug, Clone)]
172pub struct Quill {
173 pub glue_template: String,
175 pub metadata: HashMap<String, serde_yaml::Value>,
177 pub base_path: PathBuf,
179 pub name: String,
181 pub glue_file: String,
183 pub files: HashMap<PathBuf, FileEntry>,
185}
186
187impl Quill {
188 pub fn from_path<P: AsRef<std::path::Path>>(
190 path: P,
191 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
192 use std::fs;
193
194 let path = path.as_ref();
195 let name = path
196 .file_name()
197 .and_then(|n| n.to_str())
198 .unwrap_or("unnamed")
199 .to_string();
200
201 let quill_toml_path = path.join("Quill.toml");
203 let quill_toml_content = fs::read_to_string(&quill_toml_path)
204 .map_err(|e| format!("Failed to read Quill.toml: {}", e))?;
205
206 let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
207 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
208
209 let mut metadata = HashMap::new();
210 let mut glue_file = "glue.typ".to_string(); let mut quill_name = name; if let Some(quill_section) = quill_toml.get("Quill") {
215 if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
217 quill_name = name_val.to_string();
218 }
219
220 if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
221 match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
222 Ok(yaml_value) => {
223 metadata.insert("backend".to_string(), yaml_value);
224 }
225 Err(e) => {
226 eprintln!("Warning: Failed to convert backend field: {}", e);
227 }
228 }
229 }
230
231 if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
232 glue_file = glue_val.to_string();
233 }
234
235 if let toml::Value::Table(table) = quill_section {
237 for (key, value) in table {
238 if key != "name" && key != "backend" && key != "glue" && key != "version" {
239 match Self::toml_to_yaml_value(value) {
240 Ok(yaml_value) => {
241 metadata.insert(key.clone(), yaml_value);
242 }
243 Err(e) => {
244 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
245 }
246 }
247 }
248 }
249 }
250 }
251
252 if let Some(typst_section) = quill_toml.get("typst") {
254 if let toml::Value::Table(table) = typst_section {
255 for (key, value) in table {
256 match Self::toml_to_yaml_value(value) {
257 Ok(yaml_value) => {
258 metadata.insert(format!("typst_{}", key), yaml_value);
259 }
260 Err(e) => {
261 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
262 }
263 }
264 }
265 }
266 }
267
268 let glue_path = path.join(&glue_file);
270 let template_content = fs::read_to_string(&glue_path)
271 .map_err(|e| format!("Failed to read glue file '{}': {}", glue_file, e))?;
272
273 let quillignore_path = path.join(".quillignore");
275 let ignore = if quillignore_path.exists() {
276 let ignore_content = fs::read_to_string(&quillignore_path)
277 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
278 QuillIgnore::from_content(&ignore_content)
279 } else {
280 QuillIgnore::new(vec![
282 ".git/".to_string(),
283 ".gitignore".to_string(),
284 ".quillignore".to_string(),
285 "target/".to_string(),
286 "node_modules/".to_string(),
287 ])
288 };
289
290 let mut files = HashMap::new();
292 Self::load_directory_recursive(path, path, &mut files, &ignore)?;
293
294 let quill = Quill {
295 glue_template: template_content,
296 metadata,
297 base_path: path.to_path_buf(),
298 name: quill_name,
299 glue_file,
300 files,
301 };
302
303 quill.validate()?;
305
306 Ok(quill)
307 }
308
309 fn load_directory_recursive(
311 current_dir: &Path,
312 base_dir: &Path,
313 files: &mut HashMap<PathBuf, FileEntry>,
314 ignore: &QuillIgnore,
315 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
316 use std::fs;
317
318 if !current_dir.exists() {
319 return Ok(());
320 }
321
322 for entry in fs::read_dir(current_dir)? {
323 let entry = entry?;
324 let path = entry.path();
325 let relative_path = path
326 .strip_prefix(base_dir)
327 .map_err(|e| format!("Failed to get relative path: {}", e))?
328 .to_path_buf();
329
330 if ignore.is_ignored(&relative_path) {
332 continue;
333 }
334
335 if path.is_file() {
336 let contents = fs::read(&path)
337 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
338
339 files.insert(
340 relative_path.clone(),
341 FileEntry {
342 contents,
343 path: relative_path,
344 is_dir: false,
345 },
346 );
347 } else if path.is_dir() {
348 files.insert(
350 relative_path.clone(),
351 FileEntry {
352 contents: Vec::new(),
353 path: relative_path,
354 is_dir: true,
355 },
356 );
357
358 Self::load_directory_recursive(&path, base_dir, files, ignore)?;
360 }
361 }
362
363 Ok(())
364 }
365
366 pub fn toml_to_yaml_value(
368 toml_val: &toml::Value,
369 ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
370 let json_val = serde_json::to_value(toml_val)?;
371 let yaml_val = serde_yaml::to_value(json_val)?;
372 Ok(yaml_val)
373 }
374
375 pub fn assets_path(&self) -> PathBuf {
377 self.base_path.join("assets")
378 }
379
380 pub fn packages_path(&self) -> PathBuf {
382 self.base_path.join("packages")
383 }
384
385 pub fn glue_path(&self) -> PathBuf {
387 self.base_path.join(&self.glue_file)
388 }
389
390 pub fn typst_packages(&self) -> Vec<String> {
392 self.metadata
393 .get("typst_packages")
394 .and_then(|v| v.as_sequence())
395 .map(|seq| {
396 seq.iter()
397 .filter_map(|v| v.as_str().map(|s| s.to_string()))
398 .collect()
399 })
400 .unwrap_or_default()
401 }
402
403 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
405 let glue_path = PathBuf::from(&self.glue_file);
407 if !self.files.contains_key(&glue_path) {
408 return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
409 }
410 Ok(())
411 }
412
413 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
415 let path = path.as_ref();
416 self.files.get(path).map(|entry| entry.contents.as_slice())
417 }
418
419 pub fn get_file_entry<P: AsRef<Path>>(&self, path: P) -> Option<&FileEntry> {
421 let path = path.as_ref();
422 self.files.get(path)
423 }
424
425 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
427 let path = path.as_ref();
428 self.files.contains_key(path)
429 }
430
431 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
433 let dir_path = dir_path.as_ref();
434 let mut entries = Vec::new();
435
436 for (path, entry) in &self.files {
437 if let Some(parent) = path.parent() {
438 if parent == dir_path && !entry.is_dir {
439 entries.push(path.clone());
440 }
441 } else if dir_path == Path::new("") && !entry.is_dir {
442 entries.push(path.clone());
444 }
445 }
446
447 entries.sort();
448 entries
449 }
450
451 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
453 let dir_path = dir_path.as_ref();
454 let mut entries = Vec::new();
455
456 for (path, entry) in &self.files {
457 if entry.is_dir {
458 if let Some(parent) = path.parent() {
459 if parent == dir_path {
460 entries.push(path.clone());
461 }
462 } else if dir_path == Path::new("") {
463 entries.push(path.clone());
465 }
466 }
467 }
468
469 entries.sort();
470 entries
471 }
472
473 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
475 let pattern_str = pattern.as_ref().to_string_lossy();
476 let mut matches = Vec::new();
477
478 for (path, entry) in &self.files {
479 if !entry.is_dir {
480 let path_str = path.to_string_lossy();
481 if self.matches_simple_pattern(&pattern_str, &path_str) {
482 matches.push(path.clone());
483 }
484 }
485 }
486
487 matches.sort();
488 matches
489 }
490
491 fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
493 if pattern == "*" {
494 return true;
495 }
496
497 if !pattern.contains('*') {
498 return path == pattern;
499 }
500
501 if pattern.ends_with("/*") {
503 let dir_pattern = &pattern[..pattern.len() - 2];
504 return path.starts_with(&format!("{}/", dir_pattern));
505 }
506
507 let parts: Vec<&str> = pattern.split('*').collect();
508 if parts.len() == 2 {
509 let (prefix, suffix) = (parts[0], parts[1]);
510 if prefix.is_empty() {
511 return path.ends_with(suffix);
512 } else if suffix.is_empty() {
513 return path.starts_with(prefix);
514 } else {
515 return path.starts_with(prefix) && path.ends_with(suffix);
516 }
517 }
518
519 false
520 }
521}
522#[cfg(test)]
523mod quill_tests {
524 use super::*;
525 use std::fs;
526 use tempfile::TempDir;
527
528 #[test]
529 fn test_quillignore_parsing() {
530 let ignore_content = r#"
531# This is a comment
532*.tmp
533target/
534node_modules/
535.git/
536"#;
537 let ignore = QuillIgnore::from_content(ignore_content);
538 assert_eq!(ignore.patterns.len(), 4);
539 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
540 assert!(ignore.patterns.contains(&"target/".to_string()));
541 }
542
543 #[test]
544 fn test_quillignore_matching() {
545 let ignore = QuillIgnore::new(vec![
546 "*.tmp".to_string(),
547 "target/".to_string(),
548 "node_modules/".to_string(),
549 ".git/".to_string(),
550 ]);
551
552 assert!(ignore.is_ignored("test.tmp"));
554 assert!(ignore.is_ignored("path/to/file.tmp"));
555 assert!(!ignore.is_ignored("test.txt"));
556
557 assert!(ignore.is_ignored("target"));
559 assert!(ignore.is_ignored("target/debug"));
560 assert!(ignore.is_ignored("target/debug/deps"));
561 assert!(!ignore.is_ignored("src/target.rs"));
562
563 assert!(ignore.is_ignored("node_modules"));
564 assert!(ignore.is_ignored("node_modules/package"));
565 assert!(!ignore.is_ignored("my_node_modules"));
566 }
567
568 #[test]
569 fn test_in_memory_file_system() {
570 let temp_dir = TempDir::new().unwrap();
571 let quill_dir = temp_dir.path();
572
573 fs::write(
575 quill_dir.join("Quill.toml"),
576 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
577 )
578 .unwrap();
579 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
580
581 let assets_dir = quill_dir.join("assets");
582 fs::create_dir_all(&assets_dir).unwrap();
583 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
584
585 let packages_dir = quill_dir.join("packages");
586 fs::create_dir_all(&packages_dir).unwrap();
587 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
588
589 let quill = Quill::from_path(quill_dir).unwrap();
591
592 assert!(quill.file_exists("glue.typ"));
594 assert!(quill.file_exists("assets/test.txt"));
595 assert!(quill.file_exists("packages/package.typ"));
596 assert!(!quill.file_exists("nonexistent.txt"));
597
598 let asset_content = quill.get_file("assets/test.txt").unwrap();
600 assert_eq!(asset_content, b"asset content");
601
602 let asset_files = quill.list_directory("assets");
604 assert_eq!(asset_files.len(), 1);
605 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
606 }
607
608 #[test]
609 fn test_quillignore_integration() {
610 let temp_dir = TempDir::new().unwrap();
611 let quill_dir = temp_dir.path();
612
613 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
615
616 fs::write(
618 quill_dir.join("Quill.toml"),
619 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
620 )
621 .unwrap();
622 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
623 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
624
625 let target_dir = quill_dir.join("target");
626 fs::create_dir_all(&target_dir).unwrap();
627 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
628
629 let quill = Quill::from_path(quill_dir).unwrap();
631
632 assert!(quill.file_exists("glue.typ"));
634 assert!(!quill.file_exists("should_ignore.tmp"));
635 assert!(!quill.file_exists("target/debug.txt"));
636 }
637
638 #[test]
639 fn test_find_files_pattern() {
640 let temp_dir = TempDir::new().unwrap();
641 let quill_dir = temp_dir.path();
642
643 fs::write(
645 quill_dir.join("Quill.toml"),
646 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
647 )
648 .unwrap();
649 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
650
651 let assets_dir = quill_dir.join("assets");
652 fs::create_dir_all(&assets_dir).unwrap();
653 fs::write(assets_dir.join("image.png"), "png data").unwrap();
654 fs::write(assets_dir.join("data.json"), "json data").unwrap();
655
656 let fonts_dir = assets_dir.join("fonts");
657 fs::create_dir_all(&fonts_dir).unwrap();
658 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
659
660 let quill = Quill::from_path(quill_dir).unwrap();
662
663 let all_assets = quill.find_files("assets/*");
665 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
668 assert_eq!(typ_files.len(), 1);
669 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
670 }
671
672 #[test]
673 fn test_new_standardized_toml_format() {
674 let temp_dir = TempDir::new().unwrap();
675 let quill_dir = temp_dir.path();
676
677 let toml_content = r#"[Quill]
679name = "my-custom-quill"
680backend = "typst"
681glue = "custom_glue.typ"
682description = "Test quill with new format"
683author = "Test Author"
684"#;
685 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
686 fs::write(
687 quill_dir.join("custom_glue.typ"),
688 "= Custom Template\n\nThis is a custom template.",
689 )
690 .unwrap();
691
692 let quill = Quill::from_path(quill_dir).unwrap();
694
695 assert_eq!(quill.name, "my-custom-quill");
697
698 assert_eq!(quill.glue_file, "custom_glue.typ");
700
701 assert!(quill.metadata.contains_key("backend"));
703 if let Some(backend_val) = quill.metadata.get("backend") {
704 if let Some(backend_str) = backend_val.as_str() {
705 assert_eq!(backend_str, "typst");
706 } else {
707 panic!("Backend value is not a string");
708 }
709 }
710
711 assert!(quill.metadata.contains_key("description"));
713 assert!(quill.metadata.contains_key("author"));
714 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue_template.contains("Custom Template"));
718 assert!(quill.glue_template.contains("custom template"));
719 }
720
721 #[test]
722 fn test_typst_packages_parsing() {
723 let temp_dir = TempDir::new().unwrap();
724 let quill_dir = temp_dir.path();
725
726 let toml_content = r#"
727[Quill]
728name = "test-quill"
729backend = "typst"
730glue = "glue.typ"
731
732[typst]
733packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
734"#;
735
736 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
737 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
738
739 let quill = Quill::from_path(quill_dir).unwrap();
740 let packages = quill.typst_packages();
741
742 assert_eq!(packages.len(), 2);
743 assert_eq!(packages[0], "@preview/bubble:0.2.2");
744 assert_eq!(packages[1], "@preview/example:1.0.0");
745 }
746}