1use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
9pub enum FileTreeNode {
10 File {
12 contents: Vec<u8>,
14 },
15 Directory {
17 files: HashMap<String, FileTreeNode>,
19 },
20}
21
22impl FileTreeNode {
23 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
25 let path = path.as_ref();
26
27 if path == Path::new("") {
29 return Some(self);
30 }
31
32 let components: Vec<_> = path
34 .components()
35 .filter_map(|c| {
36 if let std::path::Component::Normal(s) = c {
37 s.to_str()
38 } else {
39 None
40 }
41 })
42 .collect();
43
44 if components.is_empty() {
45 return Some(self);
46 }
47
48 let mut current_node = self;
50 for component in components {
51 match current_node {
52 FileTreeNode::Directory { files } => {
53 current_node = files.get(component)?;
54 }
55 FileTreeNode::File { .. } => {
56 return None; }
58 }
59 }
60
61 Some(current_node)
62 }
63
64 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
66 match self.get_node(path)? {
67 FileTreeNode::File { contents } => Some(contents.as_slice()),
68 FileTreeNode::Directory { .. } => None,
69 }
70 }
71
72 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
74 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
75 }
76
77 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
79 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
80 }
81
82 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
84 match self.get_node(dir_path) {
85 Some(FileTreeNode::Directory { files }) => files
86 .iter()
87 .filter_map(|(name, node)| {
88 if matches!(node, FileTreeNode::File { .. }) {
89 Some(name.clone())
90 } else {
91 None
92 }
93 })
94 .collect(),
95 _ => Vec::new(),
96 }
97 }
98
99 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
101 match self.get_node(dir_path) {
102 Some(FileTreeNode::Directory { files }) => files
103 .iter()
104 .filter_map(|(name, node)| {
105 if matches!(node, FileTreeNode::Directory { .. }) {
106 Some(name.clone())
107 } else {
108 None
109 }
110 })
111 .collect(),
112 _ => Vec::new(),
113 }
114 }
115
116 pub fn insert<P: AsRef<Path>>(
118 &mut self,
119 path: P,
120 node: FileTreeNode,
121 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
122 let path = path.as_ref();
123
124 let components: Vec<_> = path
126 .components()
127 .filter_map(|c| {
128 if let std::path::Component::Normal(s) = c {
129 s.to_str().map(|s| s.to_string())
130 } else {
131 None
132 }
133 })
134 .collect();
135
136 if components.is_empty() {
137 return Err("Cannot insert at root path".into());
138 }
139
140 let mut current_node = self;
142 for component in &components[..components.len() - 1] {
143 match current_node {
144 FileTreeNode::Directory { files } => {
145 current_node =
146 files
147 .entry(component.clone())
148 .or_insert_with(|| FileTreeNode::Directory {
149 files: HashMap::new(),
150 });
151 }
152 FileTreeNode::File { .. } => {
153 return Err("Cannot traverse into a file".into());
154 }
155 }
156 }
157
158 let filename = &components[components.len() - 1];
160 match current_node {
161 FileTreeNode::Directory { files } => {
162 files.insert(filename.clone(), node);
163 Ok(())
164 }
165 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
166 }
167 }
168
169 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
171 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
172 Ok(FileTreeNode::File {
174 contents: contents_str.as_bytes().to_vec(),
175 })
176 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
177 let contents: Vec<u8> = bytes_array
179 .iter()
180 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
181 .collect();
182 Ok(FileTreeNode::File { contents })
183 } else if let Some(obj) = value.as_object() {
184 let mut files = HashMap::new();
186 for (name, child_value) in obj {
187 files.insert(name.clone(), Self::from_json_value(child_value)?);
188 }
189 Ok(FileTreeNode::Directory { files })
191 } else {
192 Err(format!("Invalid file tree node: {:?}", value).into())
193 }
194 }
195}
196
197#[derive(Debug, Clone)]
199pub struct QuillIgnore {
200 patterns: Vec<String>,
201}
202
203impl QuillIgnore {
204 pub fn new(patterns: Vec<String>) -> Self {
206 Self { patterns }
207 }
208
209 pub fn from_content(content: &str) -> Self {
211 let patterns = content
212 .lines()
213 .map(|line| line.trim())
214 .filter(|line| !line.is_empty() && !line.starts_with('#'))
215 .map(|line| line.to_string())
216 .collect();
217 Self::new(patterns)
218 }
219
220 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
222 let path = path.as_ref();
223 let path_str = path.to_string_lossy();
224
225 for pattern in &self.patterns {
226 if self.matches_pattern(pattern, &path_str) {
227 return true;
228 }
229 }
230 false
231 }
232
233 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
235 if pattern.ends_with('/') {
237 let pattern_prefix = &pattern[..pattern.len() - 1];
238 return path.starts_with(pattern_prefix)
239 && (path.len() == pattern_prefix.len()
240 || path.chars().nth(pattern_prefix.len()) == Some('/'));
241 }
242
243 if !pattern.contains('*') {
245 return path == pattern || path.ends_with(&format!("/{}", pattern));
246 }
247
248 if pattern == "*" {
250 return true;
251 }
252
253 let pattern_parts: Vec<&str> = pattern.split('*').collect();
255 if pattern_parts.len() == 2 {
256 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
257 if prefix.is_empty() {
258 return path.ends_with(suffix);
259 } else if suffix.is_empty() {
260 return path.starts_with(prefix);
261 } else {
262 return path.starts_with(prefix) && path.ends_with(suffix);
263 }
264 }
265
266 false
267 }
268}
269
270#[derive(Debug, Clone)]
272pub struct Quill {
273 pub glue_template: String,
275 pub metadata: HashMap<String, serde_yaml::Value>,
277 pub name: String,
279 pub glue_file: String,
281 pub template_file: Option<String>,
283 pub template: Option<String>,
285 pub files: FileTreeNode,
287}
288
289impl Quill {
290 pub fn from_path<P: AsRef<std::path::Path>>(
292 path: P,
293 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
294 use std::fs;
295
296 let path = path.as_ref();
297 let name = path
298 .file_name()
299 .and_then(|n| n.to_str())
300 .unwrap_or("unnamed")
301 .to_string();
302
303 let quillignore_path = path.join(".quillignore");
305 let ignore = if quillignore_path.exists() {
306 let ignore_content = fs::read_to_string(&quillignore_path)
307 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
308 QuillIgnore::from_content(&ignore_content)
309 } else {
310 QuillIgnore::new(vec![
312 ".git/".to_string(),
313 ".gitignore".to_string(),
314 ".quillignore".to_string(),
315 "target/".to_string(),
316 "node_modules/".to_string(),
317 ])
318 };
319
320 let root = Self::load_directory_as_tree(path, path, &ignore)?;
322
323 Self::from_tree(root, Some(name))
325 }
326
327 pub fn from_tree(
345 root: FileTreeNode,
346 default_name: Option<String>,
347 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
348 let quill_toml_bytes = root
350 .get_file("Quill.toml")
351 .ok_or("Quill.toml not found in file tree")?;
352
353 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
354 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
355
356 let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
357 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
358
359 let mut metadata = HashMap::new();
360 let mut glue_file = "glue.typ".to_string(); let mut template_file: Option<String> = None;
362 let mut quill_name = default_name.unwrap_or_else(|| "unnamed".to_string());
363
364 if let Some(quill_section) = quill_toml.get("Quill") {
366 if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
368 quill_name = name_val.to_string();
369 }
370
371 if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
372 match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
373 Ok(yaml_value) => {
374 metadata.insert("backend".to_string(), yaml_value);
375 }
376 Err(e) => {
377 eprintln!("Warning: Failed to convert backend field: {}", e);
378 }
379 }
380 }
381
382 if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
383 glue_file = glue_val.to_string();
384 }
385
386 if let Some(template_val) = quill_section.get("template").and_then(|v| v.as_str()) {
387 template_file = Some(template_val.to_string());
388 }
389
390 if let toml::Value::Table(table) = quill_section {
392 for (key, value) in table {
393 if key != "name"
394 && key != "backend"
395 && key != "glue"
396 && key != "template"
397 && key != "version"
398 {
399 match Self::toml_to_yaml_value(value) {
400 Ok(yaml_value) => {
401 metadata.insert(key.clone(), yaml_value);
402 }
403 Err(e) => {
404 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
405 }
406 }
407 }
408 }
409 }
410 }
411
412 if let Some(typst_section) = quill_toml.get("typst") {
414 if let toml::Value::Table(table) = typst_section {
415 for (key, value) in table {
416 match Self::toml_to_yaml_value(value) {
417 Ok(yaml_value) => {
418 metadata.insert(format!("typst_{}", key), yaml_value);
419 }
420 Err(e) => {
421 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
422 }
423 }
424 }
425 }
426 }
427
428 let glue_bytes = root
430 .get_file(&glue_file)
431 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file))?;
432
433 let template_content = String::from_utf8(glue_bytes.to_vec())
434 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file, e))?;
435
436 let template_content_opt = if let Some(ref template_file_name) = template_file {
438 root.get_file(template_file_name).and_then(|bytes| {
439 String::from_utf8(bytes.to_vec())
440 .map_err(|e| {
441 eprintln!(
442 "Warning: Template file '{}' is not valid UTF-8: {}",
443 template_file_name, e
444 );
445 e
446 })
447 .ok()
448 })
449 } else {
450 None
451 };
452
453 let quill = Quill {
454 glue_template: template_content,
455 metadata,
456 name: quill_name,
457 glue_file,
458 template_file,
459 template: template_content_opt,
460 files: root,
461 };
462
463 quill.validate()?;
465
466 Ok(quill)
467 }
468
469 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
476 use serde_json::Value as JsonValue;
477
478 let json: JsonValue =
479 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
480
481 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
482
483 let default_name = obj
485 .get("metadata")
486 .and_then(|m| m.get("name"))
487 .and_then(|v| v.as_str())
488 .map(String::from);
489
490 let files_obj = obj
492 .get("files")
493 .and_then(|v| v.as_object())
494 .ok_or_else(|| "Missing or invalid 'files' key")?;
495
496 let mut root_files = HashMap::new();
498 for (key, value) in files_obj {
499 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
500 }
501
502 let root = FileTreeNode::Directory { files: root_files };
503
504 Self::from_tree(root, default_name)
506 }
507
508 fn load_directory_as_tree(
510 current_dir: &Path,
511 base_dir: &Path,
512 ignore: &QuillIgnore,
513 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
514 use std::fs;
515
516 if !current_dir.exists() {
517 return Ok(FileTreeNode::Directory {
518 files: HashMap::new(),
519 });
520 }
521
522 let mut files = HashMap::new();
523
524 for entry in fs::read_dir(current_dir)? {
525 let entry = entry?;
526 let path = entry.path();
527 let relative_path = path
528 .strip_prefix(base_dir)
529 .map_err(|e| format!("Failed to get relative path: {}", e))?
530 .to_path_buf();
531
532 if ignore.is_ignored(&relative_path) {
534 continue;
535 }
536
537 let filename = path
539 .file_name()
540 .and_then(|n| n.to_str())
541 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
542 .to_string();
543
544 if path.is_file() {
545 let contents = fs::read(&path)
546 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
547
548 files.insert(filename, FileTreeNode::File { contents });
549 } else if path.is_dir() {
550 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
552 files.insert(filename, subdir_tree);
553 }
554 }
555
556 Ok(FileTreeNode::Directory { files })
557 }
558
559 pub fn toml_to_yaml_value(
561 toml_val: &toml::Value,
562 ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
563 let json_val = serde_json::to_value(toml_val)?;
564 let yaml_val = serde_yaml::to_value(json_val)?;
565 Ok(yaml_val)
566 }
567
568 pub fn typst_packages(&self) -> Vec<String> {
570 self.metadata
571 .get("typst_packages")
572 .and_then(|v| v.as_sequence())
573 .map(|seq| {
574 seq.iter()
575 .filter_map(|v| v.as_str().map(|s| s.to_string()))
576 .collect()
577 })
578 .unwrap_or_default()
579 }
580
581 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
583 if !self.files.file_exists(&self.glue_file) {
585 return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
586 }
587 Ok(())
588 }
589
590 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
592 self.files.get_file(path)
593 }
594
595 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
597 self.files.file_exists(path)
598 }
599
600 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
602 self.files.dir_exists(path)
603 }
604
605 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
607 self.files.list_files(path)
608 }
609
610 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
612 self.files.list_subdirectories(path)
613 }
614
615 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
617 let dir_path = dir_path.as_ref();
618 let filenames = self.files.list_files(dir_path);
619
620 filenames
622 .iter()
623 .map(|name| {
624 if dir_path == Path::new("") {
625 PathBuf::from(name)
626 } else {
627 dir_path.join(name)
628 }
629 })
630 .collect()
631 }
632
633 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
635 let dir_path = dir_path.as_ref();
636 let subdirs = self.files.list_subdirectories(dir_path);
637
638 subdirs
640 .iter()
641 .map(|name| {
642 if dir_path == Path::new("") {
643 PathBuf::from(name)
644 } else {
645 dir_path.join(name)
646 }
647 })
648 .collect()
649 }
650
651 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
653 let pattern_str = pattern.as_ref().to_string_lossy();
654 let mut matches = Vec::new();
655
656 self.find_files_recursive(&self.files, Path::new(""), &pattern_str, &mut matches);
658
659 matches.sort();
660 matches
661 }
662
663 fn find_files_recursive(
665 &self,
666 node: &FileTreeNode,
667 current_path: &Path,
668 pattern: &str,
669 matches: &mut Vec<PathBuf>,
670 ) {
671 match node {
672 FileTreeNode::File { .. } => {
673 let path_str = current_path.to_string_lossy();
674 if self.matches_simple_pattern(pattern, &path_str) {
675 matches.push(current_path.to_path_buf());
676 }
677 }
678 FileTreeNode::Directory { files } => {
679 for (name, child_node) in files {
680 let child_path = if current_path == Path::new("") {
681 PathBuf::from(name)
682 } else {
683 current_path.join(name)
684 };
685 self.find_files_recursive(child_node, &child_path, pattern, matches);
686 }
687 }
688 }
689 }
690
691 fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
693 if pattern == "*" {
694 return true;
695 }
696
697 if !pattern.contains('*') {
698 return path == pattern;
699 }
700
701 if pattern.ends_with("/*") {
703 let dir_pattern = &pattern[..pattern.len() - 2];
704 return path.starts_with(&format!("{}/", dir_pattern));
705 }
706
707 let parts: Vec<&str> = pattern.split('*').collect();
708 if parts.len() == 2 {
709 let (prefix, suffix) = (parts[0], parts[1]);
710 if prefix.is_empty() {
711 return path.ends_with(suffix);
712 } else if suffix.is_empty() {
713 return path.starts_with(prefix);
714 } else {
715 return path.starts_with(prefix) && path.ends_with(suffix);
716 }
717 }
718
719 false
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use std::fs;
727 use tempfile::TempDir;
728
729 #[test]
730 fn test_quillignore_parsing() {
731 let ignore_content = r#"
732# This is a comment
733*.tmp
734target/
735node_modules/
736.git/
737"#;
738 let ignore = QuillIgnore::from_content(ignore_content);
739 assert_eq!(ignore.patterns.len(), 4);
740 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
741 assert!(ignore.patterns.contains(&"target/".to_string()));
742 }
743
744 #[test]
745 fn test_quillignore_matching() {
746 let ignore = QuillIgnore::new(vec![
747 "*.tmp".to_string(),
748 "target/".to_string(),
749 "node_modules/".to_string(),
750 ".git/".to_string(),
751 ]);
752
753 assert!(ignore.is_ignored("test.tmp"));
755 assert!(ignore.is_ignored("path/to/file.tmp"));
756 assert!(!ignore.is_ignored("test.txt"));
757
758 assert!(ignore.is_ignored("target"));
760 assert!(ignore.is_ignored("target/debug"));
761 assert!(ignore.is_ignored("target/debug/deps"));
762 assert!(!ignore.is_ignored("src/target.rs"));
763
764 assert!(ignore.is_ignored("node_modules"));
765 assert!(ignore.is_ignored("node_modules/package"));
766 assert!(!ignore.is_ignored("my_node_modules"));
767 }
768
769 #[test]
770 fn test_in_memory_file_system() {
771 let temp_dir = TempDir::new().unwrap();
772 let quill_dir = temp_dir.path();
773
774 fs::write(
776 quill_dir.join("Quill.toml"),
777 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
778 )
779 .unwrap();
780 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
781
782 let assets_dir = quill_dir.join("assets");
783 fs::create_dir_all(&assets_dir).unwrap();
784 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
785
786 let packages_dir = quill_dir.join("packages");
787 fs::create_dir_all(&packages_dir).unwrap();
788 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
789
790 let quill = Quill::from_path(quill_dir).unwrap();
792
793 assert!(quill.file_exists("glue.typ"));
795 assert!(quill.file_exists("assets/test.txt"));
796 assert!(quill.file_exists("packages/package.typ"));
797 assert!(!quill.file_exists("nonexistent.txt"));
798
799 let asset_content = quill.get_file("assets/test.txt").unwrap();
801 assert_eq!(asset_content, b"asset content");
802
803 let asset_files = quill.list_directory("assets");
805 assert_eq!(asset_files.len(), 1);
806 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
807 }
808
809 #[test]
810 fn test_quillignore_integration() {
811 let temp_dir = TempDir::new().unwrap();
812 let quill_dir = temp_dir.path();
813
814 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
816
817 fs::write(
819 quill_dir.join("Quill.toml"),
820 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
821 )
822 .unwrap();
823 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
824 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
825
826 let target_dir = quill_dir.join("target");
827 fs::create_dir_all(&target_dir).unwrap();
828 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
829
830 let quill = Quill::from_path(quill_dir).unwrap();
832
833 assert!(quill.file_exists("glue.typ"));
835 assert!(!quill.file_exists("should_ignore.tmp"));
836 assert!(!quill.file_exists("target/debug.txt"));
837 }
838
839 #[test]
840 fn test_find_files_pattern() {
841 let temp_dir = TempDir::new().unwrap();
842 let quill_dir = temp_dir.path();
843
844 fs::write(
846 quill_dir.join("Quill.toml"),
847 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
848 )
849 .unwrap();
850 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
851
852 let assets_dir = quill_dir.join("assets");
853 fs::create_dir_all(&assets_dir).unwrap();
854 fs::write(assets_dir.join("image.png"), "png data").unwrap();
855 fs::write(assets_dir.join("data.json"), "json data").unwrap();
856
857 let fonts_dir = assets_dir.join("fonts");
858 fs::create_dir_all(&fonts_dir).unwrap();
859 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
860
861 let quill = Quill::from_path(quill_dir).unwrap();
863
864 let all_assets = quill.find_files("assets/*");
866 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
869 assert_eq!(typ_files.len(), 1);
870 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
871 }
872
873 #[test]
874 fn test_new_standardized_toml_format() {
875 let temp_dir = TempDir::new().unwrap();
876 let quill_dir = temp_dir.path();
877
878 let toml_content = r#"[Quill]
880name = "my-custom-quill"
881backend = "typst"
882glue = "custom_glue.typ"
883description = "Test quill with new format"
884author = "Test Author"
885"#;
886 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
887 fs::write(
888 quill_dir.join("custom_glue.typ"),
889 "= Custom Template\n\nThis is a custom template.",
890 )
891 .unwrap();
892
893 let quill = Quill::from_path(quill_dir).unwrap();
895
896 assert_eq!(quill.name, "my-custom-quill");
898
899 assert_eq!(quill.glue_file, "custom_glue.typ");
901
902 assert!(quill.metadata.contains_key("backend"));
904 if let Some(backend_val) = quill.metadata.get("backend") {
905 if let Some(backend_str) = backend_val.as_str() {
906 assert_eq!(backend_str, "typst");
907 } else {
908 panic!("Backend value is not a string");
909 }
910 }
911
912 assert!(quill.metadata.contains_key("description"));
914 assert!(quill.metadata.contains_key("author"));
915 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue_template.contains("Custom Template"));
919 assert!(quill.glue_template.contains("custom template"));
920 }
921
922 #[test]
923 fn test_typst_packages_parsing() {
924 let temp_dir = TempDir::new().unwrap();
925 let quill_dir = temp_dir.path();
926
927 let toml_content = r#"
928[Quill]
929name = "test-quill"
930backend = "typst"
931glue = "glue.typ"
932
933[typst]
934packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
935"#;
936
937 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
938 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
939
940 let quill = Quill::from_path(quill_dir).unwrap();
941 let packages = quill.typst_packages();
942
943 assert_eq!(packages.len(), 2);
944 assert_eq!(packages[0], "@preview/bubble:0.2.2");
945 assert_eq!(packages[1], "@preview/example:1.0.0");
946 }
947
948 #[test]
949 fn test_template_loading() {
950 let temp_dir = TempDir::new().unwrap();
951 let quill_dir = temp_dir.path();
952
953 let toml_content = r#"[Quill]
955name = "test-with-template"
956backend = "typst"
957glue = "glue.typ"
958template = "example.md"
959"#;
960 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
961 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
962 fs::write(
963 quill_dir.join("example.md"),
964 "---\ntitle: Test\n---\n\nThis is a test template.",
965 )
966 .unwrap();
967
968 let quill = Quill::from_path(quill_dir).unwrap();
970
971 assert_eq!(quill.template_file, Some("example.md".to_string()));
973
974 assert!(quill.template.is_some());
976 let template = quill.template.unwrap();
977 assert!(template.contains("title: Test"));
978 assert!(template.contains("This is a test template"));
979
980 assert_eq!(quill.glue_template, "glue content");
982 }
983
984 #[test]
985 fn test_template_optional() {
986 let temp_dir = TempDir::new().unwrap();
987 let quill_dir = temp_dir.path();
988
989 let toml_content = r#"[Quill]
991name = "test-without-template"
992backend = "typst"
993glue = "glue.typ"
994"#;
995 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
996 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
997
998 let quill = Quill::from_path(quill_dir).unwrap();
1000
1001 assert_eq!(quill.template_file, None);
1003 assert_eq!(quill.template, None);
1004
1005 assert_eq!(quill.glue_template, "glue content");
1007 }
1008
1009 #[test]
1010 fn test_from_tree() {
1011 let mut root_files = HashMap::new();
1013
1014 let quill_toml = r#"[Quill]
1016name = "test-from-tree"
1017backend = "typst"
1018glue = "glue.typ"
1019description = "A test quill from tree"
1020"#;
1021 root_files.insert(
1022 "Quill.toml".to_string(),
1023 FileTreeNode::File {
1024 contents: quill_toml.as_bytes().to_vec(),
1025 },
1026 );
1027
1028 let glue_content = "= Test Template\n\nThis is a test.";
1030 root_files.insert(
1031 "glue.typ".to_string(),
1032 FileTreeNode::File {
1033 contents: glue_content.as_bytes().to_vec(),
1034 },
1035 );
1036
1037 let root = FileTreeNode::Directory { files: root_files };
1038
1039 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1041
1042 assert_eq!(quill.name, "test-from-tree");
1044 assert_eq!(quill.glue_file, "glue.typ");
1045 assert_eq!(quill.glue_template, glue_content);
1046 assert!(quill.metadata.contains_key("backend"));
1047 assert!(quill.metadata.contains_key("description"));
1048 }
1049
1050 #[test]
1051 fn test_from_tree_with_template() {
1052 let mut root_files = HashMap::new();
1053
1054 let quill_toml = r#"[Quill]
1056name = "test-tree-template"
1057backend = "typst"
1058glue = "glue.typ"
1059template = "template.md"
1060"#;
1061 root_files.insert(
1062 "Quill.toml".to_string(),
1063 FileTreeNode::File {
1064 contents: quill_toml.as_bytes().to_vec(),
1065 },
1066 );
1067
1068 root_files.insert(
1070 "glue.typ".to_string(),
1071 FileTreeNode::File {
1072 contents: b"glue content".to_vec(),
1073 },
1074 );
1075
1076 let template_content = "# {{ title }}\n\n{{ body }}";
1078 root_files.insert(
1079 "template.md".to_string(),
1080 FileTreeNode::File {
1081 contents: template_content.as_bytes().to_vec(),
1082 },
1083 );
1084
1085 let root = FileTreeNode::Directory { files: root_files };
1086
1087 let quill = Quill::from_tree(root, None).unwrap();
1089
1090 assert_eq!(quill.template_file, Some("template.md".to_string()));
1092 assert_eq!(quill.template, Some(template_content.to_string()));
1093 }
1094
1095 #[test]
1096 fn test_from_json() {
1097 let json_str = r#"{
1099 "metadata": {
1100 "name": "test-from-json"
1101 },
1102 "files": {
1103 "Quill.toml": {
1104 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1105 },
1106 "glue.typ": {
1107 "contents": "= Test Glue\n\nThis is test content."
1108 }
1109 }
1110 }"#;
1111
1112 let quill = Quill::from_json(json_str).unwrap();
1114
1115 assert_eq!(quill.name, "test-from-json");
1117 assert_eq!(quill.glue_file, "glue.typ");
1118 assert!(quill.glue_template.contains("Test Glue"));
1119 assert!(quill.metadata.contains_key("backend"));
1120 }
1121
1122 #[test]
1123 fn test_from_json_with_byte_array() {
1124 let json_str = r#"{
1126 "files": {
1127 "Quill.toml": {
1128 "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]
1129 },
1130 "glue.typ": {
1131 "contents": "test glue"
1132 }
1133 }
1134 }"#;
1135
1136 let quill = Quill::from_json(json_str).unwrap();
1138
1139 assert_eq!(quill.name, "test");
1141 assert_eq!(quill.glue_file, "glue.typ");
1142 }
1143
1144 #[test]
1145 fn test_from_json_missing_files() {
1146 let json_str = r#"{
1148 "metadata": {
1149 "name": "test"
1150 }
1151 }"#;
1152
1153 let result = Quill::from_json(json_str);
1154 assert!(result.is_err());
1155 assert!(result.unwrap_err().to_string().contains("files"));
1157 }
1158
1159 #[test]
1160 fn test_from_json_tree_structure() {
1161 let json_str = r#"{
1163 "files": {
1164 "Quill.toml": {
1165 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1166 },
1167 "glue.typ": {
1168 "contents": "= Test Glue\n\nTree structure content."
1169 }
1170 }
1171 }"#;
1172
1173 let quill = Quill::from_json(json_str).unwrap();
1174
1175 assert_eq!(quill.name, "test-tree-json");
1176 assert!(quill.glue_template.contains("Tree structure content"));
1177 assert!(quill.metadata.contains_key("backend"));
1178 }
1179
1180 #[test]
1181 fn test_from_json_nested_tree_structure() {
1182 let json_str = r#"{
1184 "files": {
1185 "Quill.toml": {
1186 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1187 },
1188 "glue.typ": {
1189 "contents": "glue"
1190 },
1191 "src": {
1192 "main.rs": {
1193 "contents": "fn main() {}"
1194 },
1195 "lib.rs": {
1196 "contents": "// lib"
1197 }
1198 }
1199 }
1200 }"#;
1201
1202 let quill = Quill::from_json(json_str).unwrap();
1203
1204 assert_eq!(quill.name, "nested-test");
1205 assert!(quill.file_exists("src/main.rs"));
1207 assert!(quill.file_exists("src/lib.rs"));
1208
1209 let main_rs = quill.get_file("src/main.rs").unwrap();
1210 assert_eq!(main_rs, b"fn main() {}");
1211 }
1212
1213 #[test]
1214 fn test_from_tree_structure_direct() {
1215 let mut root_files = HashMap::new();
1217
1218 root_files.insert(
1219 "Quill.toml".to_string(),
1220 FileTreeNode::File {
1221 contents:
1222 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1223 .to_vec(),
1224 },
1225 );
1226
1227 root_files.insert(
1228 "glue.typ".to_string(),
1229 FileTreeNode::File {
1230 contents: b"glue content".to_vec(),
1231 },
1232 );
1233
1234 let mut src_files = HashMap::new();
1236 src_files.insert(
1237 "main.rs".to_string(),
1238 FileTreeNode::File {
1239 contents: b"fn main() {}".to_vec(),
1240 },
1241 );
1242
1243 root_files.insert(
1244 "src".to_string(),
1245 FileTreeNode::Directory { files: src_files },
1246 );
1247
1248 let root = FileTreeNode::Directory { files: root_files };
1249
1250 let quill = Quill::from_tree(root, None).unwrap();
1251
1252 assert_eq!(quill.name, "direct-tree");
1253 assert!(quill.file_exists("src/main.rs"));
1254 assert!(quill.file_exists("glue.typ"));
1255 }
1256
1257 #[test]
1258 fn test_from_json_with_metadata_override() {
1259 let json_str = r#"{
1261 "metadata": {
1262 "name": "override-name"
1263 },
1264 "files": {
1265 "Quill.toml": {
1266 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1267 },
1268 "glue.typ": {
1269 "contents": "= glue"
1270 }
1271 }
1272 }"#;
1273
1274 let quill = Quill::from_json(json_str).unwrap();
1275 assert_eq!(quill.name, "toml-name");
1278 }
1279
1280 #[test]
1281 fn test_from_json_empty_directory() {
1282 let json_str = r#"{
1284 "files": {
1285 "Quill.toml": {
1286 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1287 },
1288 "glue.typ": {
1289 "contents": "glue"
1290 },
1291 "empty_dir": {}
1292 }
1293 }"#;
1294
1295 let quill = Quill::from_json(json_str).unwrap();
1296 assert_eq!(quill.name, "empty-dir-test");
1297 assert!(quill.dir_exists("empty_dir"));
1298 assert!(!quill.file_exists("empty_dir"));
1299 }
1300
1301 #[test]
1302 fn test_dir_exists_and_list_apis() {
1303 let mut root_files = HashMap::new();
1304
1305 root_files.insert(
1307 "Quill.toml".to_string(),
1308 FileTreeNode::File {
1309 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1310 .to_vec(),
1311 },
1312 );
1313
1314 root_files.insert(
1316 "glue.typ".to_string(),
1317 FileTreeNode::File {
1318 contents: b"glue content".to_vec(),
1319 },
1320 );
1321
1322 let mut assets_files = HashMap::new();
1324 assets_files.insert(
1325 "logo.png".to_string(),
1326 FileTreeNode::File {
1327 contents: vec![137, 80, 78, 71],
1328 },
1329 );
1330 assets_files.insert(
1331 "icon.svg".to_string(),
1332 FileTreeNode::File {
1333 contents: b"<svg></svg>".to_vec(),
1334 },
1335 );
1336
1337 let mut fonts_files = HashMap::new();
1339 fonts_files.insert(
1340 "font.ttf".to_string(),
1341 FileTreeNode::File {
1342 contents: b"font data".to_vec(),
1343 },
1344 );
1345 assets_files.insert(
1346 "fonts".to_string(),
1347 FileTreeNode::Directory { files: fonts_files },
1348 );
1349
1350 root_files.insert(
1351 "assets".to_string(),
1352 FileTreeNode::Directory {
1353 files: assets_files,
1354 },
1355 );
1356
1357 root_files.insert(
1359 "empty".to_string(),
1360 FileTreeNode::Directory {
1361 files: HashMap::new(),
1362 },
1363 );
1364
1365 let root = FileTreeNode::Directory { files: root_files };
1366 let quill = Quill::from_tree(root, None).unwrap();
1367
1368 assert!(quill.dir_exists("assets"));
1370 assert!(quill.dir_exists("assets/fonts"));
1371 assert!(quill.dir_exists("empty"));
1372 assert!(!quill.dir_exists("nonexistent"));
1373 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1377 assert!(quill.file_exists("assets/logo.png"));
1378 assert!(quill.file_exists("assets/fonts/font.ttf"));
1379 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1383 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1385 assert!(root_files_list.contains(&"glue.typ".to_string()));
1386
1387 let assets_files_list = quill.list_files("assets");
1388 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1390 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1391
1392 let root_subdirs = quill.list_subdirectories("");
1394 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1396 assert!(root_subdirs.contains(&"empty".to_string()));
1397
1398 let assets_subdirs = quill.list_subdirectories("assets");
1399 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1401
1402 let empty_subdirs = quill.list_subdirectories("empty");
1403 assert_eq!(empty_subdirs.len(), 0);
1404 }
1405}