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: 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 = "glue.typ".to_string(); 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 = 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 glue_bytes = root
569 .get_file(&glue_file)
570 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file))?;
571
572 let template_content = String::from_utf8(glue_bytes.to_vec())
573 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file, e))?;
574
575 let template_content_opt = if let Some(ref template_file_name) = template_file {
577 root.get_file(template_file_name).and_then(|bytes| {
578 String::from_utf8(bytes.to_vec())
579 .map_err(|e| {
580 eprintln!(
581 "Warning: Template file '{}' is not valid UTF-8: {}",
582 template_file_name, e
583 );
584 e
585 })
586 .ok()
587 })
588 } else {
589 None
590 };
591
592 let quill = Quill {
593 glue_template: template_content,
594 metadata,
595 name: quill_name,
596 backend,
597 glue_file,
598 example: template_content_opt,
599 field_schemas,
600 files: root,
601 };
602
603 quill.validate()?;
605
606 Ok(quill)
607 }
608
609 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
616 use serde_json::Value as JsonValue;
617
618 let json: JsonValue =
619 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
620
621 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
622
623 let default_name = obj
625 .get("metadata")
626 .and_then(|m| m.get("name"))
627 .and_then(|v| v.as_str())
628 .map(String::from);
629
630 let files_obj = obj
632 .get("files")
633 .and_then(|v| v.as_object())
634 .ok_or_else(|| "Missing or invalid 'files' key")?;
635
636 let mut root_files = HashMap::new();
638 for (key, value) in files_obj {
639 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
640 }
641
642 let root = FileTreeNode::Directory { files: root_files };
643
644 Self::from_tree(root, default_name)
646 }
647
648 fn load_directory_as_tree(
650 current_dir: &Path,
651 base_dir: &Path,
652 ignore: &QuillIgnore,
653 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
654 use std::fs;
655
656 if !current_dir.exists() {
657 return Ok(FileTreeNode::Directory {
658 files: HashMap::new(),
659 });
660 }
661
662 let mut files = HashMap::new();
663
664 for entry in fs::read_dir(current_dir)? {
665 let entry = entry?;
666 let path = entry.path();
667 let relative_path = path
668 .strip_prefix(base_dir)
669 .map_err(|e| format!("Failed to get relative path: {}", e))?
670 .to_path_buf();
671
672 if ignore.is_ignored(&relative_path) {
674 continue;
675 }
676
677 let filename = path
679 .file_name()
680 .and_then(|n| n.to_str())
681 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
682 .to_string();
683
684 if path.is_file() {
685 let contents = fs::read(&path)
686 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
687
688 files.insert(filename, FileTreeNode::File { contents });
689 } else if path.is_dir() {
690 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
692 files.insert(filename, subdir_tree);
693 }
694 }
695
696 Ok(FileTreeNode::Directory { files })
697 }
698
699 pub fn typst_packages(&self) -> Vec<String> {
701 self.metadata
702 .get("typst_packages")
703 .and_then(|v| v.as_array())
704 .map(|arr| {
705 arr.iter()
706 .filter_map(|v| v.as_str().map(|s| s.to_string()))
707 .collect()
708 })
709 .unwrap_or_default()
710 }
711
712 pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
714 if !self.files.file_exists(&self.glue_file) {
716 return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
717 }
718 Ok(())
719 }
720
721 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
723 self.files.get_file(path)
724 }
725
726 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
728 self.files.file_exists(path)
729 }
730
731 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
733 self.files.dir_exists(path)
734 }
735
736 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
738 self.files.list_files(path)
739 }
740
741 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
743 self.files.list_subdirectories(path)
744 }
745
746 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
748 let dir_path = dir_path.as_ref();
749 let filenames = self.files.list_files(dir_path);
750
751 filenames
753 .iter()
754 .map(|name| {
755 if dir_path == Path::new("") {
756 PathBuf::from(name)
757 } else {
758 dir_path.join(name)
759 }
760 })
761 .collect()
762 }
763
764 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
766 let dir_path = dir_path.as_ref();
767 let subdirs = self.files.list_subdirectories(dir_path);
768
769 subdirs
771 .iter()
772 .map(|name| {
773 if dir_path == Path::new("") {
774 PathBuf::from(name)
775 } else {
776 dir_path.join(name)
777 }
778 })
779 .collect()
780 }
781
782 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
784 let pattern_str = pattern.as_ref().to_string_lossy();
785 let mut matches = Vec::new();
786
787 self.find_files_recursive(&self.files, Path::new(""), &pattern_str, &mut matches);
789
790 matches.sort();
791 matches
792 }
793
794 fn find_files_recursive(
796 &self,
797 node: &FileTreeNode,
798 current_path: &Path,
799 pattern: &str,
800 matches: &mut Vec<PathBuf>,
801 ) {
802 match node {
803 FileTreeNode::File { .. } => {
804 let path_str = current_path.to_string_lossy();
805 if self.matches_simple_pattern(pattern, &path_str) {
806 matches.push(current_path.to_path_buf());
807 }
808 }
809 FileTreeNode::Directory { files } => {
810 for (name, child_node) in files {
811 let child_path = if current_path == Path::new("") {
812 PathBuf::from(name)
813 } else {
814 current_path.join(name)
815 };
816 self.find_files_recursive(child_node, &child_path, pattern, matches);
817 }
818 }
819 }
820 }
821
822 fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
824 if pattern == "*" {
825 return true;
826 }
827
828 if !pattern.contains('*') {
829 return path == pattern;
830 }
831
832 if pattern.ends_with("/*") {
834 let dir_pattern = &pattern[..pattern.len() - 2];
835 return path.starts_with(&format!("{}/", dir_pattern));
836 }
837
838 let parts: Vec<&str> = pattern.split('*').collect();
839 if parts.len() == 2 {
840 let (prefix, suffix) = (parts[0], parts[1]);
841 if prefix.is_empty() {
842 return path.ends_with(suffix);
843 } else if suffix.is_empty() {
844 return path.starts_with(prefix);
845 } else {
846 return path.starts_with(prefix) && path.ends_with(suffix);
847 }
848 }
849
850 false
851 }
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use std::fs;
858 use tempfile::TempDir;
859
860 #[test]
861 fn test_quillignore_parsing() {
862 let ignore_content = r#"
863# This is a comment
864*.tmp
865target/
866node_modules/
867.git/
868"#;
869 let ignore = QuillIgnore::from_content(ignore_content);
870 assert_eq!(ignore.patterns.len(), 4);
871 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
872 assert!(ignore.patterns.contains(&"target/".to_string()));
873 }
874
875 #[test]
876 fn test_quillignore_matching() {
877 let ignore = QuillIgnore::new(vec![
878 "*.tmp".to_string(),
879 "target/".to_string(),
880 "node_modules/".to_string(),
881 ".git/".to_string(),
882 ]);
883
884 assert!(ignore.is_ignored("test.tmp"));
886 assert!(ignore.is_ignored("path/to/file.tmp"));
887 assert!(!ignore.is_ignored("test.txt"));
888
889 assert!(ignore.is_ignored("target"));
891 assert!(ignore.is_ignored("target/debug"));
892 assert!(ignore.is_ignored("target/debug/deps"));
893 assert!(!ignore.is_ignored("src/target.rs"));
894
895 assert!(ignore.is_ignored("node_modules"));
896 assert!(ignore.is_ignored("node_modules/package"));
897 assert!(!ignore.is_ignored("my_node_modules"));
898 }
899
900 #[test]
901 fn test_in_memory_file_system() {
902 let temp_dir = TempDir::new().unwrap();
903 let quill_dir = temp_dir.path();
904
905 fs::write(
907 quill_dir.join("Quill.toml"),
908 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
909 )
910 .unwrap();
911 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
912
913 let assets_dir = quill_dir.join("assets");
914 fs::create_dir_all(&assets_dir).unwrap();
915 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
916
917 let packages_dir = quill_dir.join("packages");
918 fs::create_dir_all(&packages_dir).unwrap();
919 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
920
921 let quill = Quill::from_path(quill_dir).unwrap();
923
924 assert!(quill.file_exists("glue.typ"));
926 assert!(quill.file_exists("assets/test.txt"));
927 assert!(quill.file_exists("packages/package.typ"));
928 assert!(!quill.file_exists("nonexistent.txt"));
929
930 let asset_content = quill.get_file("assets/test.txt").unwrap();
932 assert_eq!(asset_content, b"asset content");
933
934 let asset_files = quill.list_directory("assets");
936 assert_eq!(asset_files.len(), 1);
937 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
938 }
939
940 #[test]
941 fn test_quillignore_integration() {
942 let temp_dir = TempDir::new().unwrap();
943 let quill_dir = temp_dir.path();
944
945 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
947
948 fs::write(
950 quill_dir.join("Quill.toml"),
951 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
952 )
953 .unwrap();
954 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
955 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
956
957 let target_dir = quill_dir.join("target");
958 fs::create_dir_all(&target_dir).unwrap();
959 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
960
961 let quill = Quill::from_path(quill_dir).unwrap();
963
964 assert!(quill.file_exists("glue.typ"));
966 assert!(!quill.file_exists("should_ignore.tmp"));
967 assert!(!quill.file_exists("target/debug.txt"));
968 }
969
970 #[test]
971 fn test_find_files_pattern() {
972 let temp_dir = TempDir::new().unwrap();
973 let quill_dir = temp_dir.path();
974
975 fs::write(
977 quill_dir.join("Quill.toml"),
978 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"",
979 )
980 .unwrap();
981 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
982
983 let assets_dir = quill_dir.join("assets");
984 fs::create_dir_all(&assets_dir).unwrap();
985 fs::write(assets_dir.join("image.png"), "png data").unwrap();
986 fs::write(assets_dir.join("data.json"), "json data").unwrap();
987
988 let fonts_dir = assets_dir.join("fonts");
989 fs::create_dir_all(&fonts_dir).unwrap();
990 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
991
992 let quill = Quill::from_path(quill_dir).unwrap();
994
995 let all_assets = quill.find_files("assets/*");
997 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1000 assert_eq!(typ_files.len(), 1);
1001 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1002 }
1003
1004 #[test]
1005 fn test_new_standardized_toml_format() {
1006 let temp_dir = TempDir::new().unwrap();
1007 let quill_dir = temp_dir.path();
1008
1009 let toml_content = r#"[Quill]
1011name = "my-custom-quill"
1012backend = "typst"
1013glue = "custom_glue.typ"
1014description = "Test quill with new format"
1015author = "Test Author"
1016"#;
1017 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1018 fs::write(
1019 quill_dir.join("custom_glue.typ"),
1020 "= Custom Template\n\nThis is a custom template.",
1021 )
1022 .unwrap();
1023
1024 let quill = Quill::from_path(quill_dir).unwrap();
1026
1027 assert_eq!(quill.name, "my-custom-quill");
1029
1030 assert_eq!(quill.glue_file, "custom_glue.typ");
1032
1033 assert!(quill.metadata.contains_key("backend"));
1035 if let Some(backend_val) = quill.metadata.get("backend") {
1036 if let Some(backend_str) = backend_val.as_str() {
1037 assert_eq!(backend_str, "typst");
1038 } else {
1039 panic!("Backend value is not a string");
1040 }
1041 }
1042
1043 assert!(quill.metadata.contains_key("description"));
1045 assert!(quill.metadata.contains_key("author"));
1046 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue_template.contains("Custom Template"));
1050 assert!(quill.glue_template.contains("custom template"));
1051 }
1052
1053 #[test]
1054 fn test_typst_packages_parsing() {
1055 let temp_dir = TempDir::new().unwrap();
1056 let quill_dir = temp_dir.path();
1057
1058 let toml_content = r#"
1059[Quill]
1060name = "test-quill"
1061backend = "typst"
1062glue = "glue.typ"
1063description = "Test quill for packages"
1064
1065[typst]
1066packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1067"#;
1068
1069 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1070 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1071
1072 let quill = Quill::from_path(quill_dir).unwrap();
1073 let packages = quill.typst_packages();
1074
1075 assert_eq!(packages.len(), 2);
1076 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1077 assert_eq!(packages[1], "@preview/example:1.0.0");
1078 }
1079
1080 #[test]
1081 fn test_template_loading() {
1082 let temp_dir = TempDir::new().unwrap();
1083 let quill_dir = temp_dir.path();
1084
1085 let toml_content = r#"[Quill]
1087name = "test-with-template"
1088backend = "typst"
1089glue = "glue.typ"
1090example = "example.md"
1091description = "Test quill with template"
1092"#;
1093 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1094 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1095 fs::write(
1096 quill_dir.join("example.md"),
1097 "---\ntitle: Test\n---\n\nThis is a test template.",
1098 )
1099 .unwrap();
1100
1101 let quill = Quill::from_path(quill_dir).unwrap();
1103
1104 assert!(quill.example.is_some());
1106 let example = quill.example.unwrap();
1107 assert!(example.contains("title: Test"));
1108 assert!(example.contains("This is a test template"));
1109
1110 assert_eq!(quill.glue_template, "glue content");
1112 }
1113
1114 #[test]
1115 fn test_template_optional() {
1116 let temp_dir = TempDir::new().unwrap();
1117 let quill_dir = temp_dir.path();
1118
1119 let toml_content = r#"[Quill]
1121name = "test-without-template"
1122backend = "typst"
1123glue = "glue.typ"
1124description = "Test quill without template"
1125"#;
1126 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1127 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1128
1129 let quill = Quill::from_path(quill_dir).unwrap();
1131
1132 assert_eq!(quill.example, None);
1134
1135 assert_eq!(quill.glue_template, "glue content");
1137 }
1138
1139 #[test]
1140 fn test_from_tree() {
1141 let mut root_files = HashMap::new();
1143
1144 let quill_toml = r#"[Quill]
1146name = "test-from-tree"
1147backend = "typst"
1148glue = "glue.typ"
1149description = "A test quill from tree"
1150"#;
1151 root_files.insert(
1152 "Quill.toml".to_string(),
1153 FileTreeNode::File {
1154 contents: quill_toml.as_bytes().to_vec(),
1155 },
1156 );
1157
1158 let glue_content = "= Test Template\n\nThis is a test.";
1160 root_files.insert(
1161 "glue.typ".to_string(),
1162 FileTreeNode::File {
1163 contents: glue_content.as_bytes().to_vec(),
1164 },
1165 );
1166
1167 let root = FileTreeNode::Directory { files: root_files };
1168
1169 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1171
1172 assert_eq!(quill.name, "test-from-tree");
1174 assert_eq!(quill.glue_file, "glue.typ");
1175 assert_eq!(quill.glue_template, glue_content);
1176 assert!(quill.metadata.contains_key("backend"));
1177 assert!(quill.metadata.contains_key("description"));
1178 }
1179
1180 #[test]
1181 fn test_from_tree_with_template() {
1182 let mut root_files = HashMap::new();
1183
1184 let quill_toml = r#"[Quill]
1186name = "test-tree-template"
1187backend = "typst"
1188glue = "glue.typ"
1189example = "template.md"
1190description = "Test tree with template"
1191"#;
1192 root_files.insert(
1193 "Quill.toml".to_string(),
1194 FileTreeNode::File {
1195 contents: quill_toml.as_bytes().to_vec(),
1196 },
1197 );
1198
1199 root_files.insert(
1201 "glue.typ".to_string(),
1202 FileTreeNode::File {
1203 contents: b"glue content".to_vec(),
1204 },
1205 );
1206
1207 let template_content = "# {{ title }}\n\n{{ body }}";
1209 root_files.insert(
1210 "template.md".to_string(),
1211 FileTreeNode::File {
1212 contents: template_content.as_bytes().to_vec(),
1213 },
1214 );
1215
1216 let root = FileTreeNode::Directory { files: root_files };
1217
1218 let quill = Quill::from_tree(root, None).unwrap();
1220
1221 assert_eq!(quill.example, Some(template_content.to_string()));
1223 }
1224
1225 #[test]
1226 fn test_from_json() {
1227 let json_str = r#"{
1229 "metadata": {
1230 "name": "test-from-json"
1231 },
1232 "files": {
1233 "Quill.toml": {
1234 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1235 },
1236 "glue.typ": {
1237 "contents": "= Test Glue\n\nThis is test content."
1238 }
1239 }
1240 }"#;
1241
1242 let quill = Quill::from_json(json_str).unwrap();
1244
1245 assert_eq!(quill.name, "test-from-json");
1247 assert_eq!(quill.glue_file, "glue.typ");
1248 assert!(quill.glue_template.contains("Test Glue"));
1249 assert!(quill.metadata.contains_key("backend"));
1250 }
1251
1252 #[test]
1253 fn test_from_json_with_byte_array() {
1254 let json_str = r#"{
1256 "files": {
1257 "Quill.toml": {
1258 "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]
1259 },
1260 "glue.typ": {
1261 "contents": "test glue"
1262 }
1263 }
1264 }"#;
1265
1266 let quill = Quill::from_json(json_str).unwrap();
1268
1269 assert_eq!(quill.name, "test");
1271 assert_eq!(quill.glue_file, "glue.typ");
1272 }
1273
1274 #[test]
1275 fn test_from_json_missing_files() {
1276 let json_str = r#"{
1278 "metadata": {
1279 "name": "test"
1280 }
1281 }"#;
1282
1283 let result = Quill::from_json(json_str);
1284 assert!(result.is_err());
1285 assert!(result.unwrap_err().to_string().contains("files"));
1287 }
1288
1289 #[test]
1290 fn test_from_json_tree_structure() {
1291 let json_str = r#"{
1293 "files": {
1294 "Quill.toml": {
1295 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1296 },
1297 "glue.typ": {
1298 "contents": "= Test Glue\n\nTree structure content."
1299 }
1300 }
1301 }"#;
1302
1303 let quill = Quill::from_json(json_str).unwrap();
1304
1305 assert_eq!(quill.name, "test-tree-json");
1306 assert!(quill.glue_template.contains("Tree structure content"));
1307 assert!(quill.metadata.contains_key("backend"));
1308 }
1309
1310 #[test]
1311 fn test_from_json_nested_tree_structure() {
1312 let json_str = r#"{
1314 "files": {
1315 "Quill.toml": {
1316 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Nested test\"\n"
1317 },
1318 "glue.typ": {
1319 "contents": "glue"
1320 },
1321 "src": {
1322 "main.rs": {
1323 "contents": "fn main() {}"
1324 },
1325 "lib.rs": {
1326 "contents": "// lib"
1327 }
1328 }
1329 }
1330 }"#;
1331
1332 let quill = Quill::from_json(json_str).unwrap();
1333
1334 assert_eq!(quill.name, "nested-test");
1335 assert!(quill.file_exists("src/main.rs"));
1337 assert!(quill.file_exists("src/lib.rs"));
1338
1339 let main_rs = quill.get_file("src/main.rs").unwrap();
1340 assert_eq!(main_rs, b"fn main() {}");
1341 }
1342
1343 #[test]
1344 fn test_from_tree_structure_direct() {
1345 let mut root_files = HashMap::new();
1347
1348 root_files.insert(
1349 "Quill.toml".to_string(),
1350 FileTreeNode::File {
1351 contents:
1352 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1353 .to_vec(),
1354 },
1355 );
1356
1357 root_files.insert(
1358 "glue.typ".to_string(),
1359 FileTreeNode::File {
1360 contents: b"glue content".to_vec(),
1361 },
1362 );
1363
1364 let mut src_files = HashMap::new();
1366 src_files.insert(
1367 "main.rs".to_string(),
1368 FileTreeNode::File {
1369 contents: b"fn main() {}".to_vec(),
1370 },
1371 );
1372
1373 root_files.insert(
1374 "src".to_string(),
1375 FileTreeNode::Directory { files: src_files },
1376 );
1377
1378 let root = FileTreeNode::Directory { files: root_files };
1379
1380 let quill = Quill::from_tree(root, None).unwrap();
1381
1382 assert_eq!(quill.name, "direct-tree");
1383 assert!(quill.file_exists("src/main.rs"));
1384 assert!(quill.file_exists("glue.typ"));
1385 }
1386
1387 #[test]
1388 fn test_from_json_with_metadata_override() {
1389 let json_str = r#"{
1391 "metadata": {
1392 "name": "override-name"
1393 },
1394 "files": {
1395 "Quill.toml": {
1396 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1397 },
1398 "glue.typ": {
1399 "contents": "= glue"
1400 }
1401 }
1402 }"#;
1403
1404 let quill = Quill::from_json(json_str).unwrap();
1405 assert_eq!(quill.name, "toml-name");
1408 }
1409
1410 #[test]
1411 fn test_from_json_empty_directory() {
1412 let json_str = r#"{
1414 "files": {
1415 "Quill.toml": {
1416 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1417 },
1418 "glue.typ": {
1419 "contents": "glue"
1420 },
1421 "empty_dir": {}
1422 }
1423 }"#;
1424
1425 let quill = Quill::from_json(json_str).unwrap();
1426 assert_eq!(quill.name, "empty-dir-test");
1427 assert!(quill.dir_exists("empty_dir"));
1428 assert!(!quill.file_exists("empty_dir"));
1429 }
1430
1431 #[test]
1432 fn test_dir_exists_and_list_apis() {
1433 let mut root_files = HashMap::new();
1434
1435 root_files.insert(
1437 "Quill.toml".to_string(),
1438 FileTreeNode::File {
1439 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"\ndescription = \"Test quill\"\n"
1440 .to_vec(),
1441 },
1442 );
1443
1444 root_files.insert(
1446 "glue.typ".to_string(),
1447 FileTreeNode::File {
1448 contents: b"glue content".to_vec(),
1449 },
1450 );
1451
1452 let mut assets_files = HashMap::new();
1454 assets_files.insert(
1455 "logo.png".to_string(),
1456 FileTreeNode::File {
1457 contents: vec![137, 80, 78, 71],
1458 },
1459 );
1460 assets_files.insert(
1461 "icon.svg".to_string(),
1462 FileTreeNode::File {
1463 contents: b"<svg></svg>".to_vec(),
1464 },
1465 );
1466
1467 let mut fonts_files = HashMap::new();
1469 fonts_files.insert(
1470 "font.ttf".to_string(),
1471 FileTreeNode::File {
1472 contents: b"font data".to_vec(),
1473 },
1474 );
1475 assets_files.insert(
1476 "fonts".to_string(),
1477 FileTreeNode::Directory { files: fonts_files },
1478 );
1479
1480 root_files.insert(
1481 "assets".to_string(),
1482 FileTreeNode::Directory {
1483 files: assets_files,
1484 },
1485 );
1486
1487 root_files.insert(
1489 "empty".to_string(),
1490 FileTreeNode::Directory {
1491 files: HashMap::new(),
1492 },
1493 );
1494
1495 let root = FileTreeNode::Directory { files: root_files };
1496 let quill = Quill::from_tree(root, None).unwrap();
1497
1498 assert!(quill.dir_exists("assets"));
1500 assert!(quill.dir_exists("assets/fonts"));
1501 assert!(quill.dir_exists("empty"));
1502 assert!(!quill.dir_exists("nonexistent"));
1503 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1507 assert!(quill.file_exists("assets/logo.png"));
1508 assert!(quill.file_exists("assets/fonts/font.ttf"));
1509 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1513 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1515 assert!(root_files_list.contains(&"glue.typ".to_string()));
1516
1517 let assets_files_list = quill.list_files("assets");
1518 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1520 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1521
1522 let root_subdirs = quill.list_subdirectories("");
1524 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1526 assert!(root_subdirs.contains(&"empty".to_string()));
1527
1528 let assets_subdirs = quill.list_subdirectories("assets");
1529 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1531
1532 let empty_subdirs = quill.list_subdirectories("empty");
1533 assert_eq!(empty_subdirs.len(), 0);
1534 }
1535
1536 #[test]
1537 fn test_field_schemas_parsing() {
1538 let mut root_files = HashMap::new();
1539
1540 let quill_toml = r#"[Quill]
1542name = "taro"
1543backend = "typst"
1544glue = "glue.typ"
1545example = "taro.md"
1546description = "Test template for field schemas"
1547
1548[fields]
1549author = {description = "Author of document", required = true}
1550ice_cream = {description = "favorite ice cream flavor"}
1551title = {description = "title of document", required = true}
1552"#;
1553 root_files.insert(
1554 "Quill.toml".to_string(),
1555 FileTreeNode::File {
1556 contents: quill_toml.as_bytes().to_vec(),
1557 },
1558 );
1559
1560 let glue_content = "= Test Template\n\nThis is a test.";
1562 root_files.insert(
1563 "glue.typ".to_string(),
1564 FileTreeNode::File {
1565 contents: glue_content.as_bytes().to_vec(),
1566 },
1567 );
1568
1569 root_files.insert(
1571 "taro.md".to_string(),
1572 FileTreeNode::File {
1573 contents: b"# Template".to_vec(),
1574 },
1575 );
1576
1577 let root = FileTreeNode::Directory { files: root_files };
1578
1579 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1581
1582 assert_eq!(quill.field_schemas.len(), 3);
1584 assert!(quill.field_schemas.contains_key("author"));
1585 assert!(quill.field_schemas.contains_key("ice_cream"));
1586 assert!(quill.field_schemas.contains_key("title"));
1587
1588 let author_schema = quill.field_schemas.get("author").unwrap();
1590 assert_eq!(author_schema.description, "Author of document");
1591 assert_eq!(author_schema.required, true);
1592
1593 let ice_cream_schema = quill.field_schemas.get("ice_cream").unwrap();
1595 assert_eq!(ice_cream_schema.description, "favorite ice cream flavor");
1596 assert_eq!(ice_cream_schema.required, false);
1597
1598 let title_schema = quill.field_schemas.get("title").unwrap();
1600 assert_eq!(title_schema.description, "title of document");
1601 assert_eq!(title_schema.required, true);
1602 }
1603
1604 #[test]
1605 fn test_field_schema_struct() {
1606 let schema1 = FieldSchema::new("Test description".to_string());
1608 assert_eq!(schema1.description, "Test description");
1609 assert_eq!(schema1.required, false);
1610 assert_eq!(schema1.r#type, None);
1611 assert_eq!(schema1.example, None);
1612 assert_eq!(schema1.default, None);
1613
1614 let yaml_str = r#"
1616description: "Full field schema"
1617required: true
1618type: "string"
1619example: "Example value"
1620default: "Default value"
1621"#;
1622 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1623 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1624 let schema2 = FieldSchema::from_quill_value(&quill_value).unwrap();
1625 assert_eq!(schema2.description, "Full field schema");
1626 assert_eq!(schema2.required, true);
1627 assert_eq!(schema2.r#type, Some("string".to_string()));
1628 assert_eq!(
1629 schema2.example.as_ref().and_then(|v| v.as_str()),
1630 Some("Example value")
1631 );
1632 assert_eq!(
1633 schema2.default.as_ref().and_then(|v| v.as_str()),
1634 Some("Default value")
1635 );
1636
1637 let quill_value = schema2.to_quill_value();
1639 let obj = quill_value.as_object().unwrap();
1640 assert_eq!(
1641 obj.get("description").unwrap().as_str().unwrap(),
1642 "Full field schema"
1643 );
1644 assert_eq!(obj.get("required").unwrap().as_bool().unwrap(), true);
1645 assert_eq!(obj.get("type").unwrap().as_str().unwrap(), "string");
1646 }
1647}