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 quillignore_path = path.join(".quillignore");
207 let ignore = if quillignore_path.exists() {
208 let ignore_content = fs::read_to_string(&quillignore_path)
209 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
210 QuillIgnore::from_content(&ignore_content)
211 } else {
212 QuillIgnore::new(vec![
214 ".git/".to_string(),
215 ".gitignore".to_string(),
216 ".quillignore".to_string(),
217 "target/".to_string(),
218 "node_modules/".to_string(),
219 ])
220 };
221
222 let mut files = HashMap::new();
224 Self::load_directory_recursive(path, path, &mut files, &ignore)?;
225
226 Self::from_tree(files, Some(path.to_path_buf()), Some(name))
228 }
229
230 pub fn from_tree(
249 files: HashMap<PathBuf, FileEntry>,
250 base_path: Option<PathBuf>,
251 default_name: Option<String>,
252 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
253 let quill_toml_path = PathBuf::from("Quill.toml");
255 let quill_toml_entry = files
256 .get(&quill_toml_path)
257 .ok_or("Quill.toml not found in file tree")?;
258
259 let quill_toml_content = String::from_utf8(quill_toml_entry.contents.clone())
260 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
261
262 let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
263 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
264
265 let mut metadata = HashMap::new();
266 let mut glue_file = "glue.typ".to_string(); let mut template_file: Option<String> = None;
268 let mut quill_name = default_name.unwrap_or_else(|| "unnamed".to_string());
269
270 if let Some(quill_section) = quill_toml.get("Quill") {
272 if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
274 quill_name = name_val.to_string();
275 }
276
277 if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
278 match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
279 Ok(yaml_value) => {
280 metadata.insert("backend".to_string(), yaml_value);
281 }
282 Err(e) => {
283 eprintln!("Warning: Failed to convert backend field: {}", e);
284 }
285 }
286 }
287
288 if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
289 glue_file = glue_val.to_string();
290 }
291
292 if let Some(template_val) = quill_section.get("template").and_then(|v| v.as_str()) {
293 template_file = Some(template_val.to_string());
294 }
295
296 if let toml::Value::Table(table) = quill_section {
298 for (key, value) in table {
299 if key != "name"
300 && key != "backend"
301 && key != "glue"
302 && key != "template"
303 && key != "version"
304 {
305 match Self::toml_to_yaml_value(value) {
306 Ok(yaml_value) => {
307 metadata.insert(key.clone(), yaml_value);
308 }
309 Err(e) => {
310 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
311 }
312 }
313 }
314 }
315 }
316 }
317
318 if let Some(typst_section) = quill_toml.get("typst") {
320 if let toml::Value::Table(table) = typst_section {
321 for (key, value) in table {
322 match Self::toml_to_yaml_value(value) {
323 Ok(yaml_value) => {
324 metadata.insert(format!("typst_{}", key), yaml_value);
325 }
326 Err(e) => {
327 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
328 }
329 }
330 }
331 }
332 }
333
334 let glue_path = PathBuf::from(&glue_file);
336 let glue_entry = files
337 .get(&glue_path)
338 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file))?;
339
340 let template_content = String::from_utf8(glue_entry.contents.clone())
341 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file, e))?;
342
343 let template_content_opt = if let Some(ref template_file_name) = template_file {
345 let template_path = PathBuf::from(template_file_name);
346 files.get(&template_path).and_then(|entry| {
347 String::from_utf8(entry.contents.clone())
348 .map_err(|e| {
349 eprintln!(
350 "Warning: Template file '{}' is not valid UTF-8: {}",
351 template_file_name, e
352 );
353 e
354 })
355 .ok()
356 })
357 } else {
358 None
359 };
360
361 let quill = Quill {
362 glue_template: template_content,
363 metadata,
364 base_path: base_path.unwrap_or_else(|| PathBuf::from("/")),
365 name: quill_name,
366 glue_file,
367 template_file,
368 template: template_content_opt,
369 files,
370 };
371
372 quill.validate()?;
374
375 Ok(quill)
376 }
377
378 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
412 use serde_json::Value as JsonValue;
413
414 let json: JsonValue =
415 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
416
417 let files_json = json.get("files").ok_or("Missing 'files' field in JSON")?;
419
420 let mut files = HashMap::new();
421 if let JsonValue::Object(files_obj) = files_json {
422 for (path_str, file_data) in files_obj {
423 let path = PathBuf::from(path_str);
424
425 let contents =
426 if let Some(content_str) = file_data.get("contents").and_then(|v| v.as_str()) {
427 content_str.as_bytes().to_vec()
429 } else if let Some(bytes_array) =
430 file_data.get("contents").and_then(|v| v.as_array())
431 {
432 bytes_array
434 .iter()
435 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
436 .collect()
437 } else {
438 return Err(format!("Invalid contents for file '{}'", path_str).into());
439 };
440
441 let is_dir = file_data
442 .get("is_dir")
443 .and_then(|v| v.as_bool())
444 .unwrap_or(false);
445
446 files.insert(
447 path.clone(),
448 FileEntry {
449 contents,
450 path,
451 is_dir,
452 },
453 );
454 }
455 } else {
456 return Err("'files' field must be an object".into());
457 }
458
459 let base_path = json
461 .get("base_path")
462 .and_then(|v| v.as_str())
463 .map(PathBuf::from);
464
465 let default_name = json.get("name").and_then(|v| v.as_str()).map(String::from);
466
467 Self::from_tree(files, base_path, default_name)
469 }
470
471 fn load_directory_recursive(
473 current_dir: &Path,
474 base_dir: &Path,
475 files: &mut HashMap<PathBuf, FileEntry>,
476 ignore: &QuillIgnore,
477 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
478 use std::fs;
479
480 if !current_dir.exists() {
481 return Ok(());
482 }
483
484 for entry in fs::read_dir(current_dir)? {
485 let entry = entry?;
486 let path = entry.path();
487 let relative_path = path
488 .strip_prefix(base_dir)
489 .map_err(|e| format!("Failed to get relative path: {}", e))?
490 .to_path_buf();
491
492 if ignore.is_ignored(&relative_path) {
494 continue;
495 }
496
497 if path.is_file() {
498 let contents = fs::read(&path)
499 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
500
501 files.insert(
502 relative_path.clone(),
503 FileEntry {
504 contents,
505 path: relative_path,
506 is_dir: false,
507 },
508 );
509 } else if path.is_dir() {
510 files.insert(
512 relative_path.clone(),
513 FileEntry {
514 contents: Vec::new(),
515 path: relative_path,
516 is_dir: true,
517 },
518 );
519
520 Self::load_directory_recursive(&path, base_dir, files, ignore)?;
522 }
523 }
524
525 Ok(())
526 }
527
528 pub fn toml_to_yaml_value(
530 toml_val: &toml::Value,
531 ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
532 let json_val = serde_json::to_value(toml_val)?;
533 let yaml_val = serde_yaml::to_value(json_val)?;
534 Ok(yaml_val)
535 }
536
537 pub fn assets_path(&self) -> PathBuf {
539 self.base_path.join("assets")
540 }
541
542 pub fn packages_path(&self) -> PathBuf {
544 self.base_path.join("packages")
545 }
546
547 pub fn glue_path(&self) -> PathBuf {
549 self.base_path.join(&self.glue_file)
550 }
551
552 pub fn typst_packages(&self) -> Vec<String> {
554 self.metadata
555 .get("typst_packages")
556 .and_then(|v| v.as_sequence())
557 .map(|seq| {
558 seq.iter()
559 .filter_map(|v| v.as_str().map(|s| s.to_string()))
560 .collect()
561 })
562 .unwrap_or_default()
563 }
564
565 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
567 let glue_path = PathBuf::from(&self.glue_file);
569 if !self.files.contains_key(&glue_path) {
570 return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
571 }
572 Ok(())
573 }
574
575 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
577 let path = path.as_ref();
578 self.files.get(path).map(|entry| entry.contents.as_slice())
579 }
580
581 pub fn get_file_entry<P: AsRef<Path>>(&self, path: P) -> Option<&FileEntry> {
583 let path = path.as_ref();
584 self.files.get(path)
585 }
586
587 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
589 let path = path.as_ref();
590 self.files.contains_key(path)
591 }
592
593 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
595 let dir_path = dir_path.as_ref();
596 let mut entries = Vec::new();
597
598 for (path, entry) in &self.files {
599 if let Some(parent) = path.parent() {
600 if parent == dir_path && !entry.is_dir {
601 entries.push(path.clone());
602 }
603 } else if dir_path == Path::new("") && !entry.is_dir {
604 entries.push(path.clone());
606 }
607 }
608
609 entries.sort();
610 entries
611 }
612
613 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
615 let dir_path = dir_path.as_ref();
616 let mut entries = Vec::new();
617
618 for (path, entry) in &self.files {
619 if entry.is_dir {
620 if let Some(parent) = path.parent() {
621 if parent == dir_path {
622 entries.push(path.clone());
623 }
624 } else if dir_path == Path::new("") {
625 entries.push(path.clone());
627 }
628 }
629 }
630
631 entries.sort();
632 entries
633 }
634
635 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
637 let pattern_str = pattern.as_ref().to_string_lossy();
638 let mut matches = Vec::new();
639
640 for (path, entry) in &self.files {
641 if !entry.is_dir {
642 let path_str = path.to_string_lossy();
643 if self.matches_simple_pattern(&pattern_str, &path_str) {
644 matches.push(path.clone());
645 }
646 }
647 }
648
649 matches.sort();
650 matches
651 }
652
653 fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
655 if pattern == "*" {
656 return true;
657 }
658
659 if !pattern.contains('*') {
660 return path == pattern;
661 }
662
663 if pattern.ends_with("/*") {
665 let dir_pattern = &pattern[..pattern.len() - 2];
666 return path.starts_with(&format!("{}/", dir_pattern));
667 }
668
669 let parts: Vec<&str> = pattern.split('*').collect();
670 if parts.len() == 2 {
671 let (prefix, suffix) = (parts[0], parts[1]);
672 if prefix.is_empty() {
673 return path.ends_with(suffix);
674 } else if suffix.is_empty() {
675 return path.starts_with(prefix);
676 } else {
677 return path.starts_with(prefix) && path.ends_with(suffix);
678 }
679 }
680
681 false
682 }
683}
684#[cfg(test)]
685mod quill_tests {
686 use super::*;
687 use std::fs;
688 use tempfile::TempDir;
689
690 #[test]
691 fn test_quillignore_parsing() {
692 let ignore_content = r#"
693# This is a comment
694*.tmp
695target/
696node_modules/
697.git/
698"#;
699 let ignore = QuillIgnore::from_content(ignore_content);
700 assert_eq!(ignore.patterns.len(), 4);
701 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
702 assert!(ignore.patterns.contains(&"target/".to_string()));
703 }
704
705 #[test]
706 fn test_quillignore_matching() {
707 let ignore = QuillIgnore::new(vec![
708 "*.tmp".to_string(),
709 "target/".to_string(),
710 "node_modules/".to_string(),
711 ".git/".to_string(),
712 ]);
713
714 assert!(ignore.is_ignored("test.tmp"));
716 assert!(ignore.is_ignored("path/to/file.tmp"));
717 assert!(!ignore.is_ignored("test.txt"));
718
719 assert!(ignore.is_ignored("target"));
721 assert!(ignore.is_ignored("target/debug"));
722 assert!(ignore.is_ignored("target/debug/deps"));
723 assert!(!ignore.is_ignored("src/target.rs"));
724
725 assert!(ignore.is_ignored("node_modules"));
726 assert!(ignore.is_ignored("node_modules/package"));
727 assert!(!ignore.is_ignored("my_node_modules"));
728 }
729
730 #[test]
731 fn test_in_memory_file_system() {
732 let temp_dir = TempDir::new().unwrap();
733 let quill_dir = temp_dir.path();
734
735 fs::write(
737 quill_dir.join("Quill.toml"),
738 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
739 )
740 .unwrap();
741 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
742
743 let assets_dir = quill_dir.join("assets");
744 fs::create_dir_all(&assets_dir).unwrap();
745 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
746
747 let packages_dir = quill_dir.join("packages");
748 fs::create_dir_all(&packages_dir).unwrap();
749 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
750
751 let quill = Quill::from_path(quill_dir).unwrap();
753
754 assert!(quill.file_exists("glue.typ"));
756 assert!(quill.file_exists("assets/test.txt"));
757 assert!(quill.file_exists("packages/package.typ"));
758 assert!(!quill.file_exists("nonexistent.txt"));
759
760 let asset_content = quill.get_file("assets/test.txt").unwrap();
762 assert_eq!(asset_content, b"asset content");
763
764 let asset_files = quill.list_directory("assets");
766 assert_eq!(asset_files.len(), 1);
767 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
768 }
769
770 #[test]
771 fn test_quillignore_integration() {
772 let temp_dir = TempDir::new().unwrap();
773 let quill_dir = temp_dir.path();
774
775 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
777
778 fs::write(
780 quill_dir.join("Quill.toml"),
781 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
782 )
783 .unwrap();
784 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
785 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
786
787 let target_dir = quill_dir.join("target");
788 fs::create_dir_all(&target_dir).unwrap();
789 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
790
791 let quill = Quill::from_path(quill_dir).unwrap();
793
794 assert!(quill.file_exists("glue.typ"));
796 assert!(!quill.file_exists("should_ignore.tmp"));
797 assert!(!quill.file_exists("target/debug.txt"));
798 }
799
800 #[test]
801 fn test_find_files_pattern() {
802 let temp_dir = TempDir::new().unwrap();
803 let quill_dir = temp_dir.path();
804
805 fs::write(
807 quill_dir.join("Quill.toml"),
808 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
809 )
810 .unwrap();
811 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
812
813 let assets_dir = quill_dir.join("assets");
814 fs::create_dir_all(&assets_dir).unwrap();
815 fs::write(assets_dir.join("image.png"), "png data").unwrap();
816 fs::write(assets_dir.join("data.json"), "json data").unwrap();
817
818 let fonts_dir = assets_dir.join("fonts");
819 fs::create_dir_all(&fonts_dir).unwrap();
820 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
821
822 let quill = Quill::from_path(quill_dir).unwrap();
824
825 let all_assets = quill.find_files("assets/*");
827 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
830 assert_eq!(typ_files.len(), 1);
831 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
832 }
833
834 #[test]
835 fn test_new_standardized_toml_format() {
836 let temp_dir = TempDir::new().unwrap();
837 let quill_dir = temp_dir.path();
838
839 let toml_content = r#"[Quill]
841name = "my-custom-quill"
842backend = "typst"
843glue = "custom_glue.typ"
844description = "Test quill with new format"
845author = "Test Author"
846"#;
847 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
848 fs::write(
849 quill_dir.join("custom_glue.typ"),
850 "= Custom Template\n\nThis is a custom template.",
851 )
852 .unwrap();
853
854 let quill = Quill::from_path(quill_dir).unwrap();
856
857 assert_eq!(quill.name, "my-custom-quill");
859
860 assert_eq!(quill.glue_file, "custom_glue.typ");
862
863 assert!(quill.metadata.contains_key("backend"));
865 if let Some(backend_val) = quill.metadata.get("backend") {
866 if let Some(backend_str) = backend_val.as_str() {
867 assert_eq!(backend_str, "typst");
868 } else {
869 panic!("Backend value is not a string");
870 }
871 }
872
873 assert!(quill.metadata.contains_key("description"));
875 assert!(quill.metadata.contains_key("author"));
876 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue_template.contains("Custom Template"));
880 assert!(quill.glue_template.contains("custom template"));
881 }
882
883 #[test]
884 fn test_typst_packages_parsing() {
885 let temp_dir = TempDir::new().unwrap();
886 let quill_dir = temp_dir.path();
887
888 let toml_content = r#"
889[Quill]
890name = "test-quill"
891backend = "typst"
892glue = "glue.typ"
893
894[typst]
895packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
896"#;
897
898 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
899 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
900
901 let quill = Quill::from_path(quill_dir).unwrap();
902 let packages = quill.typst_packages();
903
904 assert_eq!(packages.len(), 2);
905 assert_eq!(packages[0], "@preview/bubble:0.2.2");
906 assert_eq!(packages[1], "@preview/example:1.0.0");
907 }
908
909 #[test]
910 fn test_template_loading() {
911 let temp_dir = TempDir::new().unwrap();
912 let quill_dir = temp_dir.path();
913
914 let toml_content = r#"[Quill]
916name = "test-with-template"
917backend = "typst"
918glue = "glue.typ"
919template = "example.md"
920"#;
921 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
922 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
923 fs::write(
924 quill_dir.join("example.md"),
925 "---\ntitle: Test\n---\n\nThis is a test template.",
926 )
927 .unwrap();
928
929 let quill = Quill::from_path(quill_dir).unwrap();
931
932 assert_eq!(quill.template_file, Some("example.md".to_string()));
934
935 assert!(quill.template.is_some());
937 let template = quill.template.unwrap();
938 assert!(template.contains("title: Test"));
939 assert!(template.contains("This is a test template"));
940
941 assert_eq!(quill.glue_template, "glue content");
943 }
944
945 #[test]
946 fn test_template_optional() {
947 let temp_dir = TempDir::new().unwrap();
948 let quill_dir = temp_dir.path();
949
950 let toml_content = r#"[Quill]
952name = "test-without-template"
953backend = "typst"
954glue = "glue.typ"
955"#;
956 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
957 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
958
959 let quill = Quill::from_path(quill_dir).unwrap();
961
962 assert_eq!(quill.template_file, None);
964 assert_eq!(quill.template, None);
965
966 assert_eq!(quill.glue_template, "glue content");
968 }
969
970 #[test]
971 fn test_from_tree() {
972 let mut files = HashMap::new();
974
975 let quill_toml = r#"[Quill]
977name = "test-from-tree"
978backend = "typst"
979glue = "glue.typ"
980description = "A test quill from tree"
981"#;
982 files.insert(
983 PathBuf::from("Quill.toml"),
984 FileEntry {
985 contents: quill_toml.as_bytes().to_vec(),
986 path: PathBuf::from("Quill.toml"),
987 is_dir: false,
988 },
989 );
990
991 let glue_content = "= Test Template\n\nThis is a test.";
993 files.insert(
994 PathBuf::from("glue.typ"),
995 FileEntry {
996 contents: glue_content.as_bytes().to_vec(),
997 path: PathBuf::from("glue.typ"),
998 is_dir: false,
999 },
1000 );
1001
1002 let quill = Quill::from_tree(files, Some(PathBuf::from("/test")), None).unwrap();
1004
1005 assert_eq!(quill.name, "test-from-tree");
1007 assert_eq!(quill.glue_file, "glue.typ");
1008 assert_eq!(quill.glue_template, glue_content);
1009 assert_eq!(quill.base_path, PathBuf::from("/test"));
1010 assert!(quill.metadata.contains_key("backend"));
1011 assert!(quill.metadata.contains_key("description"));
1012 }
1013
1014 #[test]
1015 fn test_from_tree_with_template() {
1016 let mut files = HashMap::new();
1017
1018 let quill_toml = r#"[Quill]
1020name = "test-tree-template"
1021backend = "typst"
1022glue = "glue.typ"
1023template = "template.md"
1024"#;
1025 files.insert(
1026 PathBuf::from("Quill.toml"),
1027 FileEntry {
1028 contents: quill_toml.as_bytes().to_vec(),
1029 path: PathBuf::from("Quill.toml"),
1030 is_dir: false,
1031 },
1032 );
1033
1034 files.insert(
1036 PathBuf::from("glue.typ"),
1037 FileEntry {
1038 contents: b"glue content".to_vec(),
1039 path: PathBuf::from("glue.typ"),
1040 is_dir: false,
1041 },
1042 );
1043
1044 let template_content = "# {{ title }}\n\n{{ body }}";
1046 files.insert(
1047 PathBuf::from("template.md"),
1048 FileEntry {
1049 contents: template_content.as_bytes().to_vec(),
1050 path: PathBuf::from("template.md"),
1051 is_dir: false,
1052 },
1053 );
1054
1055 let quill = Quill::from_tree(files, None, None).unwrap();
1057
1058 assert_eq!(quill.template_file, Some("template.md".to_string()));
1060 assert_eq!(quill.template, Some(template_content.to_string()));
1061 }
1062
1063 #[test]
1064 fn test_from_json() {
1065 let json_str = r#"{
1067 "name": "test-from-json",
1068 "base_path": "/test/path",
1069 "files": {
1070 "Quill.toml": {
1071 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n",
1072 "is_dir": false
1073 },
1074 "glue.typ": {
1075 "contents": "= Test Glue\n\nThis is test content.",
1076 "is_dir": false
1077 }
1078 }
1079 }"#;
1080
1081 let quill = Quill::from_json(json_str).unwrap();
1083
1084 assert_eq!(quill.name, "test-from-json");
1086 assert_eq!(quill.base_path, PathBuf::from("/test/path"));
1087 assert_eq!(quill.glue_file, "glue.typ");
1088 assert!(quill.glue_template.contains("Test Glue"));
1089 assert!(quill.metadata.contains_key("backend"));
1090 }
1091
1092 #[test]
1093 fn test_from_json_with_byte_array() {
1094 let json_str = r#"{
1096 "files": {
1097 "Quill.toml": {
1098 "contents": [91, 81, 117, 105, 108, 108, 93, 10, 110, 97, 109, 101, 32, 61, 32, 34, 116, 101, 115, 116, 34, 10, 98, 97, 99, 107, 101, 110, 100, 32, 61, 32, 34, 116, 121, 112, 115, 116, 34, 10, 103, 108, 117, 101, 32, 61, 32, 34, 103, 108, 117, 101, 46, 116, 121, 112, 34, 10],
1099 "is_dir": false
1100 },
1101 "glue.typ": {
1102 "contents": "test glue",
1103 "is_dir": false
1104 }
1105 }
1106 }"#;
1107
1108 let quill = Quill::from_json(json_str).unwrap();
1110
1111 assert_eq!(quill.name, "test");
1113 assert_eq!(quill.glue_file, "glue.typ");
1114 }
1115
1116 #[test]
1117 fn test_from_json_missing_files() {
1118 let json_str = r#"{
1120 "name": "test"
1121 }"#;
1122
1123 let result = Quill::from_json(json_str);
1124 assert!(result.is_err());
1125 assert!(result
1126 .unwrap_err()
1127 .to_string()
1128 .contains("Missing 'files' field"));
1129 }
1130}