1use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use crate::value::QuillValue;
8
9#[derive(Debug, Clone, PartialEq)]
11pub struct FieldSchema {
12 pub r#type: Option<String>,
14 pub required: bool,
16 pub description: String,
18 pub example: Option<QuillValue>,
20 pub default: Option<QuillValue>,
22}
23
24impl FieldSchema {
25 pub fn new(description: String) -> Self {
27 Self {
28 r#type: None,
29 required: false,
30 description,
31 example: None,
32 default: None,
33 }
34 }
35
36 pub fn from_quill_value(value: &QuillValue) -> Result<Self, String> {
38 let obj = value
39 .as_object()
40 .ok_or_else(|| "Field schema must be an object".to_string())?;
41
42 let description = obj
43 .get("description")
44 .and_then(|v| v.as_str())
45 .unwrap_or("")
46 .to_string();
47
48 let required = obj
49 .get("required")
50 .and_then(|v| v.as_bool())
51 .unwrap_or(false);
52
53 let field_type = obj
54 .get("type")
55 .and_then(|v| v.as_str())
56 .map(|s| s.to_string());
57
58 let example = obj.get("example").map(|v| QuillValue::from_json(v.clone()));
59
60 let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
61
62 Ok(Self {
63 r#type: field_type,
64 required,
65 description,
66 example,
67 default,
68 })
69 }
70
71 pub fn to_quill_value(&self) -> QuillValue {
73 let mut map = serde_json::Map::new();
74
75 map.insert(
76 "description".to_string(),
77 serde_json::Value::String(self.description.clone()),
78 );
79
80 map.insert(
81 "required".to_string(),
82 serde_json::Value::Bool(self.required),
83 );
84
85 if let Some(ref field_type) = self.r#type {
86 map.insert(
87 "type".to_string(),
88 serde_json::Value::String(field_type.clone()),
89 );
90 }
91
92 if let Some(ref example) = self.example {
93 map.insert("example".to_string(), example.as_json().clone());
94 }
95
96 if let Some(ref default) = self.default {
97 map.insert("default".to_string(), default.as_json().clone());
98 }
99
100 QuillValue::from_json(serde_json::Value::Object(map))
101 }
102}
103
104#[derive(Debug, Clone)]
106pub enum FileTreeNode {
107 File {
109 contents: Vec<u8>,
111 },
112 Directory {
114 files: HashMap<String, FileTreeNode>,
116 },
117}
118
119impl FileTreeNode {
120 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
122 let path = path.as_ref();
123
124 if path == Path::new("") {
126 return Some(self);
127 }
128
129 let components: Vec<_> = path
131 .components()
132 .filter_map(|c| {
133 if let std::path::Component::Normal(s) = c {
134 s.to_str()
135 } else {
136 None
137 }
138 })
139 .collect();
140
141 if components.is_empty() {
142 return Some(self);
143 }
144
145 let mut current_node = self;
147 for component in components {
148 match current_node {
149 FileTreeNode::Directory { files } => {
150 current_node = files.get(component)?;
151 }
152 FileTreeNode::File { .. } => {
153 return None; }
155 }
156 }
157
158 Some(current_node)
159 }
160
161 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
163 match self.get_node(path)? {
164 FileTreeNode::File { contents } => Some(contents.as_slice()),
165 FileTreeNode::Directory { .. } => None,
166 }
167 }
168
169 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
171 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
172 }
173
174 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
176 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
177 }
178
179 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
181 match self.get_node(dir_path) {
182 Some(FileTreeNode::Directory { files }) => files
183 .iter()
184 .filter_map(|(name, node)| {
185 if matches!(node, FileTreeNode::File { .. }) {
186 Some(name.clone())
187 } else {
188 None
189 }
190 })
191 .collect(),
192 _ => Vec::new(),
193 }
194 }
195
196 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
198 match self.get_node(dir_path) {
199 Some(FileTreeNode::Directory { files }) => files
200 .iter()
201 .filter_map(|(name, node)| {
202 if matches!(node, FileTreeNode::Directory { .. }) {
203 Some(name.clone())
204 } else {
205 None
206 }
207 })
208 .collect(),
209 _ => Vec::new(),
210 }
211 }
212
213 pub fn insert<P: AsRef<Path>>(
215 &mut self,
216 path: P,
217 node: FileTreeNode,
218 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
219 let path = path.as_ref();
220
221 let components: Vec<_> = path
223 .components()
224 .filter_map(|c| {
225 if let std::path::Component::Normal(s) = c {
226 s.to_str().map(|s| s.to_string())
227 } else {
228 None
229 }
230 })
231 .collect();
232
233 if components.is_empty() {
234 return Err("Cannot insert at root path".into());
235 }
236
237 let mut current_node = self;
239 for component in &components[..components.len() - 1] {
240 match current_node {
241 FileTreeNode::Directory { files } => {
242 current_node =
243 files
244 .entry(component.clone())
245 .or_insert_with(|| FileTreeNode::Directory {
246 files: HashMap::new(),
247 });
248 }
249 FileTreeNode::File { .. } => {
250 return Err("Cannot traverse into a file".into());
251 }
252 }
253 }
254
255 let filename = &components[components.len() - 1];
257 match current_node {
258 FileTreeNode::Directory { files } => {
259 files.insert(filename.clone(), node);
260 Ok(())
261 }
262 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
263 }
264 }
265
266 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
268 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
269 Ok(FileTreeNode::File {
271 contents: contents_str.as_bytes().to_vec(),
272 })
273 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
274 let contents: Vec<u8> = bytes_array
276 .iter()
277 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
278 .collect();
279 Ok(FileTreeNode::File { contents })
280 } else if let Some(obj) = value.as_object() {
281 let mut files = HashMap::new();
283 for (name, child_value) in obj {
284 files.insert(name.clone(), Self::from_json_value(child_value)?);
285 }
286 Ok(FileTreeNode::Directory { files })
288 } else {
289 Err(format!("Invalid file tree node: {:?}", value).into())
290 }
291 }
292}
293
294#[derive(Debug, Clone)]
296pub struct QuillIgnore {
297 patterns: Vec<String>,
298}
299
300impl QuillIgnore {
301 pub fn new(patterns: Vec<String>) -> Self {
303 Self { patterns }
304 }
305
306 pub fn from_content(content: &str) -> Self {
308 let patterns = content
309 .lines()
310 .map(|line| line.trim())
311 .filter(|line| !line.is_empty() && !line.starts_with('#'))
312 .map(|line| line.to_string())
313 .collect();
314 Self::new(patterns)
315 }
316
317 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
319 let path = path.as_ref();
320 let path_str = path.to_string_lossy();
321
322 for pattern in &self.patterns {
323 if self.matches_pattern(pattern, &path_str) {
324 return true;
325 }
326 }
327 false
328 }
329
330 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
332 if pattern.ends_with('/') {
334 let pattern_prefix = &pattern[..pattern.len() - 1];
335 return path.starts_with(pattern_prefix)
336 && (path.len() == pattern_prefix.len()
337 || path.chars().nth(pattern_prefix.len()) == Some('/'));
338 }
339
340 if !pattern.contains('*') {
342 return path == pattern || path.ends_with(&format!("/{}", pattern));
343 }
344
345 if pattern == "*" {
347 return true;
348 }
349
350 let pattern_parts: Vec<&str> = pattern.split('*').collect();
352 if pattern_parts.len() == 2 {
353 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
354 if prefix.is_empty() {
355 return path.ends_with(suffix);
356 } else if suffix.is_empty() {
357 return path.starts_with(prefix);
358 } else {
359 return path.starts_with(prefix) && path.ends_with(suffix);
360 }
361 }
362
363 false
364 }
365}
366
367#[derive(Debug, Clone)]
369pub struct Quill {
370 pub glue_template: String,
372 pub metadata: HashMap<String, QuillValue>,
374 pub name: String,
376 pub backend: String,
378 pub glue_file: Option<String>,
380 pub example: Option<String>,
382 pub field_schemas: HashMap<String, FieldSchema>,
384 pub files: FileTreeNode,
386}
387
388impl Quill {
389 pub fn from_path<P: AsRef<std::path::Path>>(
391 path: P,
392 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
393 use std::fs;
394
395 let path = path.as_ref();
396 let name = path
397 .file_name()
398 .and_then(|n| n.to_str())
399 .unwrap_or("unnamed")
400 .to_string();
401
402 let quillignore_path = path.join(".quillignore");
404 let ignore = if quillignore_path.exists() {
405 let ignore_content = fs::read_to_string(&quillignore_path)
406 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
407 QuillIgnore::from_content(&ignore_content)
408 } else {
409 QuillIgnore::new(vec![
411 ".git/".to_string(),
412 ".gitignore".to_string(),
413 ".quillignore".to_string(),
414 "target/".to_string(),
415 "node_modules/".to_string(),
416 ])
417 };
418
419 let root = Self::load_directory_as_tree(path, path, &ignore)?;
421
422 Self::from_tree(root, Some(name))
424 }
425
426 pub fn from_tree(
444 root: FileTreeNode,
445 default_name: Option<String>,
446 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
447 let quill_toml_bytes = root
449 .get_file("Quill.toml")
450 .ok_or("Quill.toml not found in file tree")?;
451
452 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
453 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
454
455 let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
456 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
457
458 let mut metadata = HashMap::new();
459 let mut glue_file: Option<String> = None;
460 let mut template_file: Option<String> = None;
461 let mut quill_name = default_name.unwrap_or_else(|| "unnamed".to_string());
462 let mut backend = String::new();
463 let mut field_schemas = HashMap::new();
464
465 if let Some(quill_section) = quill_toml.get("Quill") {
467 if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
469 quill_name = name_val.to_string();
470 }
471
472 if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
473 backend = backend_val.to_string();
474 match QuillValue::from_toml(&toml::Value::String(backend_val.to_string())) {
475 Ok(quill_value) => {
476 metadata.insert("backend".to_string(), quill_value);
477 }
478 Err(e) => {
479 eprintln!("Warning: Failed to convert backend field: {}", e);
480 }
481 }
482 }
483
484 if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
485 glue_file = Some(glue_val.to_string());
486 }
487
488 if let Some(example_val) = quill_section.get("example").and_then(|v| v.as_str()) {
489 template_file = Some(example_val.to_string());
490 }
491
492 let description = quill_section
494 .get("description")
495 .and_then(|v| v.as_str())
496 .ok_or("Missing required 'description' field in [Quill] section")?;
497
498 if description.trim().is_empty() {
499 return Err("'description' field in [Quill] section cannot be empty".into());
500 }
501
502 if let toml::Value::Table(table) = quill_section {
504 for (key, value) in table {
505 if key != "name"
506 && key != "backend"
507 && key != "glue"
508 && key != "example"
509 && key != "version"
510 {
511 match QuillValue::from_toml(value) {
512 Ok(quill_value) => {
513 metadata.insert(key.clone(), quill_value);
514 }
515 Err(e) => {
516 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
517 }
518 }
519 }
520 }
521 }
522 }
523
524 if let Some(typst_section) = quill_toml.get("typst") {
526 if let toml::Value::Table(table) = typst_section {
527 for (key, value) in table {
528 match QuillValue::from_toml(value) {
529 Ok(quill_value) => {
530 metadata.insert(format!("typst_{}", key), quill_value);
531 }
532 Err(e) => {
533 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
534 }
535 }
536 }
537 }
538 }
539
540 if let Some(fields_section) = quill_toml.get("fields") {
542 if let toml::Value::Table(fields_table) = fields_section {
543 for (field_name, field_schema) in fields_table {
544 match QuillValue::from_toml(field_schema) {
545 Ok(quill_value) => match FieldSchema::from_quill_value(&quill_value) {
546 Ok(schema) => {
547 field_schemas.insert(field_name.clone(), schema);
548 }
549 Err(e) => {
550 eprintln!(
551 "Warning: Failed to parse field schema '{}': {}",
552 field_name, e
553 );
554 }
555 },
556 Err(e) => {
557 eprintln!(
558 "Warning: Failed to convert field schema '{}': {}",
559 field_name, e
560 );
561 }
562 }
563 }
564 }
565 }
566
567 let template_content = if let Some(ref glue_file_name) = glue_file {
569 let glue_bytes = root
570 .get_file(glue_file_name)
571 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
572
573 String::from_utf8(glue_bytes.to_vec())
574 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?
575 } else {
576 String::new()
578 };
579
580 let template_content_opt = if let Some(ref template_file_name) = template_file {
582 root.get_file(template_file_name).and_then(|bytes| {
583 String::from_utf8(bytes.to_vec())
584 .map_err(|e| {
585 eprintln!(
586 "Warning: Template file '{}' is not valid UTF-8: {}",
587 template_file_name, e
588 );
589 e
590 })
591 .ok()
592 })
593 } else {
594 None
595 };
596
597 let quill = Quill {
598 glue_template: template_content,
599 metadata,
600 name: quill_name,
601 backend,
602 glue_file,
603 example: template_content_opt,
604 field_schemas,
605 files: root,
606 };
607
608 quill.validate()?;
610
611 Ok(quill)
612 }
613
614 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
621 use serde_json::Value as JsonValue;
622
623 let json: JsonValue =
624 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
625
626 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
627
628 let default_name = obj
630 .get("metadata")
631 .and_then(|m| m.get("name"))
632 .and_then(|v| v.as_str())
633 .map(String::from);
634
635 let files_obj = obj
637 .get("files")
638 .and_then(|v| v.as_object())
639 .ok_or_else(|| "Missing or invalid 'files' key")?;
640
641 let mut root_files = HashMap::new();
643 for (key, value) in files_obj {
644 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
645 }
646
647 let root = FileTreeNode::Directory { files: root_files };
648
649 Self::from_tree(root, default_name)
651 }
652
653 fn load_directory_as_tree(
655 current_dir: &Path,
656 base_dir: &Path,
657 ignore: &QuillIgnore,
658 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
659 use std::fs;
660
661 if !current_dir.exists() {
662 return Ok(FileTreeNode::Directory {
663 files: HashMap::new(),
664 });
665 }
666
667 let mut files = HashMap::new();
668
669 for entry in fs::read_dir(current_dir)? {
670 let entry = entry?;
671 let path = entry.path();
672 let relative_path = path
673 .strip_prefix(base_dir)
674 .map_err(|e| format!("Failed to get relative path: {}", e))?
675 .to_path_buf();
676
677 if ignore.is_ignored(&relative_path) {
679 continue;
680 }
681
682 let filename = path
684 .file_name()
685 .and_then(|n| n.to_str())
686 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
687 .to_string();
688
689 if path.is_file() {
690 let contents = fs::read(&path)
691 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
692
693 files.insert(filename, FileTreeNode::File { contents });
694 } else if path.is_dir() {
695 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
697 files.insert(filename, subdir_tree);
698 }
699 }
700
701 Ok(FileTreeNode::Directory { files })
702 }
703
704 pub fn typst_packages(&self) -> Vec<String> {
706 self.metadata
707 .get("typst_packages")
708 .and_then(|v| v.as_array())
709 .map(|arr| {
710 arr.iter()
711 .filter_map(|v| v.as_str().map(|s| s.to_string()))
712 .collect()
713 })
714 .unwrap_or_default()
715 }
716
717 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
719 if let Some(ref glue_file) = self.glue_file {
721 if !self.files.file_exists(glue_file) {
722 return Err(format!("Glue file '{}' does not exist", glue_file).into());
723 }
724 }
725 Ok(())
726 }
727
728 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
730 self.files.get_file(path)
731 }
732
733 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
735 self.files.file_exists(path)
736 }
737
738 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
740 self.files.dir_exists(path)
741 }
742
743 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
745 self.files.list_files(path)
746 }
747
748 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
750 self.files.list_subdirectories(path)
751 }
752
753 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
755 let dir_path = dir_path.as_ref();
756 let filenames = self.files.list_files(dir_path);
757
758 filenames
760 .iter()
761 .map(|name| {
762 if dir_path == Path::new("") {
763 PathBuf::from(name)
764 } else {
765 dir_path.join(name)
766 }
767 })
768 .collect()
769 }
770
771 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
773 let dir_path = dir_path.as_ref();
774 let subdirs = self.files.list_subdirectories(dir_path);
775
776 subdirs
778 .iter()
779 .map(|name| {
780 if dir_path == Path::new("") {
781 PathBuf::from(name)
782 } else {
783 dir_path.join(name)
784 }
785 })
786 .collect()
787 }
788
789 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
791 let pattern_str = pattern.as_ref().to_string_lossy();
792 let mut matches = Vec::new();
793
794 let glob_pattern = match glob::Pattern::new(&pattern_str) {
796 Ok(pat) => pat,
797 Err(_) => return matches, };
799
800 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
802
803 matches.sort();
804 matches
805 }
806
807 fn find_files_recursive(
809 &self,
810 node: &FileTreeNode,
811 current_path: &Path,
812 pattern: &glob::Pattern,
813 matches: &mut Vec<PathBuf>,
814 ) {
815 match node {
816 FileTreeNode::File { .. } => {
817 let path_str = current_path.to_string_lossy();
818 if pattern.matches(&path_str) {
819 matches.push(current_path.to_path_buf());
820 }
821 }
822 FileTreeNode::Directory { files } => {
823 for (name, child_node) in files {
824 let child_path = if current_path == Path::new("") {
825 PathBuf::from(name)
826 } else {
827 current_path.join(name)
828 };
829 self.find_files_recursive(child_node, &child_path, pattern, matches);
830 }
831 }
832 }
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use std::fs;
840 use tempfile::TempDir;
841
842 #[test]
843 fn test_quillignore_parsing() {
844 let ignore_content = r#"
845# This is a comment
846*.tmp
847target/
848node_modules/
849.git/
850"#;
851 let ignore = QuillIgnore::from_content(ignore_content);
852 assert_eq!(ignore.patterns.len(), 4);
853 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
854 assert!(ignore.patterns.contains(&"target/".to_string()));
855 }
856
857 #[test]
858 fn test_quillignore_matching() {
859 let ignore = QuillIgnore::new(vec![
860 "*.tmp".to_string(),
861 "target/".to_string(),
862 "node_modules/".to_string(),
863 ".git/".to_string(),
864 ]);
865
866 assert!(ignore.is_ignored("test.tmp"));
868 assert!(ignore.is_ignored("path/to/file.tmp"));
869 assert!(!ignore.is_ignored("test.txt"));
870
871 assert!(ignore.is_ignored("target"));
873 assert!(ignore.is_ignored("target/debug"));
874 assert!(ignore.is_ignored("target/debug/deps"));
875 assert!(!ignore.is_ignored("src/target.rs"));
876
877 assert!(ignore.is_ignored("node_modules"));
878 assert!(ignore.is_ignored("node_modules/package"));
879 assert!(!ignore.is_ignored("my_node_modules"));
880 }
881
882 #[test]
883 fn test_in_memory_file_system() {
884 let temp_dir = TempDir::new().unwrap();
885 let quill_dir = temp_dir.path();
886
887 fs::write(
889 quill_dir.join("Quill.toml"),
890 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
891 )
892 .unwrap();
893 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
894
895 let assets_dir = quill_dir.join("assets");
896 fs::create_dir_all(&assets_dir).unwrap();
897 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
898
899 let packages_dir = quill_dir.join("packages");
900 fs::create_dir_all(&packages_dir).unwrap();
901 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
902
903 let quill = Quill::from_path(quill_dir).unwrap();
905
906 assert!(quill.file_exists("glue.typ"));
908 assert!(quill.file_exists("assets/test.txt"));
909 assert!(quill.file_exists("packages/package.typ"));
910 assert!(!quill.file_exists("nonexistent.txt"));
911
912 let asset_content = quill.get_file("assets/test.txt").unwrap();
914 assert_eq!(asset_content, b"asset content");
915
916 let asset_files = quill.list_directory("assets");
918 assert_eq!(asset_files.len(), 1);
919 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
920 }
921
922 #[test]
923 fn test_quillignore_integration() {
924 let temp_dir = TempDir::new().unwrap();
925 let quill_dir = temp_dir.path();
926
927 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
929
930 fs::write(
932 quill_dir.join("Quill.toml"),
933 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
934 )
935 .unwrap();
936 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
937 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
938
939 let target_dir = quill_dir.join("target");
940 fs::create_dir_all(&target_dir).unwrap();
941 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
942
943 let quill = Quill::from_path(quill_dir).unwrap();
945
946 assert!(quill.file_exists("glue.typ"));
948 assert!(!quill.file_exists("should_ignore.tmp"));
949 assert!(!quill.file_exists("target/debug.txt"));
950 }
951
952 #[test]
953 fn test_find_files_pattern() {
954 let temp_dir = TempDir::new().unwrap();
955 let quill_dir = temp_dir.path();
956
957 fs::write(
959 quill_dir.join("Quill.toml"),
960 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
961 )
962 .unwrap();
963 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
964
965 let assets_dir = quill_dir.join("assets");
966 fs::create_dir_all(&assets_dir).unwrap();
967 fs::write(assets_dir.join("image.png"), "png data").unwrap();
968 fs::write(assets_dir.join("data.json"), "json data").unwrap();
969
970 let fonts_dir = assets_dir.join("fonts");
971 fs::create_dir_all(&fonts_dir).unwrap();
972 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
973
974 let quill = Quill::from_path(quill_dir).unwrap();
976
977 let all_assets = quill.find_files("assets/*");
979 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
982 assert_eq!(typ_files.len(), 1);
983 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
984 }
985
986 #[test]
987 fn test_new_standardized_toml_format() {
988 let temp_dir = TempDir::new().unwrap();
989 let quill_dir = temp_dir.path();
990
991 let toml_content = r#"[Quill]
993name = "my-custom-quill"
994backend = "typst"
995glue = "custom_glue.typ"
996description = "Test quill with new format"
997author = "Test Author"
998"#;
999 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1000 fs::write(
1001 quill_dir.join("custom_glue.typ"),
1002 "= Custom Template\n\nThis is a custom template.",
1003 )
1004 .unwrap();
1005
1006 let quill = Quill::from_path(quill_dir).unwrap();
1008
1009 assert_eq!(quill.name, "my-custom-quill");
1011
1012 assert_eq!(quill.glue_file, Some("custom_glue.typ".to_string()));
1014
1015 assert!(quill.metadata.contains_key("backend"));
1017 if let Some(backend_val) = quill.metadata.get("backend") {
1018 if let Some(backend_str) = backend_val.as_str() {
1019 assert_eq!(backend_str, "typst");
1020 } else {
1021 panic!("Backend value is not a string");
1022 }
1023 }
1024
1025 assert!(quill.metadata.contains_key("description"));
1027 assert!(quill.metadata.contains_key("author"));
1028 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue_template.contains("Custom Template"));
1032 assert!(quill.glue_template.contains("custom template"));
1033 }
1034
1035 #[test]
1036 fn test_typst_packages_parsing() {
1037 let temp_dir = TempDir::new().unwrap();
1038 let quill_dir = temp_dir.path();
1039
1040 let toml_content = r#"
1041[Quill]
1042name = "test-quill"
1043backend = "typst"
1044glue = "glue.typ"
1045description = "Test quill for packages"
1046
1047[typst]
1048packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1049"#;
1050
1051 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1052 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1053
1054 let quill = Quill::from_path(quill_dir).unwrap();
1055 let packages = quill.typst_packages();
1056
1057 assert_eq!(packages.len(), 2);
1058 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1059 assert_eq!(packages[1], "@preview/example:1.0.0");
1060 }
1061
1062 #[test]
1063 fn test_template_loading() {
1064 let temp_dir = TempDir::new().unwrap();
1065 let quill_dir = temp_dir.path();
1066
1067 let toml_content = r#"[Quill]
1069name = "test-with-template"
1070backend = "typst"
1071glue = "glue.typ"
1072example = "example.md"
1073description = "Test quill with template"
1074"#;
1075 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1076 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1077 fs::write(
1078 quill_dir.join("example.md"),
1079 "---\ntitle: Test\n---\n\nThis is a test template.",
1080 )
1081 .unwrap();
1082
1083 let quill = Quill::from_path(quill_dir).unwrap();
1085
1086 assert!(quill.example.is_some());
1088 let example = quill.example.unwrap();
1089 assert!(example.contains("title: Test"));
1090 assert!(example.contains("This is a test template"));
1091
1092 assert_eq!(quill.glue_template, "glue content");
1094 }
1095
1096 #[test]
1097 fn test_template_optional() {
1098 let temp_dir = TempDir::new().unwrap();
1099 let quill_dir = temp_dir.path();
1100
1101 let toml_content = r#"[Quill]
1103name = "test-without-template"
1104backend = "typst"
1105glue = "glue.typ"
1106description = "Test quill without template"
1107"#;
1108 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1109 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1110
1111 let quill = Quill::from_path(quill_dir).unwrap();
1113
1114 assert_eq!(quill.example, None);
1116
1117 assert_eq!(quill.glue_template, "glue content");
1119 }
1120
1121 #[test]
1122 fn test_from_tree() {
1123 let mut root_files = HashMap::new();
1125
1126 let quill_toml = r#"[Quill]
1128name = "test-from-tree"
1129backend = "typst"
1130glue = "glue.typ"
1131description = "A test quill from tree"
1132"#;
1133 root_files.insert(
1134 "Quill.toml".to_string(),
1135 FileTreeNode::File {
1136 contents: quill_toml.as_bytes().to_vec(),
1137 },
1138 );
1139
1140 let glue_content = "= Test Template\n\nThis is a test.";
1142 root_files.insert(
1143 "glue.typ".to_string(),
1144 FileTreeNode::File {
1145 contents: glue_content.as_bytes().to_vec(),
1146 },
1147 );
1148
1149 let root = FileTreeNode::Directory { files: root_files };
1150
1151 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1153
1154 assert_eq!(quill.name, "test-from-tree");
1156 assert_eq!(quill.glue_file, Some("glue.typ".to_string()));
1157 assert_eq!(quill.glue_template, glue_content);
1158 assert!(quill.metadata.contains_key("backend"));
1159 assert!(quill.metadata.contains_key("description"));
1160 }
1161
1162 #[test]
1163 fn test_from_tree_with_template() {
1164 let mut root_files = HashMap::new();
1165
1166 let quill_toml = r#"[Quill]
1168name = "test-tree-template"
1169backend = "typst"
1170glue = "glue.typ"
1171example = "template.md"
1172description = "Test tree with template"
1173"#;
1174 root_files.insert(
1175 "Quill.toml".to_string(),
1176 FileTreeNode::File {
1177 contents: quill_toml.as_bytes().to_vec(),
1178 },
1179 );
1180
1181 root_files.insert(
1183 "glue.typ".to_string(),
1184 FileTreeNode::File {
1185 contents: b"glue content".to_vec(),
1186 },
1187 );
1188
1189 let template_content = "# {{ title }}\n\n{{ body }}";
1191 root_files.insert(
1192 "template.md".to_string(),
1193 FileTreeNode::File {
1194 contents: template_content.as_bytes().to_vec(),
1195 },
1196 );
1197
1198 let root = FileTreeNode::Directory { files: root_files };
1199
1200 let quill = Quill::from_tree(root, None).unwrap();
1202
1203 assert_eq!(quill.example, Some(template_content.to_string()));
1205 }
1206
1207 #[test]
1208 fn test_from_json() {
1209 let json_str = r#"{
1211 "metadata": {
1212 "name": "test-from-json"
1213 },
1214 "files": {
1215 "Quill.toml": {
1216 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1217 },
1218 "glue.typ": {
1219 "contents": "= Test Glue\n\nThis is test content."
1220 }
1221 }
1222 }"#;
1223
1224 let quill = Quill::from_json(json_str).unwrap();
1226
1227 assert_eq!(quill.name, "test-from-json");
1229 assert_eq!(quill.glue_file, Some("glue.typ".to_string()));
1230 assert!(quill.glue_template.contains("Test Glue"));
1231 assert!(quill.metadata.contains_key("backend"));
1232 }
1233
1234 #[test]
1235 fn test_from_json_with_byte_array() {
1236 let json_str = r#"{
1238 "files": {
1239 "Quill.toml": {
1240 "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, 100, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 32, 61, 32, 34, 84, 101, 115, 116, 32, 113, 117, 105, 108, 108, 34, 10]
1241 },
1242 "glue.typ": {
1243 "contents": "test glue"
1244 }
1245 }
1246 }"#;
1247
1248 let quill = Quill::from_json(json_str).unwrap();
1250
1251 assert_eq!(quill.name, "test");
1253 assert_eq!(quill.glue_file, Some("glue.typ".to_string()));
1254 }
1255
1256 #[test]
1257 fn test_from_json_missing_files() {
1258 let json_str = r#"{
1260 "metadata": {
1261 "name": "test"
1262 }
1263 }"#;
1264
1265 let result = Quill::from_json(json_str);
1266 assert!(result.is_err());
1267 assert!(result.unwrap_err().to_string().contains("files"));
1269 }
1270
1271 #[test]
1272 fn test_from_json_tree_structure() {
1273 let json_str = r#"{
1275 "files": {
1276 "Quill.toml": {
1277 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1278 },
1279 "glue.typ": {
1280 "contents": "= Test Glue\n\nTree structure content."
1281 }
1282 }
1283 }"#;
1284
1285 let quill = Quill::from_json(json_str).unwrap();
1286
1287 assert_eq!(quill.name, "test-tree-json");
1288 assert!(quill.glue_template.contains("Tree structure content"));
1289 assert!(quill.metadata.contains_key("backend"));
1290 }
1291
1292 #[test]
1293 fn test_from_json_nested_tree_structure() {
1294 let json_str = r#"{
1296 "files": {
1297 "Quill.toml": {
1298 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Nested test\"\n"
1299 },
1300 "glue.typ": {
1301 "contents": "glue"
1302 },
1303 "src": {
1304 "main.rs": {
1305 "contents": "fn main() {}"
1306 },
1307 "lib.rs": {
1308 "contents": "// lib"
1309 }
1310 }
1311 }
1312 }"#;
1313
1314 let quill = Quill::from_json(json_str).unwrap();
1315
1316 assert_eq!(quill.name, "nested-test");
1317 assert!(quill.file_exists("src/main.rs"));
1319 assert!(quill.file_exists("src/lib.rs"));
1320
1321 let main_rs = quill.get_file("src/main.rs").unwrap();
1322 assert_eq!(main_rs, b"fn main() {}");
1323 }
1324
1325 #[test]
1326 fn test_from_tree_structure_direct() {
1327 let mut root_files = HashMap::new();
1329
1330 root_files.insert(
1331 "Quill.toml".to_string(),
1332 FileTreeNode::File {
1333 contents:
1334 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1335 .to_vec(),
1336 },
1337 );
1338
1339 root_files.insert(
1340 "glue.typ".to_string(),
1341 FileTreeNode::File {
1342 contents: b"glue content".to_vec(),
1343 },
1344 );
1345
1346 let mut src_files = HashMap::new();
1348 src_files.insert(
1349 "main.rs".to_string(),
1350 FileTreeNode::File {
1351 contents: b"fn main() {}".to_vec(),
1352 },
1353 );
1354
1355 root_files.insert(
1356 "src".to_string(),
1357 FileTreeNode::Directory { files: src_files },
1358 );
1359
1360 let root = FileTreeNode::Directory { files: root_files };
1361
1362 let quill = Quill::from_tree(root, None).unwrap();
1363
1364 assert_eq!(quill.name, "direct-tree");
1365 assert!(quill.file_exists("src/main.rs"));
1366 assert!(quill.file_exists("glue.typ"));
1367 }
1368
1369 #[test]
1370 fn test_from_json_with_metadata_override() {
1371 let json_str = r#"{
1373 "metadata": {
1374 "name": "override-name"
1375 },
1376 "files": {
1377 "Quill.toml": {
1378 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1379 },
1380 "glue.typ": {
1381 "contents": "= glue"
1382 }
1383 }
1384 }"#;
1385
1386 let quill = Quill::from_json(json_str).unwrap();
1387 assert_eq!(quill.name, "toml-name");
1390 }
1391
1392 #[test]
1393 fn test_from_json_empty_directory() {
1394 let json_str = r#"{
1396 "files": {
1397 "Quill.toml": {
1398 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1399 },
1400 "glue.typ": {
1401 "contents": "glue"
1402 },
1403 "empty_dir": {}
1404 }
1405 }"#;
1406
1407 let quill = Quill::from_json(json_str).unwrap();
1408 assert_eq!(quill.name, "empty-dir-test");
1409 assert!(quill.dir_exists("empty_dir"));
1410 assert!(!quill.file_exists("empty_dir"));
1411 }
1412
1413 #[test]
1414 fn test_dir_exists_and_list_apis() {
1415 let mut root_files = HashMap::new();
1416
1417 root_files.insert(
1419 "Quill.toml".to_string(),
1420 FileTreeNode::File {
1421 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"\n"
1422 .to_vec(),
1423 },
1424 );
1425
1426 root_files.insert(
1428 "glue.typ".to_string(),
1429 FileTreeNode::File {
1430 contents: b"glue content".to_vec(),
1431 },
1432 );
1433
1434 let mut assets_files = HashMap::new();
1436 assets_files.insert(
1437 "logo.png".to_string(),
1438 FileTreeNode::File {
1439 contents: vec![137, 80, 78, 71],
1440 },
1441 );
1442 assets_files.insert(
1443 "icon.svg".to_string(),
1444 FileTreeNode::File {
1445 contents: b"<svg></svg>".to_vec(),
1446 },
1447 );
1448
1449 let mut fonts_files = HashMap::new();
1451 fonts_files.insert(
1452 "font.ttf".to_string(),
1453 FileTreeNode::File {
1454 contents: b"font data".to_vec(),
1455 },
1456 );
1457 assets_files.insert(
1458 "fonts".to_string(),
1459 FileTreeNode::Directory { files: fonts_files },
1460 );
1461
1462 root_files.insert(
1463 "assets".to_string(),
1464 FileTreeNode::Directory {
1465 files: assets_files,
1466 },
1467 );
1468
1469 root_files.insert(
1471 "empty".to_string(),
1472 FileTreeNode::Directory {
1473 files: HashMap::new(),
1474 },
1475 );
1476
1477 let root = FileTreeNode::Directory { files: root_files };
1478 let quill = Quill::from_tree(root, None).unwrap();
1479
1480 assert!(quill.dir_exists("assets"));
1482 assert!(quill.dir_exists("assets/fonts"));
1483 assert!(quill.dir_exists("empty"));
1484 assert!(!quill.dir_exists("nonexistent"));
1485 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1489 assert!(quill.file_exists("assets/logo.png"));
1490 assert!(quill.file_exists("assets/fonts/font.ttf"));
1491 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1495 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1497 assert!(root_files_list.contains(&"glue.typ".to_string()));
1498
1499 let assets_files_list = quill.list_files("assets");
1500 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1502 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1503
1504 let root_subdirs = quill.list_subdirectories("");
1506 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1508 assert!(root_subdirs.contains(&"empty".to_string()));
1509
1510 let assets_subdirs = quill.list_subdirectories("assets");
1511 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1513
1514 let empty_subdirs = quill.list_subdirectories("empty");
1515 assert_eq!(empty_subdirs.len(), 0);
1516 }
1517
1518 #[test]
1519 fn test_field_schemas_parsing() {
1520 let mut root_files = HashMap::new();
1521
1522 let quill_toml = r#"[Quill]
1524name = "taro"
1525backend = "typst"
1526glue = "glue.typ"
1527example = "taro.md"
1528description = "Test template for field schemas"
1529
1530[fields]
1531author = {description = "Author of document", required = true}
1532ice_cream = {description = "favorite ice cream flavor"}
1533title = {description = "title of document", required = true}
1534"#;
1535 root_files.insert(
1536 "Quill.toml".to_string(),
1537 FileTreeNode::File {
1538 contents: quill_toml.as_bytes().to_vec(),
1539 },
1540 );
1541
1542 let glue_content = "= Test Template\n\nThis is a test.";
1544 root_files.insert(
1545 "glue.typ".to_string(),
1546 FileTreeNode::File {
1547 contents: glue_content.as_bytes().to_vec(),
1548 },
1549 );
1550
1551 root_files.insert(
1553 "taro.md".to_string(),
1554 FileTreeNode::File {
1555 contents: b"# Template".to_vec(),
1556 },
1557 );
1558
1559 let root = FileTreeNode::Directory { files: root_files };
1560
1561 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1563
1564 assert_eq!(quill.field_schemas.len(), 3);
1566 assert!(quill.field_schemas.contains_key("author"));
1567 assert!(quill.field_schemas.contains_key("ice_cream"));
1568 assert!(quill.field_schemas.contains_key("title"));
1569
1570 let author_schema = quill.field_schemas.get("author").unwrap();
1572 assert_eq!(author_schema.description, "Author of document");
1573 assert_eq!(author_schema.required, true);
1574
1575 let ice_cream_schema = quill.field_schemas.get("ice_cream").unwrap();
1577 assert_eq!(ice_cream_schema.description, "favorite ice cream flavor");
1578 assert_eq!(ice_cream_schema.required, false);
1579
1580 let title_schema = quill.field_schemas.get("title").unwrap();
1582 assert_eq!(title_schema.description, "title of document");
1583 assert_eq!(title_schema.required, true);
1584 }
1585
1586 #[test]
1587 fn test_field_schema_struct() {
1588 let schema1 = FieldSchema::new("Test description".to_string());
1590 assert_eq!(schema1.description, "Test description");
1591 assert_eq!(schema1.required, false);
1592 assert_eq!(schema1.r#type, None);
1593 assert_eq!(schema1.example, None);
1594 assert_eq!(schema1.default, None);
1595
1596 let yaml_str = r#"
1598description: "Full field schema"
1599required: true
1600type: "string"
1601example: "Example value"
1602default: "Default value"
1603"#;
1604 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1605 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1606 let schema2 = FieldSchema::from_quill_value(&quill_value).unwrap();
1607 assert_eq!(schema2.description, "Full field schema");
1608 assert_eq!(schema2.required, true);
1609 assert_eq!(schema2.r#type, Some("string".to_string()));
1610 assert_eq!(
1611 schema2.example.as_ref().and_then(|v| v.as_str()),
1612 Some("Example value")
1613 );
1614 assert_eq!(
1615 schema2.default.as_ref().and_then(|v| v.as_str()),
1616 Some("Default value")
1617 );
1618
1619 let quill_value = schema2.to_quill_value();
1621 let obj = quill_value.as_object().unwrap();
1622 assert_eq!(
1623 obj.get("description").unwrap().as_str().unwrap(),
1624 "Full field schema"
1625 );
1626 assert_eq!(obj.get("required").unwrap().as_bool().unwrap(), true);
1627 assert_eq!(obj.get("type").unwrap().as_str().unwrap(), "string");
1628 }
1629
1630 #[test]
1631 fn test_quill_without_glue_file() {
1632 let mut root_files = HashMap::new();
1634
1635 let quill_toml = r#"[Quill]
1637name = "test-no-glue"
1638backend = "typst"
1639description = "Test quill without glue file"
1640"#;
1641 root_files.insert(
1642 "Quill.toml".to_string(),
1643 FileTreeNode::File {
1644 contents: quill_toml.as_bytes().to_vec(),
1645 },
1646 );
1647
1648 let root = FileTreeNode::Directory { files: root_files };
1649
1650 let quill = Quill::from_tree(root, None).unwrap();
1652
1653 assert_eq!(quill.glue_file, None);
1655 assert_eq!(quill.glue_template, "");
1657 assert_eq!(quill.name, "test-no-glue");
1658 }
1659}