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 template_file: Option<String>,
185 pub template: Option<String>,
187 pub files: HashMap<PathBuf, FileEntry>,
189}
190
191impl Quill {
192 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 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(); let mut template_file: Option<String> = None;
216 let mut quill_name = name; if let Some(quill_section) = quill_toml.get("Quill") {
220 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 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 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 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 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 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 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 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 quill.validate()?;
338
339 Ok(quill)
340 }
341
342 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 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 files.insert(
383 relative_path.clone(),
384 FileEntry {
385 contents: Vec::new(),
386 path: relative_path,
387 is_dir: true,
388 },
389 );
390
391 Self::load_directory_recursive(&path, base_dir, files, ignore)?;
393 }
394 }
395
396 Ok(())
397 }
398
399 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 pub fn assets_path(&self) -> PathBuf {
410 self.base_path.join("assets")
411 }
412
413 pub fn packages_path(&self) -> PathBuf {
415 self.base_path.join("packages")
416 }
417
418 pub fn glue_path(&self) -> PathBuf {
420 self.base_path.join(&self.glue_file)
421 }
422
423 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 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
438 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 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 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 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 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 entries.push(path.clone());
477 }
478 }
479
480 entries.sort();
481 entries
482 }
483
484 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 entries.push(path.clone());
498 }
499 }
500 }
501
502 entries.sort();
503 entries
504 }
505
506 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 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 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 assert!(ignore.is_ignored("test.tmp"));
587 assert!(ignore.is_ignored("path/to/file.tmp"));
588 assert!(!ignore.is_ignored("test.txt"));
589
590 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 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 let quill = Quill::from_path(quill_dir).unwrap();
624
625 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 let asset_content = quill.get_file("assets/test.txt").unwrap();
633 assert_eq!(asset_content, b"asset content");
634
635 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 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
648
649 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 let quill = Quill::from_path(quill_dir).unwrap();
664
665 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 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 let quill = Quill::from_path(quill_dir).unwrap();
695
696 let all_assets = quill.find_files("assets/*");
698 assert!(all_assets.len() >= 3); 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 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 let quill = Quill::from_path(quill_dir).unwrap();
727
728 assert_eq!(quill.name, "my-custom-quill");
730
731 assert_eq!(quill.glue_file, "custom_glue.typ");
733
734 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 assert!(quill.metadata.contains_key("description"));
746 assert!(quill.metadata.contains_key("author"));
747 assert!(!quill.metadata.contains_key("version")); 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 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 let quill = Quill::from_path(quill_dir).unwrap();
802
803 assert_eq!(quill.template_file, Some("example.md".to_string()));
805
806 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 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 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 let quill = Quill::from_path(quill_dir).unwrap();
832
833 assert_eq!(quill.template_file, None);
835 assert_eq!(quill.template, None);
836
837 assert_eq!(quill.glue_template, "glue content");
839 }
840}