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 files: FileTreeNode,
391}
392
393#[derive(Debug, Clone)]
395pub struct QuillConfig {
396 pub name: String,
398 pub description: String,
400 pub backend: String,
402 pub version: Option<String>,
404 pub author: Option<String>,
406 pub example_file: Option<String>,
408 pub glue_file: Option<String>,
410 pub json_schema_file: Option<String>,
412 pub fields: HashMap<String, FieldSchema>,
414 pub metadata: HashMap<String, QuillValue>,
416 pub typst_config: HashMap<String, QuillValue>,
418}
419
420impl QuillConfig {
421 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
423 let quill_toml: toml::Value = toml::from_str(toml_content)
424 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
425
426 let quill_section = quill_toml
428 .get("Quill")
429 .ok_or("Missing required [Quill] section in Quill.toml")?;
430
431 let name = quill_section
433 .get("name")
434 .and_then(|v| v.as_str())
435 .ok_or("Missing required 'name' field in [Quill] section")?
436 .to_string();
437
438 let backend = quill_section
439 .get("backend")
440 .and_then(|v| v.as_str())
441 .ok_or("Missing required 'backend' field in [Quill] section")?
442 .to_string();
443
444 let description = quill_section
445 .get("description")
446 .and_then(|v| v.as_str())
447 .ok_or("Missing required 'description' field in [Quill] section")?;
448
449 if description.trim().is_empty() {
450 return Err("'description' field in [Quill] section cannot be empty".into());
451 }
452 let description = description.to_string();
453
454 let version = quill_section
456 .get("version")
457 .and_then(|v| v.as_str())
458 .map(|s| s.to_string());
459
460 let author = quill_section
461 .get("author")
462 .and_then(|v| v.as_str())
463 .map(|s| s.to_string());
464
465 let example_file = quill_section
466 .get("example_file")
467 .and_then(|v| v.as_str())
468 .map(|s| s.to_string());
469
470 let glue_file = quill_section
471 .get("glue_file")
472 .and_then(|v| v.as_str())
473 .map(|s| s.to_string());
474
475 let json_schema_file = quill_section
476 .get("json_schema_file")
477 .and_then(|v| v.as_str())
478 .map(|s| s.to_string());
479
480 let mut metadata = HashMap::new();
482 if let toml::Value::Table(table) = quill_section {
483 for (key, value) in table {
484 if key != "name"
486 && key != "backend"
487 && key != "description"
488 && key != "version"
489 && key != "author"
490 && key != "example_file"
491 && key != "glue_file"
492 && key != "json_schema_file"
493 {
494 match QuillValue::from_toml(value) {
495 Ok(quill_value) => {
496 metadata.insert(key.clone(), quill_value);
497 }
498 Err(e) => {
499 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
500 }
501 }
502 }
503 }
504 }
505
506 let mut typst_config = HashMap::new();
508 if let Some(typst_section) = quill_toml.get("typst") {
509 if let toml::Value::Table(table) = typst_section {
510 for (key, value) in table {
511 match QuillValue::from_toml(value) {
512 Ok(quill_value) => {
513 typst_config.insert(key.clone(), quill_value);
514 }
515 Err(e) => {
516 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
517 }
518 }
519 }
520 }
521 }
522
523 let mut fields = HashMap::new();
525 if let Some(fields_section) = quill_toml.get("fields") {
526 if let toml::Value::Table(fields_table) = fields_section {
527 for (field_name, field_schema) in fields_table {
528 match QuillValue::from_toml(field_schema) {
529 Ok(quill_value) => {
530 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
531 Ok(schema) => {
532 fields.insert(field_name.clone(), schema);
533 }
534 Err(e) => {
535 eprintln!(
536 "Warning: Failed to parse field schema '{}': {}",
537 field_name, e
538 );
539 }
540 }
541 }
542 Err(e) => {
543 eprintln!(
544 "Warning: Failed to convert field schema '{}': {}",
545 field_name, e
546 );
547 }
548 }
549 }
550 }
551 }
552
553 Ok(QuillConfig {
554 name,
555 description,
556 backend,
557 version,
558 author,
559 example_file,
560 glue_file,
561 json_schema_file,
562 fields,
563 metadata,
564 typst_config,
565 })
566 }
567}
568
569impl Quill {
570 pub fn from_path<P: AsRef<std::path::Path>>(
572 path: P,
573 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
574 use std::fs;
575
576 let path = path.as_ref();
577 let name = path
578 .file_name()
579 .and_then(|n| n.to_str())
580 .unwrap_or("unnamed")
581 .to_string();
582
583 let quillignore_path = path.join(".quillignore");
585 let ignore = if quillignore_path.exists() {
586 let ignore_content = fs::read_to_string(&quillignore_path)
587 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
588 QuillIgnore::from_content(&ignore_content)
589 } else {
590 QuillIgnore::new(vec![
592 ".git/".to_string(),
593 ".gitignore".to_string(),
594 ".quillignore".to_string(),
595 "target/".to_string(),
596 "node_modules/".to_string(),
597 ])
598 };
599
600 let root = Self::load_directory_as_tree(path, path, &ignore)?;
602
603 Self::from_tree(root, Some(name))
605 }
606
607 pub fn from_tree(
624 root: FileTreeNode,
625 _default_name: Option<String>,
626 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
627 let quill_toml_bytes = root
629 .get_file("Quill.toml")
630 .ok_or("Quill.toml not found in file tree")?;
631
632 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
633 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
634
635 let config = QuillConfig::from_toml(&quill_toml_content)?;
637
638 Self::from_config(config, root)
640 }
641
642 fn from_config(
660 config: QuillConfig,
661 root: FileTreeNode,
662 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
663 let mut metadata = config.metadata.clone();
665
666 metadata.insert(
668 "backend".to_string(),
669 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
670 );
671
672 metadata.insert(
674 "description".to_string(),
675 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
676 );
677
678 if let Some(ref author) = config.author {
680 metadata.insert(
681 "author".to_string(),
682 QuillValue::from_json(serde_json::Value::String(author.clone())),
683 );
684 }
685
686 for (key, value) in &config.typst_config {
688 metadata.insert(format!("typst_{}", key), value.clone());
689 }
690
691 if let Some(ref json_schema_path) = config.json_schema_file {
693 let schema_bytes = root.get_file(json_schema_path).ok_or_else(|| {
695 format!(
696 "json_schema_file '{}' not found in file tree",
697 json_schema_path
698 )
699 })?;
700
701 serde_json::from_slice::<serde_json::Value>(schema_bytes).map_err(|e| {
703 format!(
704 "json_schema_file '{}' is not valid JSON: {}",
705 json_schema_path, e
706 )
707 })?;
708
709 if !config.fields.is_empty() {
711 eprintln!("Warning: [fields] section is overridden by json_schema_file");
712 }
713 }
714
715 let schema = build_schema_from_fields(&config.fields)
717 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
718
719 let glue_content: Option<String> = if let Some(ref glue_file_name) = config.glue_file {
721 let glue_bytes = root
722 .get_file(glue_file_name)
723 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
724
725 let content = String::from_utf8(glue_bytes.to_vec())
726 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?;
727 Some(content)
728 } else {
729 None
731 };
732
733 let example_content = if let Some(ref example_file_name) = config.example_file {
735 root.get_file(example_file_name).and_then(|bytes| {
736 String::from_utf8(bytes.to_vec())
737 .map_err(|e| {
738 eprintln!(
739 "Warning: Example file '{}' is not valid UTF-8: {}",
740 example_file_name, e
741 );
742 e
743 })
744 .ok()
745 })
746 } else {
747 None
748 };
749
750 let quill = Quill {
751 metadata,
752 name: config.name,
753 backend: config.backend,
754 glue: glue_content,
755 example: example_content,
756 schema,
757 files: root,
758 };
759
760 Ok(quill)
761 }
762
763 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
770 use serde_json::Value as JsonValue;
771
772 let json: JsonValue =
773 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
774
775 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
776
777 let default_name = obj
779 .get("metadata")
780 .and_then(|m| m.get("name"))
781 .and_then(|v| v.as_str())
782 .map(String::from);
783
784 let files_obj = obj
786 .get("files")
787 .and_then(|v| v.as_object())
788 .ok_or_else(|| "Missing or invalid 'files' key")?;
789
790 let mut root_files = HashMap::new();
792 for (key, value) in files_obj {
793 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
794 }
795
796 let root = FileTreeNode::Directory { files: root_files };
797
798 Self::from_tree(root, default_name)
800 }
801
802 fn load_directory_as_tree(
804 current_dir: &Path,
805 base_dir: &Path,
806 ignore: &QuillIgnore,
807 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
808 use std::fs;
809
810 if !current_dir.exists() {
811 return Ok(FileTreeNode::Directory {
812 files: HashMap::new(),
813 });
814 }
815
816 let mut files = HashMap::new();
817
818 for entry in fs::read_dir(current_dir)? {
819 let entry = entry?;
820 let path = entry.path();
821 let relative_path = path
822 .strip_prefix(base_dir)
823 .map_err(|e| format!("Failed to get relative path: {}", e))?
824 .to_path_buf();
825
826 if ignore.is_ignored(&relative_path) {
828 continue;
829 }
830
831 let filename = path
833 .file_name()
834 .and_then(|n| n.to_str())
835 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
836 .to_string();
837
838 if path.is_file() {
839 let contents = fs::read(&path)
840 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
841
842 files.insert(filename, FileTreeNode::File { contents });
843 } else if path.is_dir() {
844 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
846 files.insert(filename, subdir_tree);
847 }
848 }
849
850 Ok(FileTreeNode::Directory { files })
851 }
852
853 pub fn typst_packages(&self) -> Vec<String> {
855 self.metadata
856 .get("typst_packages")
857 .and_then(|v| v.as_array())
858 .map(|arr| {
859 arr.iter()
860 .filter_map(|v| v.as_str().map(|s| s.to_string()))
861 .collect()
862 })
863 .unwrap_or_default()
864 }
865
866 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
868 self.files.get_file(path)
869 }
870
871 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
873 self.files.file_exists(path)
874 }
875
876 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
878 self.files.dir_exists(path)
879 }
880
881 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
883 self.files.list_files(path)
884 }
885
886 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
888 self.files.list_subdirectories(path)
889 }
890
891 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
893 let dir_path = dir_path.as_ref();
894 let filenames = self.files.list_files(dir_path);
895
896 filenames
898 .iter()
899 .map(|name| {
900 if dir_path == Path::new("") {
901 PathBuf::from(name)
902 } else {
903 dir_path.join(name)
904 }
905 })
906 .collect()
907 }
908
909 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
911 let dir_path = dir_path.as_ref();
912 let subdirs = self.files.list_subdirectories(dir_path);
913
914 subdirs
916 .iter()
917 .map(|name| {
918 if dir_path == Path::new("") {
919 PathBuf::from(name)
920 } else {
921 dir_path.join(name)
922 }
923 })
924 .collect()
925 }
926
927 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
929 let pattern_str = pattern.as_ref().to_string_lossy();
930 let mut matches = Vec::new();
931
932 let glob_pattern = match glob::Pattern::new(&pattern_str) {
934 Ok(pat) => pat,
935 Err(_) => return matches, };
937
938 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
940
941 matches.sort();
942 matches
943 }
944
945 fn find_files_recursive(
947 &self,
948 node: &FileTreeNode,
949 current_path: &Path,
950 pattern: &glob::Pattern,
951 matches: &mut Vec<PathBuf>,
952 ) {
953 match node {
954 FileTreeNode::File { .. } => {
955 let path_str = current_path.to_string_lossy();
956 if pattern.matches(&path_str) {
957 matches.push(current_path.to_path_buf());
958 }
959 }
960 FileTreeNode::Directory { files } => {
961 for (name, child_node) in files {
962 let child_path = if current_path == Path::new("") {
963 PathBuf::from(name)
964 } else {
965 current_path.join(name)
966 };
967 self.find_files_recursive(child_node, &child_path, pattern, matches);
968 }
969 }
970 }
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977 use std::fs;
978 use tempfile::TempDir;
979
980 #[test]
981 fn test_quillignore_parsing() {
982 let ignore_content = r#"
983# This is a comment
984*.tmp
985target/
986node_modules/
987.git/
988"#;
989 let ignore = QuillIgnore::from_content(ignore_content);
990 assert_eq!(ignore.patterns.len(), 4);
991 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
992 assert!(ignore.patterns.contains(&"target/".to_string()));
993 }
994
995 #[test]
996 fn test_quillignore_matching() {
997 let ignore = QuillIgnore::new(vec![
998 "*.tmp".to_string(),
999 "target/".to_string(),
1000 "node_modules/".to_string(),
1001 ".git/".to_string(),
1002 ]);
1003
1004 assert!(ignore.is_ignored("test.tmp"));
1006 assert!(ignore.is_ignored("path/to/file.tmp"));
1007 assert!(!ignore.is_ignored("test.txt"));
1008
1009 assert!(ignore.is_ignored("target"));
1011 assert!(ignore.is_ignored("target/debug"));
1012 assert!(ignore.is_ignored("target/debug/deps"));
1013 assert!(!ignore.is_ignored("src/target.rs"));
1014
1015 assert!(ignore.is_ignored("node_modules"));
1016 assert!(ignore.is_ignored("node_modules/package"));
1017 assert!(!ignore.is_ignored("my_node_modules"));
1018 }
1019
1020 #[test]
1021 fn test_in_memory_file_system() {
1022 let temp_dir = TempDir::new().unwrap();
1023 let quill_dir = temp_dir.path();
1024
1025 fs::write(
1027 quill_dir.join("Quill.toml"),
1028 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1029 )
1030 .unwrap();
1031 fs::write(quill_dir.join("glue.typ"), "test glue").unwrap();
1032
1033 let assets_dir = quill_dir.join("assets");
1034 fs::create_dir_all(&assets_dir).unwrap();
1035 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1036
1037 let packages_dir = quill_dir.join("packages");
1038 fs::create_dir_all(&packages_dir).unwrap();
1039 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1040
1041 let quill = Quill::from_path(quill_dir).unwrap();
1043
1044 assert!(quill.file_exists("glue.typ"));
1046 assert!(quill.file_exists("assets/test.txt"));
1047 assert!(quill.file_exists("packages/package.typ"));
1048 assert!(!quill.file_exists("nonexistent.txt"));
1049
1050 let asset_content = quill.get_file("assets/test.txt").unwrap();
1052 assert_eq!(asset_content, b"asset content");
1053
1054 let asset_files = quill.list_directory("assets");
1056 assert_eq!(asset_files.len(), 1);
1057 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1058 }
1059
1060 #[test]
1061 fn test_quillignore_integration() {
1062 let temp_dir = TempDir::new().unwrap();
1063 let quill_dir = temp_dir.path();
1064
1065 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1067
1068 fs::write(
1070 quill_dir.join("Quill.toml"),
1071 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1072 )
1073 .unwrap();
1074 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
1075 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1076
1077 let target_dir = quill_dir.join("target");
1078 fs::create_dir_all(&target_dir).unwrap();
1079 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1080
1081 let quill = Quill::from_path(quill_dir).unwrap();
1083
1084 assert!(quill.file_exists("glue.typ"));
1086 assert!(!quill.file_exists("should_ignore.tmp"));
1087 assert!(!quill.file_exists("target/debug.txt"));
1088 }
1089
1090 #[test]
1091 fn test_find_files_pattern() {
1092 let temp_dir = TempDir::new().unwrap();
1093 let quill_dir = temp_dir.path();
1094
1095 fs::write(
1097 quill_dir.join("Quill.toml"),
1098 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1099 )
1100 .unwrap();
1101 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
1102
1103 let assets_dir = quill_dir.join("assets");
1104 fs::create_dir_all(&assets_dir).unwrap();
1105 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1106 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1107
1108 let fonts_dir = assets_dir.join("fonts");
1109 fs::create_dir_all(&fonts_dir).unwrap();
1110 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1111
1112 let quill = Quill::from_path(quill_dir).unwrap();
1114
1115 let all_assets = quill.find_files("assets/*");
1117 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1120 assert_eq!(typ_files.len(), 1);
1121 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1122 }
1123
1124 #[test]
1125 fn test_new_standardized_toml_format() {
1126 let temp_dir = TempDir::new().unwrap();
1127 let quill_dir = temp_dir.path();
1128
1129 let toml_content = r#"[Quill]
1131name = "my-custom-quill"
1132backend = "typst"
1133glue_file = "custom_glue.typ"
1134description = "Test quill with new format"
1135author = "Test Author"
1136"#;
1137 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1138 fs::write(
1139 quill_dir.join("custom_glue.typ"),
1140 "= Custom Template\n\nThis is a custom template.",
1141 )
1142 .unwrap();
1143
1144 let quill = Quill::from_path(quill_dir).unwrap();
1146
1147 assert_eq!(quill.name, "my-custom-quill");
1149
1150 assert!(quill.metadata.contains_key("backend"));
1152 if let Some(backend_val) = quill.metadata.get("backend") {
1153 if let Some(backend_str) = backend_val.as_str() {
1154 assert_eq!(backend_str, "typst");
1155 } else {
1156 panic!("Backend value is not a string");
1157 }
1158 }
1159
1160 assert!(quill.metadata.contains_key("description"));
1162 assert!(quill.metadata.contains_key("author"));
1163 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue.unwrap().contains("Custom Template"));
1167 }
1168
1169 #[test]
1170 fn test_typst_packages_parsing() {
1171 let temp_dir = TempDir::new().unwrap();
1172 let quill_dir = temp_dir.path();
1173
1174 let toml_content = r#"
1175[Quill]
1176name = "test-quill"
1177backend = "typst"
1178glue_file = "glue.typ"
1179description = "Test quill for packages"
1180
1181[typst]
1182packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1183"#;
1184
1185 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1186 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1187
1188 let quill = Quill::from_path(quill_dir).unwrap();
1189 let packages = quill.typst_packages();
1190
1191 assert_eq!(packages.len(), 2);
1192 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1193 assert_eq!(packages[1], "@preview/example:1.0.0");
1194 }
1195
1196 #[test]
1197 fn test_template_loading() {
1198 let temp_dir = TempDir::new().unwrap();
1199 let quill_dir = temp_dir.path();
1200
1201 let toml_content = r#"[Quill]
1203name = "test-with-template"
1204backend = "typst"
1205glue_file = "glue.typ"
1206example_file = "example.md"
1207description = "Test quill with template"
1208"#;
1209 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1210 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1211 fs::write(
1212 quill_dir.join("example.md"),
1213 "---\ntitle: Test\n---\n\nThis is a test template.",
1214 )
1215 .unwrap();
1216
1217 let quill = Quill::from_path(quill_dir).unwrap();
1219
1220 assert!(quill.example.is_some());
1222 let example = quill.example.unwrap();
1223 assert!(example.contains("title: Test"));
1224 assert!(example.contains("This is a test template"));
1225
1226 assert_eq!(quill.glue.unwrap(), "glue content");
1228 }
1229
1230 #[test]
1231 fn test_template_optional() {
1232 let temp_dir = TempDir::new().unwrap();
1233 let quill_dir = temp_dir.path();
1234
1235 let toml_content = r#"[Quill]
1237name = "test-without-template"
1238backend = "typst"
1239glue_file = "glue.typ"
1240description = "Test quill without template"
1241"#;
1242 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1243 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1244
1245 let quill = Quill::from_path(quill_dir).unwrap();
1247
1248 assert_eq!(quill.example, None);
1250
1251 assert_eq!(quill.glue.unwrap(), "glue content");
1253 }
1254
1255 #[test]
1256 fn test_from_tree() {
1257 let mut root_files = HashMap::new();
1259
1260 let quill_toml = r#"[Quill]
1262name = "test-from-tree"
1263backend = "typst"
1264glue_file = "glue.typ"
1265description = "A test quill from tree"
1266"#;
1267 root_files.insert(
1268 "Quill.toml".to_string(),
1269 FileTreeNode::File {
1270 contents: quill_toml.as_bytes().to_vec(),
1271 },
1272 );
1273
1274 let glue_content = "= Test Template\n\nThis is a test.";
1276 root_files.insert(
1277 "glue.typ".to_string(),
1278 FileTreeNode::File {
1279 contents: glue_content.as_bytes().to_vec(),
1280 },
1281 );
1282
1283 let root = FileTreeNode::Directory { files: root_files };
1284
1285 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1287
1288 assert_eq!(quill.name, "test-from-tree");
1290 assert_eq!(quill.glue.unwrap(), glue_content);
1291 assert!(quill.metadata.contains_key("backend"));
1292 assert!(quill.metadata.contains_key("description"));
1293 }
1294
1295 #[test]
1296 fn test_from_tree_with_template() {
1297 let mut root_files = HashMap::new();
1298
1299 let quill_toml = r#"[Quill]
1301name = "test-tree-template"
1302backend = "typst"
1303glue_file = "glue.typ"
1304example_file = "template.md"
1305description = "Test tree with template"
1306"#;
1307 root_files.insert(
1308 "Quill.toml".to_string(),
1309 FileTreeNode::File {
1310 contents: quill_toml.as_bytes().to_vec(),
1311 },
1312 );
1313
1314 root_files.insert(
1316 "glue.typ".to_string(),
1317 FileTreeNode::File {
1318 contents: b"glue content".to_vec(),
1319 },
1320 );
1321
1322 let template_content = "# {{ title }}\n\n{{ body }}";
1324 root_files.insert(
1325 "template.md".to_string(),
1326 FileTreeNode::File {
1327 contents: template_content.as_bytes().to_vec(),
1328 },
1329 );
1330
1331 let root = FileTreeNode::Directory { files: root_files };
1332
1333 let quill = Quill::from_tree(root, None).unwrap();
1335
1336 assert_eq!(quill.example, Some(template_content.to_string()));
1338 }
1339
1340 #[test]
1341 fn test_from_json() {
1342 let json_str = r#"{
1344 "metadata": {
1345 "name": "test-from-json"
1346 },
1347 "files": {
1348 "Quill.toml": {
1349 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1350 },
1351 "glue.typ": {
1352 "contents": "= Test Glue\n\nThis is test content."
1353 }
1354 }
1355 }"#;
1356
1357 let quill = Quill::from_json(json_str).unwrap();
1359
1360 assert_eq!(quill.name, "test-from-json");
1362 assert!(quill.glue.unwrap().contains("Test Glue"));
1363 assert!(quill.metadata.contains_key("backend"));
1364 }
1365
1366 #[test]
1367 fn test_from_json_with_byte_array() {
1368 let json_str = r#"{
1370 "files": {
1371 "Quill.toml": {
1372 "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]
1373 },
1374 "glue.typ": {
1375 "contents": "test glue"
1376 }
1377 }
1378 }"#;
1379
1380 let quill = Quill::from_json(json_str).unwrap();
1382
1383 assert_eq!(quill.name, "test");
1385 assert_eq!(quill.glue.unwrap(), "test glue");
1386 }
1387
1388 #[test]
1389 fn test_from_json_missing_files() {
1390 let json_str = r#"{
1392 "metadata": {
1393 "name": "test"
1394 }
1395 }"#;
1396
1397 let result = Quill::from_json(json_str);
1398 assert!(result.is_err());
1399 assert!(result.unwrap_err().to_string().contains("files"));
1401 }
1402
1403 #[test]
1404 fn test_from_json_tree_structure() {
1405 let json_str = r#"{
1407 "files": {
1408 "Quill.toml": {
1409 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1410 },
1411 "glue.typ": {
1412 "contents": "= Test Glue\n\nTree structure content."
1413 }
1414 }
1415 }"#;
1416
1417 let quill = Quill::from_json(json_str).unwrap();
1418
1419 assert_eq!(quill.name, "test-tree-json");
1420 assert!(quill.glue.unwrap().contains("Tree structure content"));
1421 assert!(quill.metadata.contains_key("backend"));
1422 }
1423
1424 #[test]
1425 fn test_from_json_nested_tree_structure() {
1426 let json_str = r#"{
1428 "files": {
1429 "Quill.toml": {
1430 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Nested test\"\n"
1431 },
1432 "glue.typ": {
1433 "contents": "glue"
1434 },
1435 "src": {
1436 "main.rs": {
1437 "contents": "fn main() {}"
1438 },
1439 "lib.rs": {
1440 "contents": "// lib"
1441 }
1442 }
1443 }
1444 }"#;
1445
1446 let quill = Quill::from_json(json_str).unwrap();
1447
1448 assert_eq!(quill.name, "nested-test");
1449 assert!(quill.file_exists("src/main.rs"));
1451 assert!(quill.file_exists("src/lib.rs"));
1452
1453 let main_rs = quill.get_file("src/main.rs").unwrap();
1454 assert_eq!(main_rs, b"fn main() {}");
1455 }
1456
1457 #[test]
1458 fn test_from_tree_structure_direct() {
1459 let mut root_files = HashMap::new();
1461
1462 root_files.insert(
1463 "Quill.toml".to_string(),
1464 FileTreeNode::File {
1465 contents:
1466 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1467 .to_vec(),
1468 },
1469 );
1470
1471 root_files.insert(
1472 "glue.typ".to_string(),
1473 FileTreeNode::File {
1474 contents: b"glue content".to_vec(),
1475 },
1476 );
1477
1478 let mut src_files = HashMap::new();
1480 src_files.insert(
1481 "main.rs".to_string(),
1482 FileTreeNode::File {
1483 contents: b"fn main() {}".to_vec(),
1484 },
1485 );
1486
1487 root_files.insert(
1488 "src".to_string(),
1489 FileTreeNode::Directory { files: src_files },
1490 );
1491
1492 let root = FileTreeNode::Directory { files: root_files };
1493
1494 let quill = Quill::from_tree(root, None).unwrap();
1495
1496 assert_eq!(quill.name, "direct-tree");
1497 assert!(quill.file_exists("src/main.rs"));
1498 assert!(quill.file_exists("glue.typ"));
1499 }
1500
1501 #[test]
1502 fn test_from_json_with_metadata_override() {
1503 let json_str = r#"{
1505 "metadata": {
1506 "name": "override-name"
1507 },
1508 "files": {
1509 "Quill.toml": {
1510 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1511 },
1512 "glue.typ": {
1513 "contents": "= glue"
1514 }
1515 }
1516 }"#;
1517
1518 let quill = Quill::from_json(json_str).unwrap();
1519 assert_eq!(quill.name, "toml-name");
1522 }
1523
1524 #[test]
1525 fn test_from_json_empty_directory() {
1526 let json_str = r#"{
1528 "files": {
1529 "Quill.toml": {
1530 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1531 },
1532 "glue.typ": {
1533 "contents": "glue"
1534 },
1535 "empty_dir": {}
1536 }
1537 }"#;
1538
1539 let quill = Quill::from_json(json_str).unwrap();
1540 assert_eq!(quill.name, "empty-dir-test");
1541 assert!(quill.dir_exists("empty_dir"));
1542 assert!(!quill.file_exists("empty_dir"));
1543 }
1544
1545 #[test]
1546 fn test_dir_exists_and_list_apis() {
1547 let mut root_files = HashMap::new();
1548
1549 root_files.insert(
1551 "Quill.toml".to_string(),
1552 FileTreeNode::File {
1553 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"\n"
1554 .to_vec(),
1555 },
1556 );
1557
1558 root_files.insert(
1560 "glue.typ".to_string(),
1561 FileTreeNode::File {
1562 contents: b"glue content".to_vec(),
1563 },
1564 );
1565
1566 let mut assets_files = HashMap::new();
1568 assets_files.insert(
1569 "logo.png".to_string(),
1570 FileTreeNode::File {
1571 contents: vec![137, 80, 78, 71],
1572 },
1573 );
1574 assets_files.insert(
1575 "icon.svg".to_string(),
1576 FileTreeNode::File {
1577 contents: b"<svg></svg>".to_vec(),
1578 },
1579 );
1580
1581 let mut fonts_files = HashMap::new();
1583 fonts_files.insert(
1584 "font.ttf".to_string(),
1585 FileTreeNode::File {
1586 contents: b"font data".to_vec(),
1587 },
1588 );
1589 assets_files.insert(
1590 "fonts".to_string(),
1591 FileTreeNode::Directory { files: fonts_files },
1592 );
1593
1594 root_files.insert(
1595 "assets".to_string(),
1596 FileTreeNode::Directory {
1597 files: assets_files,
1598 },
1599 );
1600
1601 root_files.insert(
1603 "empty".to_string(),
1604 FileTreeNode::Directory {
1605 files: HashMap::new(),
1606 },
1607 );
1608
1609 let root = FileTreeNode::Directory { files: root_files };
1610 let quill = Quill::from_tree(root, None).unwrap();
1611
1612 assert!(quill.dir_exists("assets"));
1614 assert!(quill.dir_exists("assets/fonts"));
1615 assert!(quill.dir_exists("empty"));
1616 assert!(!quill.dir_exists("nonexistent"));
1617 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1621 assert!(quill.file_exists("assets/logo.png"));
1622 assert!(quill.file_exists("assets/fonts/font.ttf"));
1623 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1627 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1629 assert!(root_files_list.contains(&"glue.typ".to_string()));
1630
1631 let assets_files_list = quill.list_files("assets");
1632 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1634 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1635
1636 let root_subdirs = quill.list_subdirectories("");
1638 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1640 assert!(root_subdirs.contains(&"empty".to_string()));
1641
1642 let assets_subdirs = quill.list_subdirectories("assets");
1643 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1645
1646 let empty_subdirs = quill.list_subdirectories("empty");
1647 assert_eq!(empty_subdirs.len(), 0);
1648 }
1649
1650 #[test]
1651 fn test_field_schemas_parsing() {
1652 let mut root_files = HashMap::new();
1653
1654 let quill_toml = r#"[Quill]
1656name = "taro"
1657backend = "typst"
1658glue_file = "glue.typ"
1659example_file = "taro.md"
1660description = "Test template for field schemas"
1661
1662[fields]
1663author = {description = "Author of document" }
1664ice_cream = {description = "favorite ice cream flavor"}
1665title = {description = "title of document" }
1666"#;
1667 root_files.insert(
1668 "Quill.toml".to_string(),
1669 FileTreeNode::File {
1670 contents: quill_toml.as_bytes().to_vec(),
1671 },
1672 );
1673
1674 let glue_content = "= Test Template\n\nThis is a test.";
1676 root_files.insert(
1677 "glue.typ".to_string(),
1678 FileTreeNode::File {
1679 contents: glue_content.as_bytes().to_vec(),
1680 },
1681 );
1682
1683 root_files.insert(
1685 "taro.md".to_string(),
1686 FileTreeNode::File {
1687 contents: b"# Template".to_vec(),
1688 },
1689 );
1690
1691 let root = FileTreeNode::Directory { files: root_files };
1692
1693 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1695
1696 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1698 assert!(quill.schema["properties"]
1699 .as_object()
1700 .unwrap()
1701 .contains_key("author"));
1702 assert!(quill.schema["properties"]
1703 .as_object()
1704 .unwrap()
1705 .contains_key("ice_cream"));
1706 assert!(quill.schema["properties"]
1707 .as_object()
1708 .unwrap()
1709 .contains_key("title"));
1710
1711 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1713 assert_eq!(author_schema["description"], "Author of document");
1714
1715 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1717 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1718
1719 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1721 assert_eq!(title_schema["description"], "title of document");
1722 }
1723
1724 #[test]
1725 fn test_field_schema_struct() {
1726 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1728 assert_eq!(schema1.description, "Test description");
1729 assert_eq!(schema1.r#type, None);
1730 assert_eq!(schema1.example, None);
1731 assert_eq!(schema1.default, None);
1732
1733 let yaml_str = r#"
1735description: "Full field schema"
1736type: "string"
1737example: "Example value"
1738default: "Default value"
1739"#;
1740 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1741 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1742 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1743 assert_eq!(schema2.name, "test_name");
1744 assert_eq!(schema2.description, "Full field schema");
1745 assert_eq!(schema2.r#type, Some("string".to_string()));
1746 assert_eq!(
1747 schema2.example.as_ref().and_then(|v| v.as_str()),
1748 Some("Example value")
1749 );
1750 assert_eq!(
1751 schema2.default.as_ref().and_then(|v| v.as_str()),
1752 Some("Default value")
1753 );
1754 }
1755
1756 #[test]
1757 fn test_quill_without_glue_file() {
1758 let mut root_files = HashMap::new();
1760
1761 let quill_toml = r#"[Quill]
1763name = "test-no-glue"
1764backend = "typst"
1765description = "Test quill without glue file"
1766"#;
1767 root_files.insert(
1768 "Quill.toml".to_string(),
1769 FileTreeNode::File {
1770 contents: quill_toml.as_bytes().to_vec(),
1771 },
1772 );
1773
1774 let root = FileTreeNode::Directory { files: root_files };
1775
1776 let quill = Quill::from_tree(root, None).unwrap();
1778
1779 assert!(quill.glue.clone().is_none());
1781 assert_eq!(quill.name, "test-no-glue");
1782 }
1783
1784 #[test]
1785 fn test_quill_config_from_toml() {
1786 let toml_content = r#"[Quill]
1788name = "test-config"
1789backend = "typst"
1790description = "Test configuration parsing"
1791version = "1.0.0"
1792author = "Test Author"
1793glue_file = "glue.typ"
1794example_file = "example.md"
1795
1796[typst]
1797packages = ["@preview/bubble:0.2.2"]
1798
1799[fields]
1800title = {description = "Document title", type = "string"}
1801author = {description = "Document author"}
1802"#;
1803
1804 let config = QuillConfig::from_toml(toml_content).unwrap();
1805
1806 assert_eq!(config.name, "test-config");
1808 assert_eq!(config.backend, "typst");
1809 assert_eq!(config.description, "Test configuration parsing");
1810
1811 assert_eq!(config.version, Some("1.0.0".to_string()));
1813 assert_eq!(config.author, Some("Test Author".to_string()));
1814 assert_eq!(config.glue_file, Some("glue.typ".to_string()));
1815 assert_eq!(config.example_file, Some("example.md".to_string()));
1816
1817 assert!(config.typst_config.contains_key("packages"));
1819
1820 assert_eq!(config.fields.len(), 2);
1822 assert!(config.fields.contains_key("title"));
1823 assert!(config.fields.contains_key("author"));
1824
1825 let title_field = &config.fields["title"];
1826 assert_eq!(title_field.description, "Document title");
1827 assert_eq!(title_field.r#type, Some("string".to_string()));
1828 }
1829
1830 #[test]
1831 fn test_quill_config_missing_required_fields() {
1832 let toml_missing_name = r#"[Quill]
1834backend = "typst"
1835description = "Missing name"
1836"#;
1837 let result = QuillConfig::from_toml(toml_missing_name);
1838 assert!(result.is_err());
1839 assert!(result
1840 .unwrap_err()
1841 .to_string()
1842 .contains("Missing required 'name'"));
1843
1844 let toml_missing_backend = r#"[Quill]
1845name = "test"
1846description = "Missing backend"
1847"#;
1848 let result = QuillConfig::from_toml(toml_missing_backend);
1849 assert!(result.is_err());
1850 assert!(result
1851 .unwrap_err()
1852 .to_string()
1853 .contains("Missing required 'backend'"));
1854
1855 let toml_missing_description = r#"[Quill]
1856name = "test"
1857backend = "typst"
1858"#;
1859 let result = QuillConfig::from_toml(toml_missing_description);
1860 assert!(result.is_err());
1861 assert!(result
1862 .unwrap_err()
1863 .to_string()
1864 .contains("Missing required 'description'"));
1865 }
1866
1867 #[test]
1868 fn test_quill_config_empty_description() {
1869 let toml_empty_description = r#"[Quill]
1871name = "test"
1872backend = "typst"
1873description = " "
1874"#;
1875 let result = QuillConfig::from_toml(toml_empty_description);
1876 assert!(result.is_err());
1877 assert!(result
1878 .unwrap_err()
1879 .to_string()
1880 .contains("description' field in [Quill] section cannot be empty"));
1881 }
1882
1883 #[test]
1884 fn test_quill_config_missing_quill_section() {
1885 let toml_no_section = r#"[fields]
1887title = {description = "Title"}
1888"#;
1889 let result = QuillConfig::from_toml(toml_no_section);
1890 assert!(result.is_err());
1891 assert!(result
1892 .unwrap_err()
1893 .to_string()
1894 .contains("Missing required [Quill] section"));
1895 }
1896
1897 #[test]
1898 fn test_quill_from_config_metadata() {
1899 let mut root_files = HashMap::new();
1901
1902 let quill_toml = r#"[Quill]
1903name = "metadata-test"
1904backend = "typst"
1905description = "Test metadata flow"
1906author = "Test Author"
1907custom_field = "custom_value"
1908
1909[typst]
1910packages = ["@preview/bubble:0.2.2"]
1911"#;
1912 root_files.insert(
1913 "Quill.toml".to_string(),
1914 FileTreeNode::File {
1915 contents: quill_toml.as_bytes().to_vec(),
1916 },
1917 );
1918
1919 let root = FileTreeNode::Directory { files: root_files };
1920 let quill = Quill::from_tree(root, None).unwrap();
1921
1922 assert!(quill.metadata.contains_key("backend"));
1924 assert!(quill.metadata.contains_key("description"));
1925 assert!(quill.metadata.contains_key("author"));
1926
1927 assert!(quill.metadata.contains_key("custom_field"));
1929 assert_eq!(
1930 quill.metadata.get("custom_field").unwrap().as_str(),
1931 Some("custom_value")
1932 );
1933
1934 assert!(quill.metadata.contains_key("typst_packages"));
1936 }
1937}