1use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use crate::validation::build_schema_from_fields;
8use crate::value::QuillValue;
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct FieldSchema {
13 pub name: String,
14 pub r#type: Option<String>,
16 pub description: String,
18 pub default: Option<QuillValue>,
20 pub example: Option<QuillValue>,
22}
23
24impl FieldSchema {
25 pub fn new(name: String, description: String) -> Self {
27 Self {
28 name,
29 r#type: None,
30 description,
31 example: None,
32 default: None,
33 }
34 }
35
36 pub fn from_quill_value(key: String, 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 for key in obj.keys() {
44 match key.as_str() {
45 "name" | "type" | "description" | "example" | "default" => {}
46 _ => {
47 return Err(format!("Unknown key '{}' in field schema", key));
48 }
49 }
50 }
51
52 let name = key.clone();
53
54 let description = obj
55 .get("description")
56 .and_then(|v| v.as_str())
57 .unwrap_or("")
58 .to_string();
59
60 let field_type = obj
61 .get("type")
62 .and_then(|v| v.as_str())
63 .map(|s| s.to_string());
64
65 let example = obj.get("example").map(|v| QuillValue::from_json(v.clone()));
66
67 let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
68
69 Ok(Self {
70 name: name,
71 r#type: field_type,
72 description,
73 example,
74 default,
75 })
76 }
77}
78
79#[derive(Debug, Clone)]
81pub enum FileTreeNode {
82 File {
84 contents: Vec<u8>,
86 },
87 Directory {
89 files: HashMap<String, FileTreeNode>,
91 },
92}
93
94impl FileTreeNode {
95 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
97 let path = path.as_ref();
98
99 if path == Path::new("") {
101 return Some(self);
102 }
103
104 let components: Vec<_> = path
106 .components()
107 .filter_map(|c| {
108 if let std::path::Component::Normal(s) = c {
109 s.to_str()
110 } else {
111 None
112 }
113 })
114 .collect();
115
116 if components.is_empty() {
117 return Some(self);
118 }
119
120 let mut current_node = self;
122 for component in components {
123 match current_node {
124 FileTreeNode::Directory { files } => {
125 current_node = files.get(component)?;
126 }
127 FileTreeNode::File { .. } => {
128 return None; }
130 }
131 }
132
133 Some(current_node)
134 }
135
136 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
138 match self.get_node(path)? {
139 FileTreeNode::File { contents } => Some(contents.as_slice()),
140 FileTreeNode::Directory { .. } => None,
141 }
142 }
143
144 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
146 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
147 }
148
149 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
151 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
152 }
153
154 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
156 match self.get_node(dir_path) {
157 Some(FileTreeNode::Directory { files }) => files
158 .iter()
159 .filter_map(|(name, node)| {
160 if matches!(node, FileTreeNode::File { .. }) {
161 Some(name.clone())
162 } else {
163 None
164 }
165 })
166 .collect(),
167 _ => Vec::new(),
168 }
169 }
170
171 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
173 match self.get_node(dir_path) {
174 Some(FileTreeNode::Directory { files }) => files
175 .iter()
176 .filter_map(|(name, node)| {
177 if matches!(node, FileTreeNode::Directory { .. }) {
178 Some(name.clone())
179 } else {
180 None
181 }
182 })
183 .collect(),
184 _ => Vec::new(),
185 }
186 }
187
188 pub fn insert<P: AsRef<Path>>(
190 &mut self,
191 path: P,
192 node: FileTreeNode,
193 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
194 let path = path.as_ref();
195
196 let components: Vec<_> = path
198 .components()
199 .filter_map(|c| {
200 if let std::path::Component::Normal(s) = c {
201 s.to_str().map(|s| s.to_string())
202 } else {
203 None
204 }
205 })
206 .collect();
207
208 if components.is_empty() {
209 return Err("Cannot insert at root path".into());
210 }
211
212 let mut current_node = self;
214 for component in &components[..components.len() - 1] {
215 match current_node {
216 FileTreeNode::Directory { files } => {
217 current_node =
218 files
219 .entry(component.clone())
220 .or_insert_with(|| FileTreeNode::Directory {
221 files: HashMap::new(),
222 });
223 }
224 FileTreeNode::File { .. } => {
225 return Err("Cannot traverse into a file".into());
226 }
227 }
228 }
229
230 let filename = &components[components.len() - 1];
232 match current_node {
233 FileTreeNode::Directory { files } => {
234 files.insert(filename.clone(), node);
235 Ok(())
236 }
237 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
238 }
239 }
240
241 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
243 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
244 Ok(FileTreeNode::File {
246 contents: contents_str.as_bytes().to_vec(),
247 })
248 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
249 let contents: Vec<u8> = bytes_array
251 .iter()
252 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
253 .collect();
254 Ok(FileTreeNode::File { contents })
255 } else if let Some(obj) = value.as_object() {
256 let mut files = HashMap::new();
258 for (name, child_value) in obj {
259 files.insert(name.clone(), Self::from_json_value(child_value)?);
260 }
261 Ok(FileTreeNode::Directory { files })
263 } else {
264 Err(format!("Invalid file tree node: {:?}", value).into())
265 }
266 }
267
268 pub fn print_tree(&self) -> String {
269 self.__print_tree("", "", true)
270 }
271
272 pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
273 let mut result = String::new();
274
275 let connector = if is_last { "└── " } else { "├── " };
277 let extension = if is_last { " " } else { "│ " };
278
279 match self {
280 FileTreeNode::File { .. } => {
281 result.push_str(&format!("{}{}{}\n", prefix, connector, name));
282 }
283 FileTreeNode::Directory { files } => {
284 result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
286
287 let child_prefix = format!("{}{}", prefix, extension);
288 let count = files.len();
289
290 for (i, (child_name, node)) in files.iter().enumerate() {
291 let is_last_child = i == count - 1;
292 result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
293 }
294 }
295 }
296
297 result
298 }
299}
300
301#[derive(Debug, Clone)]
303pub struct QuillIgnore {
304 patterns: Vec<String>,
305}
306
307impl QuillIgnore {
308 pub fn new(patterns: Vec<String>) -> Self {
310 Self { patterns }
311 }
312
313 pub fn from_content(content: &str) -> Self {
315 let patterns = content
316 .lines()
317 .map(|line| line.trim())
318 .filter(|line| !line.is_empty() && !line.starts_with('#'))
319 .map(|line| line.to_string())
320 .collect();
321 Self::new(patterns)
322 }
323
324 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
326 let path = path.as_ref();
327 let path_str = path.to_string_lossy();
328
329 for pattern in &self.patterns {
330 if self.matches_pattern(pattern, &path_str) {
331 return true;
332 }
333 }
334 false
335 }
336
337 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
339 if pattern.ends_with('/') {
341 let pattern_prefix = &pattern[..pattern.len() - 1];
342 return path.starts_with(pattern_prefix)
343 && (path.len() == pattern_prefix.len()
344 || path.chars().nth(pattern_prefix.len()) == Some('/'));
345 }
346
347 if !pattern.contains('*') {
349 return path == pattern || path.ends_with(&format!("/{}", pattern));
350 }
351
352 if pattern == "*" {
354 return true;
355 }
356
357 let pattern_parts: Vec<&str> = pattern.split('*').collect();
359 if pattern_parts.len() == 2 {
360 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
361 if prefix.is_empty() {
362 return path.ends_with(suffix);
363 } else if suffix.is_empty() {
364 return path.starts_with(prefix);
365 } else {
366 return path.starts_with(prefix) && path.ends_with(suffix);
367 }
368 }
369
370 false
371 }
372}
373
374#[derive(Debug, Clone)]
376pub struct Quill {
377 pub metadata: HashMap<String, QuillValue>,
379 pub name: String,
381 pub backend: String,
383 pub glue: Option<String>,
385 pub example: Option<String>,
387 pub schema: QuillValue,
389 pub field_schemas: HashMap<String, FieldSchema>,
391 pub files: FileTreeNode,
393}
394
395#[derive(Debug, Clone)]
397pub struct QuillConfig {
398 pub name: String,
400 pub description: String,
402 pub backend: String,
404 pub version: Option<String>,
406 pub author: Option<String>,
408 pub example_file: Option<String>,
410 pub glue_file: Option<String>,
412 pub json_schema_file: Option<String>,
414 pub fields: HashMap<String, FieldSchema>,
416 pub metadata: HashMap<String, QuillValue>,
418 pub typst_config: HashMap<String, QuillValue>,
420}
421
422impl QuillConfig {
423 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
425 let quill_toml: toml::Value = toml::from_str(toml_content)
426 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
427
428 let quill_section = quill_toml
430 .get("Quill")
431 .ok_or("Missing required [Quill] section in Quill.toml")?;
432
433 let name = quill_section
435 .get("name")
436 .and_then(|v| v.as_str())
437 .ok_or("Missing required 'name' field in [Quill] section")?
438 .to_string();
439
440 let backend = quill_section
441 .get("backend")
442 .and_then(|v| v.as_str())
443 .ok_or("Missing required 'backend' field in [Quill] section")?
444 .to_string();
445
446 let description = quill_section
447 .get("description")
448 .and_then(|v| v.as_str())
449 .ok_or("Missing required 'description' field in [Quill] section")?;
450
451 if description.trim().is_empty() {
452 return Err("'description' field in [Quill] section cannot be empty".into());
453 }
454 let description = description.to_string();
455
456 let version = quill_section
458 .get("version")
459 .and_then(|v| v.as_str())
460 .map(|s| s.to_string());
461
462 let author = quill_section
463 .get("author")
464 .and_then(|v| v.as_str())
465 .map(|s| s.to_string());
466
467 let example_file = quill_section
468 .get("example_file")
469 .and_then(|v| v.as_str())
470 .map(|s| s.to_string());
471
472 let glue_file = quill_section
473 .get("glue_file")
474 .and_then(|v| v.as_str())
475 .map(|s| s.to_string());
476
477 let json_schema_file = quill_section
478 .get("json_schema_file")
479 .and_then(|v| v.as_str())
480 .map(|s| s.to_string());
481
482 let mut metadata = HashMap::new();
484 if let toml::Value::Table(table) = quill_section {
485 for (key, value) in table {
486 if key != "name"
488 && key != "backend"
489 && key != "description"
490 && key != "version"
491 && key != "author"
492 && key != "example_file"
493 && key != "glue_file"
494 && key != "json_schema_file"
495 {
496 match QuillValue::from_toml(value) {
497 Ok(quill_value) => {
498 metadata.insert(key.clone(), quill_value);
499 }
500 Err(e) => {
501 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
502 }
503 }
504 }
505 }
506 }
507
508 let mut typst_config = HashMap::new();
510 if let Some(typst_section) = quill_toml.get("typst") {
511 if let toml::Value::Table(table) = typst_section {
512 for (key, value) in table {
513 match QuillValue::from_toml(value) {
514 Ok(quill_value) => {
515 typst_config.insert(key.clone(), quill_value);
516 }
517 Err(e) => {
518 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
519 }
520 }
521 }
522 }
523 }
524
525 let mut fields = HashMap::new();
527 if let Some(fields_section) = quill_toml.get("fields") {
528 if let toml::Value::Table(fields_table) = fields_section {
529 for (field_name, field_schema) in fields_table {
530 match QuillValue::from_toml(field_schema) {
531 Ok(quill_value) => {
532 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
533 Ok(schema) => {
534 fields.insert(field_name.clone(), schema);
535 }
536 Err(e) => {
537 eprintln!(
538 "Warning: Failed to parse field schema '{}': {}",
539 field_name, e
540 );
541 }
542 }
543 }
544 Err(e) => {
545 eprintln!(
546 "Warning: Failed to convert field schema '{}': {}",
547 field_name, e
548 );
549 }
550 }
551 }
552 }
553 }
554
555 Ok(QuillConfig {
556 name,
557 description,
558 backend,
559 version,
560 author,
561 example_file,
562 glue_file,
563 json_schema_file,
564 fields,
565 metadata,
566 typst_config,
567 })
568 }
569}
570
571impl Quill {
572 pub fn from_path<P: AsRef<std::path::Path>>(
574 path: P,
575 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
576 use std::fs;
577
578 let path = path.as_ref();
579 let name = path
580 .file_name()
581 .and_then(|n| n.to_str())
582 .unwrap_or("unnamed")
583 .to_string();
584
585 let quillignore_path = path.join(".quillignore");
587 let ignore = if quillignore_path.exists() {
588 let ignore_content = fs::read_to_string(&quillignore_path)
589 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
590 QuillIgnore::from_content(&ignore_content)
591 } else {
592 QuillIgnore::new(vec![
594 ".git/".to_string(),
595 ".gitignore".to_string(),
596 ".quillignore".to_string(),
597 "target/".to_string(),
598 "node_modules/".to_string(),
599 ])
600 };
601
602 let root = Self::load_directory_as_tree(path, path, &ignore)?;
604
605 Self::from_tree(root, Some(name))
607 }
608
609 pub fn from_tree(
626 root: FileTreeNode,
627 _default_name: Option<String>,
628 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
629 let quill_toml_bytes = root
631 .get_file("Quill.toml")
632 .ok_or("Quill.toml not found in file tree")?;
633
634 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
635 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
636
637 let config = QuillConfig::from_toml(&quill_toml_content)?;
639
640 Self::from_config(config, root)
642 }
643
644 fn from_config(
662 config: QuillConfig,
663 root: FileTreeNode,
664 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
665 let mut metadata = config.metadata.clone();
667
668 metadata.insert(
670 "backend".to_string(),
671 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
672 );
673
674 metadata.insert(
676 "description".to_string(),
677 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
678 );
679
680 if let Some(ref author) = config.author {
682 metadata.insert(
683 "author".to_string(),
684 QuillValue::from_json(serde_json::Value::String(author.clone())),
685 );
686 }
687
688 for (key, value) in &config.typst_config {
690 metadata.insert(format!("typst_{}", key), value.clone());
691 }
692
693 if let Some(ref json_schema_path) = config.json_schema_file {
695 let schema_bytes = root.get_file(json_schema_path).ok_or_else(|| {
697 format!(
698 "json_schema_file '{}' not found in file tree",
699 json_schema_path
700 )
701 })?;
702
703 serde_json::from_slice::<serde_json::Value>(schema_bytes).map_err(|e| {
705 format!(
706 "json_schema_file '{}' is not valid JSON: {}",
707 json_schema_path, e
708 )
709 })?;
710
711 if !config.fields.is_empty() {
713 eprintln!("Warning: [fields] section is overridden by json_schema_file");
714 }
715 }
716
717 let schema = build_schema_from_fields(&config.fields)
719 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
720
721 let glue_content: Option<String> = if let Some(ref glue_file_name) = config.glue_file {
723 let glue_bytes = root
724 .get_file(glue_file_name)
725 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
726
727 let content = String::from_utf8(glue_bytes.to_vec())
728 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?;
729 Some(content)
730 } else {
731 None
733 };
734
735 let example_content = if let Some(ref example_file_name) = config.example_file {
737 root.get_file(example_file_name).and_then(|bytes| {
738 String::from_utf8(bytes.to_vec())
739 .map_err(|e| {
740 eprintln!(
741 "Warning: Example file '{}' is not valid UTF-8: {}",
742 example_file_name, e
743 );
744 e
745 })
746 .ok()
747 })
748 } else {
749 None
750 };
751
752 let quill = Quill {
753 metadata,
754 name: config.name,
755 backend: config.backend,
756 glue: glue_content,
757 example: example_content,
758 schema,
759 field_schemas: config.fields.clone(),
760 files: root,
761 };
762
763 Ok(quill)
764 }
765
766 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
773 use serde_json::Value as JsonValue;
774
775 let json: JsonValue =
776 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
777
778 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
779
780 let default_name = obj
782 .get("metadata")
783 .and_then(|m| m.get("name"))
784 .and_then(|v| v.as_str())
785 .map(String::from);
786
787 let files_obj = obj
789 .get("files")
790 .and_then(|v| v.as_object())
791 .ok_or_else(|| "Missing or invalid 'files' key")?;
792
793 let mut root_files = HashMap::new();
795 for (key, value) in files_obj {
796 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
797 }
798
799 let root = FileTreeNode::Directory { files: root_files };
800
801 Self::from_tree(root, default_name)
803 }
804
805 fn load_directory_as_tree(
807 current_dir: &Path,
808 base_dir: &Path,
809 ignore: &QuillIgnore,
810 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
811 use std::fs;
812
813 if !current_dir.exists() {
814 return Ok(FileTreeNode::Directory {
815 files: HashMap::new(),
816 });
817 }
818
819 let mut files = HashMap::new();
820
821 for entry in fs::read_dir(current_dir)? {
822 let entry = entry?;
823 let path = entry.path();
824 let relative_path = path
825 .strip_prefix(base_dir)
826 .map_err(|e| format!("Failed to get relative path: {}", e))?
827 .to_path_buf();
828
829 if ignore.is_ignored(&relative_path) {
831 continue;
832 }
833
834 let filename = path
836 .file_name()
837 .and_then(|n| n.to_str())
838 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
839 .to_string();
840
841 if path.is_file() {
842 let contents = fs::read(&path)
843 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
844
845 files.insert(filename, FileTreeNode::File { contents });
846 } else if path.is_dir() {
847 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
849 files.insert(filename, subdir_tree);
850 }
851 }
852
853 Ok(FileTreeNode::Directory { files })
854 }
855
856 pub fn typst_packages(&self) -> Vec<String> {
858 self.metadata
859 .get("typst_packages")
860 .and_then(|v| v.as_array())
861 .map(|arr| {
862 arr.iter()
863 .filter_map(|v| v.as_str().map(|s| s.to_string()))
864 .collect()
865 })
866 .unwrap_or_default()
867 }
868
869 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
871 self.files.get_file(path)
872 }
873
874 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
876 self.files.file_exists(path)
877 }
878
879 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
881 self.files.dir_exists(path)
882 }
883
884 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
886 self.files.list_files(path)
887 }
888
889 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
891 self.files.list_subdirectories(path)
892 }
893
894 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
896 let dir_path = dir_path.as_ref();
897 let filenames = self.files.list_files(dir_path);
898
899 filenames
901 .iter()
902 .map(|name| {
903 if dir_path == Path::new("") {
904 PathBuf::from(name)
905 } else {
906 dir_path.join(name)
907 }
908 })
909 .collect()
910 }
911
912 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
914 let dir_path = dir_path.as_ref();
915 let subdirs = self.files.list_subdirectories(dir_path);
916
917 subdirs
919 .iter()
920 .map(|name| {
921 if dir_path == Path::new("") {
922 PathBuf::from(name)
923 } else {
924 dir_path.join(name)
925 }
926 })
927 .collect()
928 }
929
930 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
932 let pattern_str = pattern.as_ref().to_string_lossy();
933 let mut matches = Vec::new();
934
935 let glob_pattern = match glob::Pattern::new(&pattern_str) {
937 Ok(pat) => pat,
938 Err(_) => return matches, };
940
941 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
943
944 matches.sort();
945 matches
946 }
947
948 fn find_files_recursive(
950 &self,
951 node: &FileTreeNode,
952 current_path: &Path,
953 pattern: &glob::Pattern,
954 matches: &mut Vec<PathBuf>,
955 ) {
956 match node {
957 FileTreeNode::File { .. } => {
958 let path_str = current_path.to_string_lossy();
959 if pattern.matches(&path_str) {
960 matches.push(current_path.to_path_buf());
961 }
962 }
963 FileTreeNode::Directory { files } => {
964 for (name, child_node) in files {
965 let child_path = if current_path == Path::new("") {
966 PathBuf::from(name)
967 } else {
968 current_path.join(name)
969 };
970 self.find_files_recursive(child_node, &child_path, pattern, matches);
971 }
972 }
973 }
974 }
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980 use std::fs;
981 use tempfile::TempDir;
982
983 #[test]
984 fn test_quillignore_parsing() {
985 let ignore_content = r#"
986# This is a comment
987*.tmp
988target/
989node_modules/
990.git/
991"#;
992 let ignore = QuillIgnore::from_content(ignore_content);
993 assert_eq!(ignore.patterns.len(), 4);
994 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
995 assert!(ignore.patterns.contains(&"target/".to_string()));
996 }
997
998 #[test]
999 fn test_quillignore_matching() {
1000 let ignore = QuillIgnore::new(vec![
1001 "*.tmp".to_string(),
1002 "target/".to_string(),
1003 "node_modules/".to_string(),
1004 ".git/".to_string(),
1005 ]);
1006
1007 assert!(ignore.is_ignored("test.tmp"));
1009 assert!(ignore.is_ignored("path/to/file.tmp"));
1010 assert!(!ignore.is_ignored("test.txt"));
1011
1012 assert!(ignore.is_ignored("target"));
1014 assert!(ignore.is_ignored("target/debug"));
1015 assert!(ignore.is_ignored("target/debug/deps"));
1016 assert!(!ignore.is_ignored("src/target.rs"));
1017
1018 assert!(ignore.is_ignored("node_modules"));
1019 assert!(ignore.is_ignored("node_modules/package"));
1020 assert!(!ignore.is_ignored("my_node_modules"));
1021 }
1022
1023 #[test]
1024 fn test_in_memory_file_system() {
1025 let temp_dir = TempDir::new().unwrap();
1026 let quill_dir = temp_dir.path();
1027
1028 fs::write(
1030 quill_dir.join("Quill.toml"),
1031 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1032 )
1033 .unwrap();
1034 fs::write(quill_dir.join("glue.typ"), "test glue").unwrap();
1035
1036 let assets_dir = quill_dir.join("assets");
1037 fs::create_dir_all(&assets_dir).unwrap();
1038 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1039
1040 let packages_dir = quill_dir.join("packages");
1041 fs::create_dir_all(&packages_dir).unwrap();
1042 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1043
1044 let quill = Quill::from_path(quill_dir).unwrap();
1046
1047 assert!(quill.file_exists("glue.typ"));
1049 assert!(quill.file_exists("assets/test.txt"));
1050 assert!(quill.file_exists("packages/package.typ"));
1051 assert!(!quill.file_exists("nonexistent.txt"));
1052
1053 let asset_content = quill.get_file("assets/test.txt").unwrap();
1055 assert_eq!(asset_content, b"asset content");
1056
1057 let asset_files = quill.list_directory("assets");
1059 assert_eq!(asset_files.len(), 1);
1060 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1061 }
1062
1063 #[test]
1064 fn test_quillignore_integration() {
1065 let temp_dir = TempDir::new().unwrap();
1066 let quill_dir = temp_dir.path();
1067
1068 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1070
1071 fs::write(
1073 quill_dir.join("Quill.toml"),
1074 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1075 )
1076 .unwrap();
1077 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
1078 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1079
1080 let target_dir = quill_dir.join("target");
1081 fs::create_dir_all(&target_dir).unwrap();
1082 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1083
1084 let quill = Quill::from_path(quill_dir).unwrap();
1086
1087 assert!(quill.file_exists("glue.typ"));
1089 assert!(!quill.file_exists("should_ignore.tmp"));
1090 assert!(!quill.file_exists("target/debug.txt"));
1091 }
1092
1093 #[test]
1094 fn test_find_files_pattern() {
1095 let temp_dir = TempDir::new().unwrap();
1096 let quill_dir = temp_dir.path();
1097
1098 fs::write(
1100 quill_dir.join("Quill.toml"),
1101 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1102 )
1103 .unwrap();
1104 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
1105
1106 let assets_dir = quill_dir.join("assets");
1107 fs::create_dir_all(&assets_dir).unwrap();
1108 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1109 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1110
1111 let fonts_dir = assets_dir.join("fonts");
1112 fs::create_dir_all(&fonts_dir).unwrap();
1113 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1114
1115 let quill = Quill::from_path(quill_dir).unwrap();
1117
1118 let all_assets = quill.find_files("assets/*");
1120 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1123 assert_eq!(typ_files.len(), 1);
1124 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1125 }
1126
1127 #[test]
1128 fn test_new_standardized_toml_format() {
1129 let temp_dir = TempDir::new().unwrap();
1130 let quill_dir = temp_dir.path();
1131
1132 let toml_content = r#"[Quill]
1134name = "my-custom-quill"
1135backend = "typst"
1136glue_file = "custom_glue.typ"
1137description = "Test quill with new format"
1138author = "Test Author"
1139"#;
1140 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1141 fs::write(
1142 quill_dir.join("custom_glue.typ"),
1143 "= Custom Template\n\nThis is a custom template.",
1144 )
1145 .unwrap();
1146
1147 let quill = Quill::from_path(quill_dir).unwrap();
1149
1150 assert_eq!(quill.name, "my-custom-quill");
1152
1153 assert!(quill.metadata.contains_key("backend"));
1155 if let Some(backend_val) = quill.metadata.get("backend") {
1156 if let Some(backend_str) = backend_val.as_str() {
1157 assert_eq!(backend_str, "typst");
1158 } else {
1159 panic!("Backend value is not a string");
1160 }
1161 }
1162
1163 assert!(quill.metadata.contains_key("description"));
1165 assert!(quill.metadata.contains_key("author"));
1166 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue.unwrap().contains("Custom Template"));
1170 }
1171
1172 #[test]
1173 fn test_typst_packages_parsing() {
1174 let temp_dir = TempDir::new().unwrap();
1175 let quill_dir = temp_dir.path();
1176
1177 let toml_content = r#"
1178[Quill]
1179name = "test-quill"
1180backend = "typst"
1181glue_file = "glue.typ"
1182description = "Test quill for packages"
1183
1184[typst]
1185packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1186"#;
1187
1188 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1189 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1190
1191 let quill = Quill::from_path(quill_dir).unwrap();
1192 let packages = quill.typst_packages();
1193
1194 assert_eq!(packages.len(), 2);
1195 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1196 assert_eq!(packages[1], "@preview/example:1.0.0");
1197 }
1198
1199 #[test]
1200 fn test_template_loading() {
1201 let temp_dir = TempDir::new().unwrap();
1202 let quill_dir = temp_dir.path();
1203
1204 let toml_content = r#"[Quill]
1206name = "test-with-template"
1207backend = "typst"
1208glue_file = "glue.typ"
1209example_file = "example.md"
1210description = "Test quill with template"
1211"#;
1212 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1213 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1214 fs::write(
1215 quill_dir.join("example.md"),
1216 "---\ntitle: Test\n---\n\nThis is a test template.",
1217 )
1218 .unwrap();
1219
1220 let quill = Quill::from_path(quill_dir).unwrap();
1222
1223 assert!(quill.example.is_some());
1225 let example = quill.example.unwrap();
1226 assert!(example.contains("title: Test"));
1227 assert!(example.contains("This is a test template"));
1228
1229 assert_eq!(quill.glue.unwrap(), "glue content");
1231 }
1232
1233 #[test]
1234 fn test_template_optional() {
1235 let temp_dir = TempDir::new().unwrap();
1236 let quill_dir = temp_dir.path();
1237
1238 let toml_content = r#"[Quill]
1240name = "test-without-template"
1241backend = "typst"
1242glue_file = "glue.typ"
1243description = "Test quill without template"
1244"#;
1245 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1246 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1247
1248 let quill = Quill::from_path(quill_dir).unwrap();
1250
1251 assert_eq!(quill.example, None);
1253
1254 assert_eq!(quill.glue.unwrap(), "glue content");
1256 }
1257
1258 #[test]
1259 fn test_from_tree() {
1260 let mut root_files = HashMap::new();
1262
1263 let quill_toml = r#"[Quill]
1265name = "test-from-tree"
1266backend = "typst"
1267glue_file = "glue.typ"
1268description = "A test quill from tree"
1269"#;
1270 root_files.insert(
1271 "Quill.toml".to_string(),
1272 FileTreeNode::File {
1273 contents: quill_toml.as_bytes().to_vec(),
1274 },
1275 );
1276
1277 let glue_content = "= Test Template\n\nThis is a test.";
1279 root_files.insert(
1280 "glue.typ".to_string(),
1281 FileTreeNode::File {
1282 contents: glue_content.as_bytes().to_vec(),
1283 },
1284 );
1285
1286 let root = FileTreeNode::Directory { files: root_files };
1287
1288 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1290
1291 assert_eq!(quill.name, "test-from-tree");
1293 assert_eq!(quill.glue.unwrap(), glue_content);
1294 assert!(quill.metadata.contains_key("backend"));
1295 assert!(quill.metadata.contains_key("description"));
1296 }
1297
1298 #[test]
1299 fn test_from_tree_with_template() {
1300 let mut root_files = HashMap::new();
1301
1302 let quill_toml = r#"[Quill]
1304name = "test-tree-template"
1305backend = "typst"
1306glue_file = "glue.typ"
1307example_file = "template.md"
1308description = "Test tree with template"
1309"#;
1310 root_files.insert(
1311 "Quill.toml".to_string(),
1312 FileTreeNode::File {
1313 contents: quill_toml.as_bytes().to_vec(),
1314 },
1315 );
1316
1317 root_files.insert(
1319 "glue.typ".to_string(),
1320 FileTreeNode::File {
1321 contents: b"glue content".to_vec(),
1322 },
1323 );
1324
1325 let template_content = "# {{ title }}\n\n{{ body }}";
1327 root_files.insert(
1328 "template.md".to_string(),
1329 FileTreeNode::File {
1330 contents: template_content.as_bytes().to_vec(),
1331 },
1332 );
1333
1334 let root = FileTreeNode::Directory { files: root_files };
1335
1336 let quill = Quill::from_tree(root, None).unwrap();
1338
1339 assert_eq!(quill.example, Some(template_content.to_string()));
1341 }
1342
1343 #[test]
1344 fn test_from_json() {
1345 let json_str = r#"{
1347 "metadata": {
1348 "name": "test-from-json"
1349 },
1350 "files": {
1351 "Quill.toml": {
1352 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1353 },
1354 "glue.typ": {
1355 "contents": "= Test Glue\n\nThis is test content."
1356 }
1357 }
1358 }"#;
1359
1360 let quill = Quill::from_json(json_str).unwrap();
1362
1363 assert_eq!(quill.name, "test-from-json");
1365 assert!(quill.glue.unwrap().contains("Test Glue"));
1366 assert!(quill.metadata.contains_key("backend"));
1367 }
1368
1369 #[test]
1370 fn test_from_json_with_byte_array() {
1371 let json_str = r#"{
1373 "files": {
1374 "Quill.toml": {
1375 "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, 95, 102, 105, 108, 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]
1376 },
1377 "glue.typ": {
1378 "contents": "test glue"
1379 }
1380 }
1381 }"#;
1382
1383 let quill = Quill::from_json(json_str).unwrap();
1385
1386 assert_eq!(quill.name, "test");
1388 assert_eq!(quill.glue.unwrap(), "test glue");
1389 }
1390
1391 #[test]
1392 fn test_from_json_missing_files() {
1393 let json_str = r#"{
1395 "metadata": {
1396 "name": "test"
1397 }
1398 }"#;
1399
1400 let result = Quill::from_json(json_str);
1401 assert!(result.is_err());
1402 assert!(result.unwrap_err().to_string().contains("files"));
1404 }
1405
1406 #[test]
1407 fn test_from_json_tree_structure() {
1408 let json_str = r#"{
1410 "files": {
1411 "Quill.toml": {
1412 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1413 },
1414 "glue.typ": {
1415 "contents": "= Test Glue\n\nTree structure content."
1416 }
1417 }
1418 }"#;
1419
1420 let quill = Quill::from_json(json_str).unwrap();
1421
1422 assert_eq!(quill.name, "test-tree-json");
1423 assert!(quill.glue.unwrap().contains("Tree structure content"));
1424 assert!(quill.metadata.contains_key("backend"));
1425 }
1426
1427 #[test]
1428 fn test_from_json_nested_tree_structure() {
1429 let json_str = r#"{
1431 "files": {
1432 "Quill.toml": {
1433 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Nested test\"\n"
1434 },
1435 "glue.typ": {
1436 "contents": "glue"
1437 },
1438 "src": {
1439 "main.rs": {
1440 "contents": "fn main() {}"
1441 },
1442 "lib.rs": {
1443 "contents": "// lib"
1444 }
1445 }
1446 }
1447 }"#;
1448
1449 let quill = Quill::from_json(json_str).unwrap();
1450
1451 assert_eq!(quill.name, "nested-test");
1452 assert!(quill.file_exists("src/main.rs"));
1454 assert!(quill.file_exists("src/lib.rs"));
1455
1456 let main_rs = quill.get_file("src/main.rs").unwrap();
1457 assert_eq!(main_rs, b"fn main() {}");
1458 }
1459
1460 #[test]
1461 fn test_from_tree_structure_direct() {
1462 let mut root_files = HashMap::new();
1464
1465 root_files.insert(
1466 "Quill.toml".to_string(),
1467 FileTreeNode::File {
1468 contents:
1469 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1470 .to_vec(),
1471 },
1472 );
1473
1474 root_files.insert(
1475 "glue.typ".to_string(),
1476 FileTreeNode::File {
1477 contents: b"glue content".to_vec(),
1478 },
1479 );
1480
1481 let mut src_files = HashMap::new();
1483 src_files.insert(
1484 "main.rs".to_string(),
1485 FileTreeNode::File {
1486 contents: b"fn main() {}".to_vec(),
1487 },
1488 );
1489
1490 root_files.insert(
1491 "src".to_string(),
1492 FileTreeNode::Directory { files: src_files },
1493 );
1494
1495 let root = FileTreeNode::Directory { files: root_files };
1496
1497 let quill = Quill::from_tree(root, None).unwrap();
1498
1499 assert_eq!(quill.name, "direct-tree");
1500 assert!(quill.file_exists("src/main.rs"));
1501 assert!(quill.file_exists("glue.typ"));
1502 }
1503
1504 #[test]
1505 fn test_from_json_with_metadata_override() {
1506 let json_str = r#"{
1508 "metadata": {
1509 "name": "override-name"
1510 },
1511 "files": {
1512 "Quill.toml": {
1513 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1514 },
1515 "glue.typ": {
1516 "contents": "= glue"
1517 }
1518 }
1519 }"#;
1520
1521 let quill = Quill::from_json(json_str).unwrap();
1522 assert_eq!(quill.name, "toml-name");
1525 }
1526
1527 #[test]
1528 fn test_from_json_empty_directory() {
1529 let json_str = r#"{
1531 "files": {
1532 "Quill.toml": {
1533 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1534 },
1535 "glue.typ": {
1536 "contents": "glue"
1537 },
1538 "empty_dir": {}
1539 }
1540 }"#;
1541
1542 let quill = Quill::from_json(json_str).unwrap();
1543 assert_eq!(quill.name, "empty-dir-test");
1544 assert!(quill.dir_exists("empty_dir"));
1545 assert!(!quill.file_exists("empty_dir"));
1546 }
1547
1548 #[test]
1549 fn test_dir_exists_and_list_apis() {
1550 let mut root_files = HashMap::new();
1551
1552 root_files.insert(
1554 "Quill.toml".to_string(),
1555 FileTreeNode::File {
1556 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"\n"
1557 .to_vec(),
1558 },
1559 );
1560
1561 root_files.insert(
1563 "glue.typ".to_string(),
1564 FileTreeNode::File {
1565 contents: b"glue content".to_vec(),
1566 },
1567 );
1568
1569 let mut assets_files = HashMap::new();
1571 assets_files.insert(
1572 "logo.png".to_string(),
1573 FileTreeNode::File {
1574 contents: vec![137, 80, 78, 71],
1575 },
1576 );
1577 assets_files.insert(
1578 "icon.svg".to_string(),
1579 FileTreeNode::File {
1580 contents: b"<svg></svg>".to_vec(),
1581 },
1582 );
1583
1584 let mut fonts_files = HashMap::new();
1586 fonts_files.insert(
1587 "font.ttf".to_string(),
1588 FileTreeNode::File {
1589 contents: b"font data".to_vec(),
1590 },
1591 );
1592 assets_files.insert(
1593 "fonts".to_string(),
1594 FileTreeNode::Directory { files: fonts_files },
1595 );
1596
1597 root_files.insert(
1598 "assets".to_string(),
1599 FileTreeNode::Directory {
1600 files: assets_files,
1601 },
1602 );
1603
1604 root_files.insert(
1606 "empty".to_string(),
1607 FileTreeNode::Directory {
1608 files: HashMap::new(),
1609 },
1610 );
1611
1612 let root = FileTreeNode::Directory { files: root_files };
1613 let quill = Quill::from_tree(root, None).unwrap();
1614
1615 assert!(quill.dir_exists("assets"));
1617 assert!(quill.dir_exists("assets/fonts"));
1618 assert!(quill.dir_exists("empty"));
1619 assert!(!quill.dir_exists("nonexistent"));
1620 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1624 assert!(quill.file_exists("assets/logo.png"));
1625 assert!(quill.file_exists("assets/fonts/font.ttf"));
1626 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1630 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1632 assert!(root_files_list.contains(&"glue.typ".to_string()));
1633
1634 let assets_files_list = quill.list_files("assets");
1635 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1637 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1638
1639 let root_subdirs = quill.list_subdirectories("");
1641 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1643 assert!(root_subdirs.contains(&"empty".to_string()));
1644
1645 let assets_subdirs = quill.list_subdirectories("assets");
1646 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1648
1649 let empty_subdirs = quill.list_subdirectories("empty");
1650 assert_eq!(empty_subdirs.len(), 0);
1651 }
1652
1653 #[test]
1654 fn test_field_schemas_parsing() {
1655 let mut root_files = HashMap::new();
1656
1657 let quill_toml = r#"[Quill]
1659name = "taro"
1660backend = "typst"
1661glue_file = "glue.typ"
1662example_file = "taro.md"
1663description = "Test template for field schemas"
1664
1665[fields]
1666author = {description = "Author of document" }
1667ice_cream = {description = "favorite ice cream flavor"}
1668title = {description = "title of document" }
1669"#;
1670 root_files.insert(
1671 "Quill.toml".to_string(),
1672 FileTreeNode::File {
1673 contents: quill_toml.as_bytes().to_vec(),
1674 },
1675 );
1676
1677 let glue_content = "= Test Template\n\nThis is a test.";
1679 root_files.insert(
1680 "glue.typ".to_string(),
1681 FileTreeNode::File {
1682 contents: glue_content.as_bytes().to_vec(),
1683 },
1684 );
1685
1686 root_files.insert(
1688 "taro.md".to_string(),
1689 FileTreeNode::File {
1690 contents: b"# Template".to_vec(),
1691 },
1692 );
1693
1694 let root = FileTreeNode::Directory { files: root_files };
1695
1696 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1698
1699 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1701 assert!(quill.schema["properties"]
1702 .as_object()
1703 .unwrap()
1704 .contains_key("author"));
1705 assert!(quill.schema["properties"]
1706 .as_object()
1707 .unwrap()
1708 .contains_key("ice_cream"));
1709 assert!(quill.schema["properties"]
1710 .as_object()
1711 .unwrap()
1712 .contains_key("title"));
1713
1714 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1716 assert_eq!(author_schema["description"], "Author of document");
1717
1718 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1720 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1721
1722 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1724 assert_eq!(title_schema["description"], "title of document");
1725 }
1726
1727 #[test]
1728 fn test_field_schema_struct() {
1729 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1731 assert_eq!(schema1.description, "Test description");
1732 assert_eq!(schema1.r#type, None);
1733 assert_eq!(schema1.example, None);
1734 assert_eq!(schema1.default, None);
1735
1736 let yaml_str = r#"
1738description: "Full field schema"
1739type: "string"
1740example: "Example value"
1741default: "Default value"
1742"#;
1743 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1744 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1745 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1746 assert_eq!(schema2.name, "test_name");
1747 assert_eq!(schema2.description, "Full field schema");
1748 assert_eq!(schema2.r#type, Some("string".to_string()));
1749 assert_eq!(
1750 schema2.example.as_ref().and_then(|v| v.as_str()),
1751 Some("Example value")
1752 );
1753 assert_eq!(
1754 schema2.default.as_ref().and_then(|v| v.as_str()),
1755 Some("Default value")
1756 );
1757 }
1758
1759 #[test]
1760 fn test_quill_without_glue_file() {
1761 let mut root_files = HashMap::new();
1763
1764 let quill_toml = r#"[Quill]
1766name = "test-no-glue"
1767backend = "typst"
1768description = "Test quill without glue file"
1769"#;
1770 root_files.insert(
1771 "Quill.toml".to_string(),
1772 FileTreeNode::File {
1773 contents: quill_toml.as_bytes().to_vec(),
1774 },
1775 );
1776
1777 let root = FileTreeNode::Directory { files: root_files };
1778
1779 let quill = Quill::from_tree(root, None).unwrap();
1781
1782 assert!(quill.glue.clone().is_none());
1784 assert_eq!(quill.name, "test-no-glue");
1785 }
1786
1787 #[test]
1788 fn test_quill_config_from_toml() {
1789 let toml_content = r#"[Quill]
1791name = "test-config"
1792backend = "typst"
1793description = "Test configuration parsing"
1794version = "1.0.0"
1795author = "Test Author"
1796glue_file = "glue.typ"
1797example_file = "example.md"
1798
1799[typst]
1800packages = ["@preview/bubble:0.2.2"]
1801
1802[fields]
1803title = {description = "Document title", type = "string"}
1804author = {description = "Document author"}
1805"#;
1806
1807 let config = QuillConfig::from_toml(toml_content).unwrap();
1808
1809 assert_eq!(config.name, "test-config");
1811 assert_eq!(config.backend, "typst");
1812 assert_eq!(config.description, "Test configuration parsing");
1813
1814 assert_eq!(config.version, Some("1.0.0".to_string()));
1816 assert_eq!(config.author, Some("Test Author".to_string()));
1817 assert_eq!(config.glue_file, Some("glue.typ".to_string()));
1818 assert_eq!(config.example_file, Some("example.md".to_string()));
1819
1820 assert!(config.typst_config.contains_key("packages"));
1822
1823 assert_eq!(config.fields.len(), 2);
1825 assert!(config.fields.contains_key("title"));
1826 assert!(config.fields.contains_key("author"));
1827
1828 let title_field = &config.fields["title"];
1829 assert_eq!(title_field.description, "Document title");
1830 assert_eq!(title_field.r#type, Some("string".to_string()));
1831 }
1832
1833 #[test]
1834 fn test_quill_config_missing_required_fields() {
1835 let toml_missing_name = r#"[Quill]
1837backend = "typst"
1838description = "Missing name"
1839"#;
1840 let result = QuillConfig::from_toml(toml_missing_name);
1841 assert!(result.is_err());
1842 assert!(result
1843 .unwrap_err()
1844 .to_string()
1845 .contains("Missing required 'name'"));
1846
1847 let toml_missing_backend = r#"[Quill]
1848name = "test"
1849description = "Missing backend"
1850"#;
1851 let result = QuillConfig::from_toml(toml_missing_backend);
1852 assert!(result.is_err());
1853 assert!(result
1854 .unwrap_err()
1855 .to_string()
1856 .contains("Missing required 'backend'"));
1857
1858 let toml_missing_description = r#"[Quill]
1859name = "test"
1860backend = "typst"
1861"#;
1862 let result = QuillConfig::from_toml(toml_missing_description);
1863 assert!(result.is_err());
1864 assert!(result
1865 .unwrap_err()
1866 .to_string()
1867 .contains("Missing required 'description'"));
1868 }
1869
1870 #[test]
1871 fn test_quill_config_empty_description() {
1872 let toml_empty_description = r#"[Quill]
1874name = "test"
1875backend = "typst"
1876description = " "
1877"#;
1878 let result = QuillConfig::from_toml(toml_empty_description);
1879 assert!(result.is_err());
1880 assert!(result
1881 .unwrap_err()
1882 .to_string()
1883 .contains("description' field in [Quill] section cannot be empty"));
1884 }
1885
1886 #[test]
1887 fn test_quill_config_missing_quill_section() {
1888 let toml_no_section = r#"[fields]
1890title = {description = "Title"}
1891"#;
1892 let result = QuillConfig::from_toml(toml_no_section);
1893 assert!(result.is_err());
1894 assert!(result
1895 .unwrap_err()
1896 .to_string()
1897 .contains("Missing required [Quill] section"));
1898 }
1899
1900 #[test]
1901 fn test_quill_from_config_metadata() {
1902 let mut root_files = HashMap::new();
1904
1905 let quill_toml = r#"[Quill]
1906name = "metadata-test"
1907backend = "typst"
1908description = "Test metadata flow"
1909author = "Test Author"
1910custom_field = "custom_value"
1911
1912[typst]
1913packages = ["@preview/bubble:0.2.2"]
1914"#;
1915 root_files.insert(
1916 "Quill.toml".to_string(),
1917 FileTreeNode::File {
1918 contents: quill_toml.as_bytes().to_vec(),
1919 },
1920 );
1921
1922 let root = FileTreeNode::Directory { files: root_files };
1923 let quill = Quill::from_tree(root, None).unwrap();
1924
1925 assert!(quill.metadata.contains_key("backend"));
1927 assert!(quill.metadata.contains_key("description"));
1928 assert!(quill.metadata.contains_key("author"));
1929
1930 assert!(quill.metadata.contains_key("custom_field"));
1932 assert_eq!(
1933 quill.metadata.get("custom_field").unwrap().as_str(),
1934 Some("custom_value")
1935 );
1936
1937 assert!(quill.metadata.contains_key("typst_packages"));
1939 }
1940}