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(files_obj) = value.get("files").and_then(|v| v.as_object()) {
184 let mut files = HashMap::new();
186 for (name, child_value) in files_obj {
187 files.insert(name.clone(), Self::from_json_value(child_value)?);
188 }
189 Ok(FileTreeNode::Directory { files })
190 } else if let Some(obj) = value.as_object() {
191 let mut files = HashMap::new();
193 for (name, child_value) in obj {
194 if name == "is_dir" {
196 continue;
197 }
198 files.insert(name.clone(), Self::from_json_value(child_value)?);
199 }
200 if files.is_empty() {
201 return Err("Empty directory or invalid file node".into());
202 }
203 Ok(FileTreeNode::Directory { files })
204 } else {
205 Err(format!("Invalid file tree node: {:?}", value).into())
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
212pub struct QuillIgnore {
213 patterns: Vec<String>,
214}
215
216impl QuillIgnore {
217 pub fn new(patterns: Vec<String>) -> Self {
219 Self { patterns }
220 }
221
222 pub fn from_content(content: &str) -> Self {
224 let patterns = content
225 .lines()
226 .map(|line| line.trim())
227 .filter(|line| !line.is_empty() && !line.starts_with('#'))
228 .map(|line| line.to_string())
229 .collect();
230 Self::new(patterns)
231 }
232
233 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
235 let path = path.as_ref();
236 let path_str = path.to_string_lossy();
237
238 for pattern in &self.patterns {
239 if self.matches_pattern(pattern, &path_str) {
240 return true;
241 }
242 }
243 false
244 }
245
246 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
248 if pattern.ends_with('/') {
250 let pattern_prefix = &pattern[..pattern.len() - 1];
251 return path.starts_with(pattern_prefix)
252 && (path.len() == pattern_prefix.len()
253 || path.chars().nth(pattern_prefix.len()) == Some('/'));
254 }
255
256 if !pattern.contains('*') {
258 return path == pattern || path.ends_with(&format!("/{}", pattern));
259 }
260
261 if pattern == "*" {
263 return true;
264 }
265
266 let pattern_parts: Vec<&str> = pattern.split('*').collect();
268 if pattern_parts.len() == 2 {
269 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
270 if prefix.is_empty() {
271 return path.ends_with(suffix);
272 } else if suffix.is_empty() {
273 return path.starts_with(prefix);
274 } else {
275 return path.starts_with(prefix) && path.ends_with(suffix);
276 }
277 }
278
279 false
280 }
281}
282
283#[derive(Debug, Clone)]
285pub struct Quill {
286 pub glue_template: String,
288 pub metadata: HashMap<String, serde_yaml::Value>,
290 pub base_path: PathBuf,
292 pub name: String,
294 pub glue_file: String,
296 pub template_file: Option<String>,
298 pub template: Option<String>,
300 pub files: FileTreeNode,
302}
303
304impl Quill {
305 pub fn from_path<P: AsRef<std::path::Path>>(
307 path: P,
308 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
309 use std::fs;
310
311 let path = path.as_ref();
312 let name = path
313 .file_name()
314 .and_then(|n| n.to_str())
315 .unwrap_or("unnamed")
316 .to_string();
317
318 let quillignore_path = path.join(".quillignore");
320 let ignore = if quillignore_path.exists() {
321 let ignore_content = fs::read_to_string(&quillignore_path)
322 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
323 QuillIgnore::from_content(&ignore_content)
324 } else {
325 QuillIgnore::new(vec![
327 ".git/".to_string(),
328 ".gitignore".to_string(),
329 ".quillignore".to_string(),
330 "target/".to_string(),
331 "node_modules/".to_string(),
332 ])
333 };
334
335 let root = Self::load_directory_as_tree(path, path, &ignore)?;
337
338 Self::from_tree(root, Some(path.to_path_buf()), Some(name))
340 }
341
342 pub fn from_tree(
360 root: FileTreeNode,
361 base_path: Option<PathBuf>,
362 default_name: Option<String>,
363 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
364 let quill_toml_bytes = root
366 .get_file("Quill.toml")
367 .ok_or("Quill.toml not found in file tree")?;
368
369 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
370 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
371
372 let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
373 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
374
375 let mut metadata = HashMap::new();
376 let mut glue_file = "glue.typ".to_string(); let mut template_file: Option<String> = None;
378 let mut quill_name = default_name.unwrap_or_else(|| "unnamed".to_string());
379
380 if let Some(quill_section) = quill_toml.get("Quill") {
382 if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
384 quill_name = name_val.to_string();
385 }
386
387 if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
388 match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
389 Ok(yaml_value) => {
390 metadata.insert("backend".to_string(), yaml_value);
391 }
392 Err(e) => {
393 eprintln!("Warning: Failed to convert backend field: {}", e);
394 }
395 }
396 }
397
398 if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
399 glue_file = glue_val.to_string();
400 }
401
402 if let Some(template_val) = quill_section.get("template").and_then(|v| v.as_str()) {
403 template_file = Some(template_val.to_string());
404 }
405
406 if let toml::Value::Table(table) = quill_section {
408 for (key, value) in table {
409 if key != "name"
410 && key != "backend"
411 && key != "glue"
412 && key != "template"
413 && key != "version"
414 {
415 match Self::toml_to_yaml_value(value) {
416 Ok(yaml_value) => {
417 metadata.insert(key.clone(), yaml_value);
418 }
419 Err(e) => {
420 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
421 }
422 }
423 }
424 }
425 }
426 }
427
428 if let Some(typst_section) = quill_toml.get("typst") {
430 if let toml::Value::Table(table) = typst_section {
431 for (key, value) in table {
432 match Self::toml_to_yaml_value(value) {
433 Ok(yaml_value) => {
434 metadata.insert(format!("typst_{}", key), yaml_value);
435 }
436 Err(e) => {
437 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
438 }
439 }
440 }
441 }
442 }
443
444 let glue_bytes = root
446 .get_file(&glue_file)
447 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file))?;
448
449 let template_content = String::from_utf8(glue_bytes.to_vec())
450 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file, e))?;
451
452 let template_content_opt = if let Some(ref template_file_name) = template_file {
454 root.get_file(template_file_name).and_then(|bytes| {
455 String::from_utf8(bytes.to_vec())
456 .map_err(|e| {
457 eprintln!(
458 "Warning: Template file '{}' is not valid UTF-8: {}",
459 template_file_name, e
460 );
461 e
462 })
463 .ok()
464 })
465 } else {
466 None
467 };
468
469 let quill = Quill {
470 glue_template: template_content,
471 metadata,
472 base_path: base_path.unwrap_or_else(|| PathBuf::from("/")),
473 name: quill_name,
474 glue_file,
475 template_file,
476 template: template_content_opt,
477 files: root,
478 };
479
480 quill.validate()?;
482
483 Ok(quill)
484 }
485
486 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
493 use serde_json::Value as JsonValue;
494
495 let json: JsonValue =
496 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
497
498 let base_path = json
500 .get("base_path")
501 .and_then(|v| v.as_str())
502 .map(PathBuf::from);
503
504 let default_name = json.get("name").and_then(|v| v.as_str()).map(String::from);
505
506 let mut root_files = HashMap::new();
511
512 if let JsonValue::Object(obj) = &json {
513 for (key, value) in obj {
514 if key == "name" || key == "base_path" {
516 continue;
517 }
518 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
519 }
520 } else {
521 return Err("JSON root must be an object".into());
522 }
523
524 let root = FileTreeNode::Directory { files: root_files };
525
526 Self::from_tree(root, base_path, default_name)
528 }
529
530 fn load_directory_as_tree(
532 current_dir: &Path,
533 base_dir: &Path,
534 ignore: &QuillIgnore,
535 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
536 use std::fs;
537
538 if !current_dir.exists() {
539 return Ok(FileTreeNode::Directory {
540 files: HashMap::new(),
541 });
542 }
543
544 let mut files = HashMap::new();
545
546 for entry in fs::read_dir(current_dir)? {
547 let entry = entry?;
548 let path = entry.path();
549 let relative_path = path
550 .strip_prefix(base_dir)
551 .map_err(|e| format!("Failed to get relative path: {}", e))?
552 .to_path_buf();
553
554 if ignore.is_ignored(&relative_path) {
556 continue;
557 }
558
559 let filename = path
561 .file_name()
562 .and_then(|n| n.to_str())
563 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
564 .to_string();
565
566 if path.is_file() {
567 let contents = fs::read(&path)
568 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
569
570 files.insert(filename, FileTreeNode::File { contents });
571 } else if path.is_dir() {
572 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
574 files.insert(filename, subdir_tree);
575 }
576 }
577
578 Ok(FileTreeNode::Directory { files })
579 }
580
581 pub fn toml_to_yaml_value(
583 toml_val: &toml::Value,
584 ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
585 let json_val = serde_json::to_value(toml_val)?;
586 let yaml_val = serde_yaml::to_value(json_val)?;
587 Ok(yaml_val)
588 }
589
590 pub fn assets_path(&self) -> PathBuf {
592 self.base_path.join("assets")
593 }
594
595 pub fn packages_path(&self) -> PathBuf {
597 self.base_path.join("packages")
598 }
599
600 pub fn glue_path(&self) -> PathBuf {
602 self.base_path.join(&self.glue_file)
603 }
604
605 pub fn typst_packages(&self) -> Vec<String> {
607 self.metadata
608 .get("typst_packages")
609 .and_then(|v| v.as_sequence())
610 .map(|seq| {
611 seq.iter()
612 .filter_map(|v| v.as_str().map(|s| s.to_string()))
613 .collect()
614 })
615 .unwrap_or_default()
616 }
617
618 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
620 if !self.files.file_exists(&self.glue_file) {
622 return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
623 }
624 Ok(())
625 }
626
627 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
629 self.files.get_file(path)
630 }
631
632 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
634 self.files.file_exists(path)
635 }
636
637 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
639 let dir_path = dir_path.as_ref();
640 let filenames = self.files.list_files(dir_path);
641
642 filenames
644 .iter()
645 .map(|name| {
646 if dir_path == Path::new("") {
647 PathBuf::from(name)
648 } else {
649 dir_path.join(name)
650 }
651 })
652 .collect()
653 }
654
655 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
657 let dir_path = dir_path.as_ref();
658 let subdirs = self.files.list_subdirectories(dir_path);
659
660 subdirs
662 .iter()
663 .map(|name| {
664 if dir_path == Path::new("") {
665 PathBuf::from(name)
666 } else {
667 dir_path.join(name)
668 }
669 })
670 .collect()
671 }
672
673 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
675 let pattern_str = pattern.as_ref().to_string_lossy();
676 let mut matches = Vec::new();
677
678 self.find_files_recursive(&self.files, Path::new(""), &pattern_str, &mut matches);
680
681 matches.sort();
682 matches
683 }
684
685 fn find_files_recursive(
687 &self,
688 node: &FileTreeNode,
689 current_path: &Path,
690 pattern: &str,
691 matches: &mut Vec<PathBuf>,
692 ) {
693 match node {
694 FileTreeNode::File { .. } => {
695 let path_str = current_path.to_string_lossy();
696 if self.matches_simple_pattern(pattern, &path_str) {
697 matches.push(current_path.to_path_buf());
698 }
699 }
700 FileTreeNode::Directory { files } => {
701 for (name, child_node) in files {
702 let child_path = if current_path == Path::new("") {
703 PathBuf::from(name)
704 } else {
705 current_path.join(name)
706 };
707 self.find_files_recursive(child_node, &child_path, pattern, matches);
708 }
709 }
710 }
711 }
712
713 fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
715 if pattern == "*" {
716 return true;
717 }
718
719 if !pattern.contains('*') {
720 return path == pattern;
721 }
722
723 if pattern.ends_with("/*") {
725 let dir_pattern = &pattern[..pattern.len() - 2];
726 return path.starts_with(&format!("{}/", dir_pattern));
727 }
728
729 let parts: Vec<&str> = pattern.split('*').collect();
730 if parts.len() == 2 {
731 let (prefix, suffix) = (parts[0], parts[1]);
732 if prefix.is_empty() {
733 return path.ends_with(suffix);
734 } else if suffix.is_empty() {
735 return path.starts_with(prefix);
736 } else {
737 return path.starts_with(prefix) && path.ends_with(suffix);
738 }
739 }
740
741 false
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use std::fs;
749 use tempfile::TempDir;
750
751 #[test]
752 fn test_quillignore_parsing() {
753 let ignore_content = r#"
754# This is a comment
755*.tmp
756target/
757node_modules/
758.git/
759"#;
760 let ignore = QuillIgnore::from_content(ignore_content);
761 assert_eq!(ignore.patterns.len(), 4);
762 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
763 assert!(ignore.patterns.contains(&"target/".to_string()));
764 }
765
766 #[test]
767 fn test_quillignore_matching() {
768 let ignore = QuillIgnore::new(vec![
769 "*.tmp".to_string(),
770 "target/".to_string(),
771 "node_modules/".to_string(),
772 ".git/".to_string(),
773 ]);
774
775 assert!(ignore.is_ignored("test.tmp"));
777 assert!(ignore.is_ignored("path/to/file.tmp"));
778 assert!(!ignore.is_ignored("test.txt"));
779
780 assert!(ignore.is_ignored("target"));
782 assert!(ignore.is_ignored("target/debug"));
783 assert!(ignore.is_ignored("target/debug/deps"));
784 assert!(!ignore.is_ignored("src/target.rs"));
785
786 assert!(ignore.is_ignored("node_modules"));
787 assert!(ignore.is_ignored("node_modules/package"));
788 assert!(!ignore.is_ignored("my_node_modules"));
789 }
790
791 #[test]
792 fn test_in_memory_file_system() {
793 let temp_dir = TempDir::new().unwrap();
794 let quill_dir = temp_dir.path();
795
796 fs::write(
798 quill_dir.join("Quill.toml"),
799 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
800 )
801 .unwrap();
802 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
803
804 let assets_dir = quill_dir.join("assets");
805 fs::create_dir_all(&assets_dir).unwrap();
806 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
807
808 let packages_dir = quill_dir.join("packages");
809 fs::create_dir_all(&packages_dir).unwrap();
810 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
811
812 let quill = Quill::from_path(quill_dir).unwrap();
814
815 assert!(quill.file_exists("glue.typ"));
817 assert!(quill.file_exists("assets/test.txt"));
818 assert!(quill.file_exists("packages/package.typ"));
819 assert!(!quill.file_exists("nonexistent.txt"));
820
821 let asset_content = quill.get_file("assets/test.txt").unwrap();
823 assert_eq!(asset_content, b"asset content");
824
825 let asset_files = quill.list_directory("assets");
827 assert_eq!(asset_files.len(), 1);
828 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
829 }
830
831 #[test]
832 fn test_quillignore_integration() {
833 let temp_dir = TempDir::new().unwrap();
834 let quill_dir = temp_dir.path();
835
836 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
838
839 fs::write(
841 quill_dir.join("Quill.toml"),
842 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
843 )
844 .unwrap();
845 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
846 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
847
848 let target_dir = quill_dir.join("target");
849 fs::create_dir_all(&target_dir).unwrap();
850 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
851
852 let quill = Quill::from_path(quill_dir).unwrap();
854
855 assert!(quill.file_exists("glue.typ"));
857 assert!(!quill.file_exists("should_ignore.tmp"));
858 assert!(!quill.file_exists("target/debug.txt"));
859 }
860
861 #[test]
862 fn test_find_files_pattern() {
863 let temp_dir = TempDir::new().unwrap();
864 let quill_dir = temp_dir.path();
865
866 fs::write(
868 quill_dir.join("Quill.toml"),
869 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
870 )
871 .unwrap();
872 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
873
874 let assets_dir = quill_dir.join("assets");
875 fs::create_dir_all(&assets_dir).unwrap();
876 fs::write(assets_dir.join("image.png"), "png data").unwrap();
877 fs::write(assets_dir.join("data.json"), "json data").unwrap();
878
879 let fonts_dir = assets_dir.join("fonts");
880 fs::create_dir_all(&fonts_dir).unwrap();
881 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
882
883 let quill = Quill::from_path(quill_dir).unwrap();
885
886 let all_assets = quill.find_files("assets/*");
888 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
891 assert_eq!(typ_files.len(), 1);
892 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
893 }
894
895 #[test]
896 fn test_new_standardized_toml_format() {
897 let temp_dir = TempDir::new().unwrap();
898 let quill_dir = temp_dir.path();
899
900 let toml_content = r#"[Quill]
902name = "my-custom-quill"
903backend = "typst"
904glue = "custom_glue.typ"
905description = "Test quill with new format"
906author = "Test Author"
907"#;
908 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
909 fs::write(
910 quill_dir.join("custom_glue.typ"),
911 "= Custom Template\n\nThis is a custom template.",
912 )
913 .unwrap();
914
915 let quill = Quill::from_path(quill_dir).unwrap();
917
918 assert_eq!(quill.name, "my-custom-quill");
920
921 assert_eq!(quill.glue_file, "custom_glue.typ");
923
924 assert!(quill.metadata.contains_key("backend"));
926 if let Some(backend_val) = quill.metadata.get("backend") {
927 if let Some(backend_str) = backend_val.as_str() {
928 assert_eq!(backend_str, "typst");
929 } else {
930 panic!("Backend value is not a string");
931 }
932 }
933
934 assert!(quill.metadata.contains_key("description"));
936 assert!(quill.metadata.contains_key("author"));
937 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue_template.contains("Custom Template"));
941 assert!(quill.glue_template.contains("custom template"));
942 }
943
944 #[test]
945 fn test_typst_packages_parsing() {
946 let temp_dir = TempDir::new().unwrap();
947 let quill_dir = temp_dir.path();
948
949 let toml_content = r#"
950[Quill]
951name = "test-quill"
952backend = "typst"
953glue = "glue.typ"
954
955[typst]
956packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
957"#;
958
959 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
960 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
961
962 let quill = Quill::from_path(quill_dir).unwrap();
963 let packages = quill.typst_packages();
964
965 assert_eq!(packages.len(), 2);
966 assert_eq!(packages[0], "@preview/bubble:0.2.2");
967 assert_eq!(packages[1], "@preview/example:1.0.0");
968 }
969
970 #[test]
971 fn test_template_loading() {
972 let temp_dir = TempDir::new().unwrap();
973 let quill_dir = temp_dir.path();
974
975 let toml_content = r#"[Quill]
977name = "test-with-template"
978backend = "typst"
979glue = "glue.typ"
980template = "example.md"
981"#;
982 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
983 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
984 fs::write(
985 quill_dir.join("example.md"),
986 "---\ntitle: Test\n---\n\nThis is a test template.",
987 )
988 .unwrap();
989
990 let quill = Quill::from_path(quill_dir).unwrap();
992
993 assert_eq!(quill.template_file, Some("example.md".to_string()));
995
996 assert!(quill.template.is_some());
998 let template = quill.template.unwrap();
999 assert!(template.contains("title: Test"));
1000 assert!(template.contains("This is a test template"));
1001
1002 assert_eq!(quill.glue_template, "glue content");
1004 }
1005
1006 #[test]
1007 fn test_template_optional() {
1008 let temp_dir = TempDir::new().unwrap();
1009 let quill_dir = temp_dir.path();
1010
1011 let toml_content = r#"[Quill]
1013name = "test-without-template"
1014backend = "typst"
1015glue = "glue.typ"
1016"#;
1017 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1018 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1019
1020 let quill = Quill::from_path(quill_dir).unwrap();
1022
1023 assert_eq!(quill.template_file, None);
1025 assert_eq!(quill.template, None);
1026
1027 assert_eq!(quill.glue_template, "glue content");
1029 }
1030
1031 #[test]
1032 fn test_from_tree() {
1033 let mut root_files = HashMap::new();
1035
1036 let quill_toml = r#"[Quill]
1038name = "test-from-tree"
1039backend = "typst"
1040glue = "glue.typ"
1041description = "A test quill from tree"
1042"#;
1043 root_files.insert(
1044 "Quill.toml".to_string(),
1045 FileTreeNode::File {
1046 contents: quill_toml.as_bytes().to_vec(),
1047 },
1048 );
1049
1050 let glue_content = "= Test Template\n\nThis is a test.";
1052 root_files.insert(
1053 "glue.typ".to_string(),
1054 FileTreeNode::File {
1055 contents: glue_content.as_bytes().to_vec(),
1056 },
1057 );
1058
1059 let root = FileTreeNode::Directory { files: root_files };
1060
1061 let quill = Quill::from_tree(root, Some(PathBuf::from("/test")), None).unwrap();
1063
1064 assert_eq!(quill.name, "test-from-tree");
1066 assert_eq!(quill.glue_file, "glue.typ");
1067 assert_eq!(quill.glue_template, glue_content);
1068 assert_eq!(quill.base_path, PathBuf::from("/test"));
1069 assert!(quill.metadata.contains_key("backend"));
1070 assert!(quill.metadata.contains_key("description"));
1071 }
1072
1073 #[test]
1074 fn test_from_tree_with_template() {
1075 let mut root_files = HashMap::new();
1076
1077 let quill_toml = r#"[Quill]
1079name = "test-tree-template"
1080backend = "typst"
1081glue = "glue.typ"
1082template = "template.md"
1083"#;
1084 root_files.insert(
1085 "Quill.toml".to_string(),
1086 FileTreeNode::File {
1087 contents: quill_toml.as_bytes().to_vec(),
1088 },
1089 );
1090
1091 root_files.insert(
1093 "glue.typ".to_string(),
1094 FileTreeNode::File {
1095 contents: b"glue content".to_vec(),
1096 },
1097 );
1098
1099 let template_content = "# {{ title }}\n\n{{ body }}";
1101 root_files.insert(
1102 "template.md".to_string(),
1103 FileTreeNode::File {
1104 contents: template_content.as_bytes().to_vec(),
1105 },
1106 );
1107
1108 let root = FileTreeNode::Directory { files: root_files };
1109
1110 let quill = Quill::from_tree(root, None, None).unwrap();
1112
1113 assert_eq!(quill.template_file, Some("template.md".to_string()));
1115 assert_eq!(quill.template, Some(template_content.to_string()));
1116 }
1117
1118 #[test]
1119 fn test_from_json() {
1120 let json_str = r#"{
1122 "name": "test-from-json",
1123 "base_path": "/test/path",
1124 "Quill.toml": {
1125 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1126 },
1127 "glue.typ": {
1128 "contents": "= Test Glue\n\nThis is test content."
1129 }
1130 }"#;
1131
1132 let quill = Quill::from_json(json_str).unwrap();
1134
1135 assert_eq!(quill.name, "test-from-json");
1137 assert_eq!(quill.base_path, PathBuf::from("/test/path"));
1138 assert_eq!(quill.glue_file, "glue.typ");
1139 assert!(quill.glue_template.contains("Test Glue"));
1140 assert!(quill.metadata.contains_key("backend"));
1141 }
1142
1143 #[test]
1144 fn test_from_json_with_byte_array() {
1145 let json_str = r#"{
1147 "Quill.toml": {
1148 "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]
1149 },
1150 "glue.typ": {
1151 "contents": "test glue"
1152 }
1153 }"#;
1154
1155 let quill = Quill::from_json(json_str).unwrap();
1157
1158 assert_eq!(quill.name, "test");
1160 assert_eq!(quill.glue_file, "glue.typ");
1161 }
1162
1163 #[test]
1164 fn test_from_json_missing_files() {
1165 let json_str = r#"{
1167 "name": "test"
1168 }"#;
1169
1170 let result = Quill::from_json(json_str);
1171 assert!(result.is_err());
1172 assert!(result.is_err());
1175 }
1176
1177 #[test]
1178 fn test_from_json_tree_structure() {
1179 let json_str = r#"{
1181 "name": "test-tree-json",
1182 "base_path": "/test",
1183 "Quill.toml": {
1184 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1185 },
1186 "glue.typ": {
1187 "contents": "= Test Glue\n\nTree structure content."
1188 }
1189 }"#;
1190
1191 let quill = Quill::from_json(json_str).unwrap();
1192
1193 assert_eq!(quill.name, "test-tree-json");
1194 assert_eq!(quill.base_path, PathBuf::from("/test"));
1195 assert!(quill.glue_template.contains("Tree structure content"));
1196 assert!(quill.metadata.contains_key("backend"));
1197 }
1198
1199 #[test]
1200 fn test_from_json_nested_tree_structure() {
1201 let json_str = r#"{
1203 "Quill.toml": {
1204 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1205 },
1206 "glue.typ": {
1207 "contents": "glue"
1208 },
1209 "src": {
1210 "files": {
1211 "main.rs": {
1212 "contents": "fn main() {}"
1213 },
1214 "lib.rs": {
1215 "contents": "// lib"
1216 }
1217 }
1218 }
1219 }"#;
1220
1221 let quill = Quill::from_json(json_str).unwrap();
1222
1223 assert_eq!(quill.name, "nested-test");
1224 assert!(quill.file_exists("src/main.rs"));
1226 assert!(quill.file_exists("src/lib.rs"));
1227
1228 let main_rs = quill.get_file("src/main.rs").unwrap();
1229 assert_eq!(main_rs, b"fn main() {}");
1230 }
1231
1232 #[test]
1233 fn test_from_tree_structure_direct() {
1234 let mut root_files = HashMap::new();
1236
1237 root_files.insert(
1238 "Quill.toml".to_string(),
1239 FileTreeNode::File {
1240 contents:
1241 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n"
1242 .to_vec(),
1243 },
1244 );
1245
1246 root_files.insert(
1247 "glue.typ".to_string(),
1248 FileTreeNode::File {
1249 contents: b"glue content".to_vec(),
1250 },
1251 );
1252
1253 let mut src_files = HashMap::new();
1255 src_files.insert(
1256 "main.rs".to_string(),
1257 FileTreeNode::File {
1258 contents: b"fn main() {}".to_vec(),
1259 },
1260 );
1261
1262 root_files.insert(
1263 "src".to_string(),
1264 FileTreeNode::Directory { files: src_files },
1265 );
1266
1267 let root = FileTreeNode::Directory { files: root_files };
1268
1269 let quill = Quill::from_tree(root, None, None).unwrap();
1270
1271 assert_eq!(quill.name, "direct-tree");
1272 assert!(quill.file_exists("src/main.rs"));
1273 assert!(quill.file_exists("glue.typ"));
1274 }
1275
1276 #[test]
1277 fn test_from_json_files_wrapper_rejected() {
1278 let json_str = r#"{
1282 "files": {
1283 "Quill.toml": { "contents": "[Quill]\nname = \"wrap\"\nbackend = \"typst\"\nglue = \"glue.typ\"\n" },
1284 "glue.typ": { "contents": "= glue" }
1285 }
1286 }"#;
1287
1288 let result = Quill::from_json(json_str);
1289 assert!(result.is_err(), "legacy files wrapper should be rejected");
1290 }
1291}