1use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use crate::schema::build_schema_from_fields;
8use crate::value::QuillValue;
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct UiSchema {
13 pub group: Option<String>,
15 pub order: Option<i32>,
17}
18
19#[derive(Debug, Clone, PartialEq)]
21pub struct FieldSchema {
22 pub name: String,
23 pub title: Option<String>,
25 pub r#type: Option<String>,
27 pub description: String,
29 pub default: Option<QuillValue>,
31 pub examples: Option<QuillValue>,
33 pub ui: Option<UiSchema>,
35 pub items: Option<HashMap<String, FieldSchema>>,
37 pub required: bool,
39}
40
41impl FieldSchema {
42 pub fn new(name: String, description: String) -> Self {
44 Self {
45 name,
46 title: None,
47 r#type: None,
48 description,
49 default: None,
50 examples: None,
51 ui: None,
52 items: None,
53 required: false,
54 }
55 }
56
57 pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
59 Self::from_quill_value_internal(key, value, false)
60 }
61
62 fn from_quill_value_internal(
64 key: String,
65 value: &QuillValue,
66 inside_scope_items: bool,
67 ) -> Result<Self, String> {
68 let obj = value
69 .as_object()
70 .ok_or_else(|| "Field schema must be an object".to_string())?;
71
72 for key in obj.keys() {
74 match key.as_str() {
75 "name" | "title" | "type" | "description" | "examples" | "default" | "ui"
76 | "items" | "required" => {}
77 _ => {
78 eprintln!("Warning: Unknown key '{}' in field schema", key);
80 }
81 }
82 }
83
84 let name = key.clone();
85
86 let title = obj
87 .get("title")
88 .and_then(|v| v.as_str())
89 .map(|s| s.to_string());
90
91 let description = obj
92 .get("description")
93 .and_then(|v| v.as_str())
94 .unwrap_or("")
95 .to_string();
96
97 let field_type = obj
98 .get("type")
99 .and_then(|v| v.as_str())
100 .map(|s| s.to_string());
101
102 if inside_scope_items && field_type.as_deref() == Some("scope") {
104 return Err(format!(
105 "Field '{}': nested scopes are not supported (type = \"scope\" in items)",
106 name
107 ));
108 }
109
110 let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
111
112 let examples = obj
113 .get("examples")
114 .map(|v| QuillValue::from_json(v.clone()));
115
116 let required = obj
118 .get("required")
119 .and_then(|v| v.as_bool())
120 .unwrap_or(false);
121
122 let ui = if let Some(ui_value) = obj.get("ui") {
124 if let Some(ui_obj) = ui_value.as_object() {
125 let group = ui_obj
126 .get("group")
127 .and_then(|v| v.as_str())
128 .map(|s| s.to_string());
129
130 for key in ui_obj.keys() {
132 match key.as_str() {
133 "group" => {}
134 _ => {
135 eprintln!(
136 "Warning: Unknown UI property '{}'. Only 'group' is supported.",
137 key
138 );
139 }
140 }
141 }
142
143 Some(UiSchema {
144 group,
145 order: None, })
147 } else {
148 return Err("UI field must be an object".to_string());
149 }
150 } else {
151 None
152 };
153
154 let items = if let Some(items_value) = obj.get("items") {
156 if field_type.as_deref() != Some("scope") {
158 return Err(format!(
159 "Field '{}': 'items' is only valid when type = \"scope\"",
160 name
161 ));
162 }
163
164 if let Some(items_obj) = items_value.as_object() {
165 let mut item_schemas = HashMap::new();
166 for (item_name, item_value) in items_obj {
167 let item_schema = Self::from_quill_value_internal(
169 item_name.clone(),
170 &QuillValue::from_json(item_value.clone()),
171 true,
172 )?;
173 item_schemas.insert(item_name.clone(), item_schema);
174 }
175 Some(item_schemas)
176 } else {
177 return Err(format!("Field '{}': 'items' must be an object", name));
178 }
179 } else {
180 None
181 };
182
183 Ok(Self {
184 name,
185 title,
186 r#type: field_type,
187 description,
188 default,
189 examples,
190 ui,
191 items,
192 required,
193 })
194 }
195}
196
197#[derive(Debug, Clone)]
199pub enum FileTreeNode {
200 File {
202 contents: Vec<u8>,
204 },
205 Directory {
207 files: HashMap<String, FileTreeNode>,
209 },
210}
211
212impl FileTreeNode {
213 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
215 let path = path.as_ref();
216
217 if path == Path::new("") {
219 return Some(self);
220 }
221
222 let components: Vec<_> = path
224 .components()
225 .filter_map(|c| {
226 if let std::path::Component::Normal(s) = c {
227 s.to_str()
228 } else {
229 None
230 }
231 })
232 .collect();
233
234 if components.is_empty() {
235 return Some(self);
236 }
237
238 let mut current_node = self;
240 for component in components {
241 match current_node {
242 FileTreeNode::Directory { files } => {
243 current_node = files.get(component)?;
244 }
245 FileTreeNode::File { .. } => {
246 return None; }
248 }
249 }
250
251 Some(current_node)
252 }
253
254 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
256 match self.get_node(path)? {
257 FileTreeNode::File { contents } => Some(contents.as_slice()),
258 FileTreeNode::Directory { .. } => None,
259 }
260 }
261
262 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
264 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
265 }
266
267 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
269 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
270 }
271
272 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
274 match self.get_node(dir_path) {
275 Some(FileTreeNode::Directory { files }) => files
276 .iter()
277 .filter_map(|(name, node)| {
278 if matches!(node, FileTreeNode::File { .. }) {
279 Some(name.clone())
280 } else {
281 None
282 }
283 })
284 .collect(),
285 _ => Vec::new(),
286 }
287 }
288
289 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
291 match self.get_node(dir_path) {
292 Some(FileTreeNode::Directory { files }) => files
293 .iter()
294 .filter_map(|(name, node)| {
295 if matches!(node, FileTreeNode::Directory { .. }) {
296 Some(name.clone())
297 } else {
298 None
299 }
300 })
301 .collect(),
302 _ => Vec::new(),
303 }
304 }
305
306 pub fn insert<P: AsRef<Path>>(
308 &mut self,
309 path: P,
310 node: FileTreeNode,
311 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
312 let path = path.as_ref();
313
314 let components: Vec<_> = path
316 .components()
317 .filter_map(|c| {
318 if let std::path::Component::Normal(s) = c {
319 s.to_str().map(|s| s.to_string())
320 } else {
321 None
322 }
323 })
324 .collect();
325
326 if components.is_empty() {
327 return Err("Cannot insert at root path".into());
328 }
329
330 let mut current_node = self;
332 for component in &components[..components.len() - 1] {
333 match current_node {
334 FileTreeNode::Directory { files } => {
335 current_node =
336 files
337 .entry(component.clone())
338 .or_insert_with(|| FileTreeNode::Directory {
339 files: HashMap::new(),
340 });
341 }
342 FileTreeNode::File { .. } => {
343 return Err("Cannot traverse into a file".into());
344 }
345 }
346 }
347
348 let filename = &components[components.len() - 1];
350 match current_node {
351 FileTreeNode::Directory { files } => {
352 files.insert(filename.clone(), node);
353 Ok(())
354 }
355 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
356 }
357 }
358
359 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
361 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
362 Ok(FileTreeNode::File {
364 contents: contents_str.as_bytes().to_vec(),
365 })
366 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
367 let contents: Vec<u8> = bytes_array
369 .iter()
370 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
371 .collect();
372 Ok(FileTreeNode::File { contents })
373 } else if let Some(obj) = value.as_object() {
374 let mut files = HashMap::new();
376 for (name, child_value) in obj {
377 files.insert(name.clone(), Self::from_json_value(child_value)?);
378 }
379 Ok(FileTreeNode::Directory { files })
381 } else {
382 Err(format!("Invalid file tree node: {:?}", value).into())
383 }
384 }
385
386 pub fn print_tree(&self) -> String {
387 self.__print_tree("", "", true)
388 }
389
390 pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
391 let mut result = String::new();
392
393 let connector = if is_last { "└── " } else { "├── " };
395 let extension = if is_last { " " } else { "│ " };
396
397 match self {
398 FileTreeNode::File { .. } => {
399 result.push_str(&format!("{}{}{}\n", prefix, connector, name));
400 }
401 FileTreeNode::Directory { files } => {
402 result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
404
405 let child_prefix = format!("{}{}", prefix, extension);
406 let count = files.len();
407
408 for (i, (child_name, node)) in files.iter().enumerate() {
409 let is_last_child = i == count - 1;
410 result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
411 }
412 }
413 }
414
415 result
416 }
417}
418
419#[derive(Debug, Clone)]
421pub struct QuillIgnore {
422 patterns: Vec<String>,
423}
424
425impl QuillIgnore {
426 pub fn new(patterns: Vec<String>) -> Self {
428 Self { patterns }
429 }
430
431 pub fn from_content(content: &str) -> Self {
433 let patterns = content
434 .lines()
435 .map(|line| line.trim())
436 .filter(|line| !line.is_empty() && !line.starts_with('#'))
437 .map(|line| line.to_string())
438 .collect();
439 Self::new(patterns)
440 }
441
442 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
444 let path = path.as_ref();
445 let path_str = path.to_string_lossy();
446
447 for pattern in &self.patterns {
448 if self.matches_pattern(pattern, &path_str) {
449 return true;
450 }
451 }
452 false
453 }
454
455 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
457 if let Some(pattern_prefix) = pattern.strip_suffix('/') {
459 return path.starts_with(pattern_prefix)
460 && (path.len() == pattern_prefix.len()
461 || path.chars().nth(pattern_prefix.len()) == Some('/'));
462 }
463
464 if !pattern.contains('*') {
466 return path == pattern || path.ends_with(&format!("/{}", pattern));
467 }
468
469 if pattern == "*" {
471 return true;
472 }
473
474 let pattern_parts: Vec<&str> = pattern.split('*').collect();
476 if pattern_parts.len() == 2 {
477 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
478 if prefix.is_empty() {
479 return path.ends_with(suffix);
480 } else if suffix.is_empty() {
481 return path.starts_with(prefix);
482 } else {
483 return path.starts_with(prefix) && path.ends_with(suffix);
484 }
485 }
486
487 false
488 }
489}
490
491#[derive(Debug, Clone)]
493pub struct Quill {
494 pub metadata: HashMap<String, QuillValue>,
496 pub name: String,
498 pub backend: String,
500 pub plate: Option<String>,
502 pub example: Option<String>,
504 pub schema: QuillValue,
506 pub defaults: HashMap<String, QuillValue>,
508 pub examples: HashMap<String, Vec<QuillValue>>,
510 pub files: FileTreeNode,
512}
513
514#[derive(Debug, Clone)]
516pub struct QuillConfig {
517 pub name: String,
519 pub description: String,
521 pub backend: String,
523 pub version: Option<String>,
525 pub author: Option<String>,
527 pub example_file: Option<String>,
529 pub plate_file: Option<String>,
531 pub fields: HashMap<String, FieldSchema>,
533 pub metadata: HashMap<String, QuillValue>,
535 pub typst_config: HashMap<String, QuillValue>,
537}
538
539impl QuillConfig {
540 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
542 let quill_toml: toml::Value = toml::from_str(toml_content)
543 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
544
545 let field_order: Vec<String> = toml_content
547 .parse::<toml_edit::DocumentMut>()
548 .ok()
549 .and_then(|doc| {
550 doc.get("fields")
551 .and_then(|item| item.as_table())
552 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
553 })
554 .unwrap_or_default();
555
556 let quill_section = quill_toml
558 .get("Quill")
559 .ok_or("Missing required [Quill] section in Quill.toml")?;
560
561 let name = quill_section
563 .get("name")
564 .and_then(|v| v.as_str())
565 .ok_or("Missing required 'name' field in [Quill] section")?
566 .to_string();
567
568 let backend = quill_section
569 .get("backend")
570 .and_then(|v| v.as_str())
571 .ok_or("Missing required 'backend' field in [Quill] section")?
572 .to_string();
573
574 let description = quill_section
575 .get("description")
576 .and_then(|v| v.as_str())
577 .ok_or("Missing required 'description' field in [Quill] section")?;
578
579 if description.trim().is_empty() {
580 return Err("'description' field in [Quill] section cannot be empty".into());
581 }
582 let description = description.to_string();
583
584 let version = quill_section
586 .get("version")
587 .and_then(|v| v.as_str())
588 .map(|s| s.to_string());
589
590 let author = quill_section
591 .get("author")
592 .and_then(|v| v.as_str())
593 .map(|s| s.to_string());
594
595 let example_file = quill_section
596 .get("example_file")
597 .and_then(|v| v.as_str())
598 .map(|s| s.to_string());
599
600 let plate_file = quill_section
601 .get("plate_file")
602 .and_then(|v| v.as_str())
603 .map(|s| s.to_string());
604
605 let mut metadata = HashMap::new();
607 if let toml::Value::Table(table) = quill_section {
608 for (key, value) in table {
609 if key != "name"
611 && key != "backend"
612 && key != "description"
613 && key != "version"
614 && key != "author"
615 && key != "example_file"
616 && key != "plate_file"
617 {
618 match QuillValue::from_toml(value) {
619 Ok(quill_value) => {
620 metadata.insert(key.clone(), quill_value);
621 }
622 Err(e) => {
623 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
624 }
625 }
626 }
627 }
628 }
629
630 let mut typst_config = HashMap::new();
632 if let Some(typst_section) = quill_toml.get("typst") {
633 if let toml::Value::Table(table) = typst_section {
634 for (key, value) in table {
635 match QuillValue::from_toml(value) {
636 Ok(quill_value) => {
637 typst_config.insert(key.clone(), quill_value);
638 }
639 Err(e) => {
640 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
641 }
642 }
643 }
644 }
645 }
646
647 let mut fields = HashMap::new();
649 if let Some(fields_section) = quill_toml.get("fields") {
650 if let toml::Value::Table(fields_table) = fields_section {
651 let mut order_counter = 0;
652 for (field_name, field_schema) in fields_table {
653 let order = if let Some(idx) = field_order.iter().position(|k| k == field_name)
655 {
656 idx as i32
657 } else {
658 let o = field_order.len() as i32 + order_counter;
659 order_counter += 1;
660 o
661 };
662
663 match QuillValue::from_toml(field_schema) {
664 Ok(quill_value) => {
665 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
666 Ok(mut schema) => {
667 if schema.ui.is_none() {
669 schema.ui = Some(UiSchema {
670 group: None,
671 order: Some(order),
672 });
673 } else if let Some(ui) = &mut schema.ui {
674 ui.order = Some(order);
675 }
676
677 fields.insert(field_name.clone(), schema);
678 }
679 Err(e) => {
680 eprintln!(
681 "Warning: Failed to parse field schema '{}': {}",
682 field_name, e
683 );
684 }
685 }
686 }
687 Err(e) => {
688 eprintln!(
689 "Warning: Failed to convert field schema '{}': {}",
690 field_name, e
691 );
692 }
693 }
694 }
695 }
696 }
697
698 Ok(QuillConfig {
699 name,
700 description,
701 backend,
702 version,
703 author,
704 example_file,
705 plate_file,
706 fields,
707 metadata,
708 typst_config,
709 })
710 }
711}
712
713impl Quill {
714 pub fn from_path<P: AsRef<std::path::Path>>(
716 path: P,
717 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
718 use std::fs;
719
720 let path = path.as_ref();
721 let name = path
722 .file_name()
723 .and_then(|n| n.to_str())
724 .unwrap_or("unnamed")
725 .to_string();
726
727 let quillignore_path = path.join(".quillignore");
729 let ignore = if quillignore_path.exists() {
730 let ignore_content = fs::read_to_string(&quillignore_path)
731 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
732 QuillIgnore::from_content(&ignore_content)
733 } else {
734 QuillIgnore::new(vec![
736 ".git/".to_string(),
737 ".gitignore".to_string(),
738 ".quillignore".to_string(),
739 "target/".to_string(),
740 "node_modules/".to_string(),
741 ])
742 };
743
744 let root = Self::load_directory_as_tree(path, path, &ignore)?;
746
747 Self::from_tree(root, Some(name))
749 }
750
751 pub fn from_tree(
768 root: FileTreeNode,
769 _default_name: Option<String>,
770 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
771 let quill_toml_bytes = root
773 .get_file("Quill.toml")
774 .ok_or("Quill.toml not found in file tree")?;
775
776 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
777 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
778
779 let config = QuillConfig::from_toml(&quill_toml_content)?;
781
782 Self::from_config(config, root)
784 }
785
786 fn from_config(
803 config: QuillConfig,
804 root: FileTreeNode,
805 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
806 let mut metadata = config.metadata.clone();
808
809 metadata.insert(
811 "backend".to_string(),
812 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
813 );
814
815 metadata.insert(
817 "description".to_string(),
818 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
819 );
820
821 if let Some(ref author) = config.author {
823 metadata.insert(
824 "author".to_string(),
825 QuillValue::from_json(serde_json::Value::String(author.clone())),
826 );
827 }
828
829 for (key, value) in &config.typst_config {
831 metadata.insert(format!("typst_{}", key), value.clone());
832 }
833
834 let schema = build_schema_from_fields(&config.fields)
836 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
837
838 let plate_content: Option<String> = if let Some(ref plate_file_name) = config.plate_file {
840 let plate_bytes = root.get_file(plate_file_name).ok_or_else(|| {
841 format!("Plate file '{}' not found in file tree", plate_file_name)
842 })?;
843
844 let content = String::from_utf8(plate_bytes.to_vec()).map_err(|e| {
845 format!("Plate file '{}' is not valid UTF-8: {}", plate_file_name, e)
846 })?;
847 Some(content)
848 } else {
849 None
851 };
852
853 let example_content = if let Some(ref example_file_name) = config.example_file {
855 root.get_file(example_file_name).and_then(|bytes| {
856 String::from_utf8(bytes.to_vec())
857 .map_err(|e| {
858 eprintln!(
859 "Warning: Example file '{}' is not valid UTF-8: {}",
860 example_file_name, e
861 );
862 e
863 })
864 .ok()
865 })
866 } else {
867 None
868 };
869
870 let defaults = crate::schema::extract_defaults_from_schema(&schema);
872 let examples = crate::schema::extract_examples_from_schema(&schema);
873
874 let quill = Quill {
875 metadata,
876 name: config.name,
877 backend: config.backend,
878 plate: plate_content,
879 example: example_content,
880 schema,
881 defaults,
882 examples,
883 files: root,
884 };
885
886 Ok(quill)
887 }
888
889 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
896 use serde_json::Value as JsonValue;
897
898 let json: JsonValue =
899 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
900
901 let obj = json.as_object().ok_or("Root must be an object")?;
902
903 let default_name = obj
905 .get("metadata")
906 .and_then(|m| m.get("name"))
907 .and_then(|v| v.as_str())
908 .map(String::from);
909
910 let files_obj = obj
912 .get("files")
913 .and_then(|v| v.as_object())
914 .ok_or("Missing or invalid 'files' key")?;
915
916 let mut root_files = HashMap::new();
918 for (key, value) in files_obj {
919 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
920 }
921
922 let root = FileTreeNode::Directory { files: root_files };
923
924 Self::from_tree(root, default_name)
926 }
927
928 fn load_directory_as_tree(
930 current_dir: &Path,
931 base_dir: &Path,
932 ignore: &QuillIgnore,
933 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
934 use std::fs;
935
936 if !current_dir.exists() {
937 return Ok(FileTreeNode::Directory {
938 files: HashMap::new(),
939 });
940 }
941
942 let mut files = HashMap::new();
943
944 for entry in fs::read_dir(current_dir)? {
945 let entry = entry?;
946 let path = entry.path();
947 let relative_path = path
948 .strip_prefix(base_dir)
949 .map_err(|e| format!("Failed to get relative path: {}", e))?
950 .to_path_buf();
951
952 if ignore.is_ignored(&relative_path) {
954 continue;
955 }
956
957 let filename = path
959 .file_name()
960 .and_then(|n| n.to_str())
961 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
962 .to_string();
963
964 if path.is_file() {
965 let contents = fs::read(&path)
966 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
967
968 files.insert(filename, FileTreeNode::File { contents });
969 } else if path.is_dir() {
970 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
972 files.insert(filename, subdir_tree);
973 }
974 }
975
976 Ok(FileTreeNode::Directory { files })
977 }
978
979 pub fn typst_packages(&self) -> Vec<String> {
981 self.metadata
982 .get("typst_packages")
983 .and_then(|v| v.as_array())
984 .map(|arr| {
985 arr.iter()
986 .filter_map(|v| v.as_str().map(|s| s.to_string()))
987 .collect()
988 })
989 .unwrap_or_default()
990 }
991
992 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
1000 &self.defaults
1001 }
1002
1003 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
1008 &self.examples
1009 }
1010
1011 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
1013 self.files.get_file(path)
1014 }
1015
1016 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1018 self.files.file_exists(path)
1019 }
1020
1021 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1023 self.files.dir_exists(path)
1024 }
1025
1026 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1028 self.files.list_files(path)
1029 }
1030
1031 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1033 self.files.list_subdirectories(path)
1034 }
1035
1036 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1038 let dir_path = dir_path.as_ref();
1039 let filenames = self.files.list_files(dir_path);
1040
1041 filenames
1043 .iter()
1044 .map(|name| {
1045 if dir_path == Path::new("") {
1046 PathBuf::from(name)
1047 } else {
1048 dir_path.join(name)
1049 }
1050 })
1051 .collect()
1052 }
1053
1054 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1056 let dir_path = dir_path.as_ref();
1057 let subdirs = self.files.list_subdirectories(dir_path);
1058
1059 subdirs
1061 .iter()
1062 .map(|name| {
1063 if dir_path == Path::new("") {
1064 PathBuf::from(name)
1065 } else {
1066 dir_path.join(name)
1067 }
1068 })
1069 .collect()
1070 }
1071
1072 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1074 let pattern_str = pattern.as_ref().to_string_lossy();
1075 let mut matches = Vec::new();
1076
1077 let glob_pattern = match glob::Pattern::new(&pattern_str) {
1079 Ok(pat) => pat,
1080 Err(_) => return matches, };
1082
1083 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1085
1086 matches.sort();
1087 matches
1088 }
1089
1090 fn find_files_recursive(
1092 &self,
1093 node: &FileTreeNode,
1094 current_path: &Path,
1095 pattern: &glob::Pattern,
1096 matches: &mut Vec<PathBuf>,
1097 ) {
1098 match node {
1099 FileTreeNode::File { .. } => {
1100 let path_str = current_path.to_string_lossy();
1101 if pattern.matches(&path_str) {
1102 matches.push(current_path.to_path_buf());
1103 }
1104 }
1105 FileTreeNode::Directory { files } => {
1106 for (name, child_node) in files {
1107 let child_path = if current_path == Path::new("") {
1108 PathBuf::from(name)
1109 } else {
1110 current_path.join(name)
1111 };
1112 self.find_files_recursive(child_node, &child_path, pattern, matches);
1113 }
1114 }
1115 }
1116 }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122 use std::fs;
1123 use tempfile::TempDir;
1124
1125 #[test]
1126 fn test_quillignore_parsing() {
1127 let ignore_content = r#"
1128# This is a comment
1129*.tmp
1130target/
1131node_modules/
1132.git/
1133"#;
1134 let ignore = QuillIgnore::from_content(ignore_content);
1135 assert_eq!(ignore.patterns.len(), 4);
1136 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1137 assert!(ignore.patterns.contains(&"target/".to_string()));
1138 }
1139
1140 #[test]
1141 fn test_quillignore_matching() {
1142 let ignore = QuillIgnore::new(vec![
1143 "*.tmp".to_string(),
1144 "target/".to_string(),
1145 "node_modules/".to_string(),
1146 ".git/".to_string(),
1147 ]);
1148
1149 assert!(ignore.is_ignored("test.tmp"));
1151 assert!(ignore.is_ignored("path/to/file.tmp"));
1152 assert!(!ignore.is_ignored("test.txt"));
1153
1154 assert!(ignore.is_ignored("target"));
1156 assert!(ignore.is_ignored("target/debug"));
1157 assert!(ignore.is_ignored("target/debug/deps"));
1158 assert!(!ignore.is_ignored("src/target.rs"));
1159
1160 assert!(ignore.is_ignored("node_modules"));
1161 assert!(ignore.is_ignored("node_modules/package"));
1162 assert!(!ignore.is_ignored("my_node_modules"));
1163 }
1164
1165 #[test]
1166 fn test_in_memory_file_system() {
1167 let temp_dir = TempDir::new().unwrap();
1168 let quill_dir = temp_dir.path();
1169
1170 fs::write(
1172 quill_dir.join("Quill.toml"),
1173 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1174 )
1175 .unwrap();
1176 fs::write(quill_dir.join("plate.typ"), "test plate").unwrap();
1177
1178 let assets_dir = quill_dir.join("assets");
1179 fs::create_dir_all(&assets_dir).unwrap();
1180 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1181
1182 let packages_dir = quill_dir.join("packages");
1183 fs::create_dir_all(&packages_dir).unwrap();
1184 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1185
1186 let quill = Quill::from_path(quill_dir).unwrap();
1188
1189 assert!(quill.file_exists("plate.typ"));
1191 assert!(quill.file_exists("assets/test.txt"));
1192 assert!(quill.file_exists("packages/package.typ"));
1193 assert!(!quill.file_exists("nonexistent.txt"));
1194
1195 let asset_content = quill.get_file("assets/test.txt").unwrap();
1197 assert_eq!(asset_content, b"asset content");
1198
1199 let asset_files = quill.list_directory("assets");
1201 assert_eq!(asset_files.len(), 1);
1202 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1203 }
1204
1205 #[test]
1206 fn test_quillignore_integration() {
1207 let temp_dir = TempDir::new().unwrap();
1208 let quill_dir = temp_dir.path();
1209
1210 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1212
1213 fs::write(
1215 quill_dir.join("Quill.toml"),
1216 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1217 )
1218 .unwrap();
1219 fs::write(quill_dir.join("plate.typ"), "test template").unwrap();
1220 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1221
1222 let target_dir = quill_dir.join("target");
1223 fs::create_dir_all(&target_dir).unwrap();
1224 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1225
1226 let quill = Quill::from_path(quill_dir).unwrap();
1228
1229 assert!(quill.file_exists("plate.typ"));
1231 assert!(!quill.file_exists("should_ignore.tmp"));
1232 assert!(!quill.file_exists("target/debug.txt"));
1233 }
1234
1235 #[test]
1236 fn test_find_files_pattern() {
1237 let temp_dir = TempDir::new().unwrap();
1238 let quill_dir = temp_dir.path();
1239
1240 fs::write(
1242 quill_dir.join("Quill.toml"),
1243 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1244 )
1245 .unwrap();
1246 fs::write(quill_dir.join("plate.typ"), "template").unwrap();
1247
1248 let assets_dir = quill_dir.join("assets");
1249 fs::create_dir_all(&assets_dir).unwrap();
1250 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1251 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1252
1253 let fonts_dir = assets_dir.join("fonts");
1254 fs::create_dir_all(&fonts_dir).unwrap();
1255 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1256
1257 let quill = Quill::from_path(quill_dir).unwrap();
1259
1260 let all_assets = quill.find_files("assets/*");
1262 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1265 assert_eq!(typ_files.len(), 1);
1266 assert!(typ_files.contains(&PathBuf::from("plate.typ")));
1267 }
1268
1269 #[test]
1270 fn test_new_standardized_toml_format() {
1271 let temp_dir = TempDir::new().unwrap();
1272 let quill_dir = temp_dir.path();
1273
1274 let toml_content = r#"[Quill]
1276name = "my-custom-quill"
1277backend = "typst"
1278plate_file = "custom_plate.typ"
1279description = "Test quill with new format"
1280author = "Test Author"
1281"#;
1282 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1283 fs::write(
1284 quill_dir.join("custom_plate.typ"),
1285 "= Custom Template\n\nThis is a custom template.",
1286 )
1287 .unwrap();
1288
1289 let quill = Quill::from_path(quill_dir).unwrap();
1291
1292 assert_eq!(quill.name, "my-custom-quill");
1294
1295 assert!(quill.metadata.contains_key("backend"));
1297 if let Some(backend_val) = quill.metadata.get("backend") {
1298 if let Some(backend_str) = backend_val.as_str() {
1299 assert_eq!(backend_str, "typst");
1300 } else {
1301 panic!("Backend value is not a string");
1302 }
1303 }
1304
1305 assert!(quill.metadata.contains_key("description"));
1307 assert!(quill.metadata.contains_key("author"));
1308 assert!(!quill.metadata.contains_key("version")); assert!(quill.plate.unwrap().contains("Custom Template"));
1312 }
1313
1314 #[test]
1315 fn test_typst_packages_parsing() {
1316 let temp_dir = TempDir::new().unwrap();
1317 let quill_dir = temp_dir.path();
1318
1319 let toml_content = r#"
1320[Quill]
1321name = "test-quill"
1322backend = "typst"
1323plate_file = "plate.typ"
1324description = "Test quill for packages"
1325
1326[typst]
1327packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1328"#;
1329
1330 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1331 fs::write(quill_dir.join("plate.typ"), "test").unwrap();
1332
1333 let quill = Quill::from_path(quill_dir).unwrap();
1334 let packages = quill.typst_packages();
1335
1336 assert_eq!(packages.len(), 2);
1337 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1338 assert_eq!(packages[1], "@preview/example:1.0.0");
1339 }
1340
1341 #[test]
1342 fn test_template_loading() {
1343 let temp_dir = TempDir::new().unwrap();
1344 let quill_dir = temp_dir.path();
1345
1346 let toml_content = r#"[Quill]
1348name = "test-with-template"
1349backend = "typst"
1350plate_file = "plate.typ"
1351example_file = "example.md"
1352description = "Test quill with template"
1353"#;
1354 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1355 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1356 fs::write(
1357 quill_dir.join("example.md"),
1358 "---\ntitle: Test\n---\n\nThis is a test template.",
1359 )
1360 .unwrap();
1361
1362 let quill = Quill::from_path(quill_dir).unwrap();
1364
1365 assert!(quill.example.is_some());
1367 let example = quill.example.unwrap();
1368 assert!(example.contains("title: Test"));
1369 assert!(example.contains("This is a test template"));
1370
1371 assert_eq!(quill.plate.unwrap(), "plate content");
1373 }
1374
1375 #[test]
1376 fn test_template_optional() {
1377 let temp_dir = TempDir::new().unwrap();
1378 let quill_dir = temp_dir.path();
1379
1380 let toml_content = r#"[Quill]
1382name = "test-without-template"
1383backend = "typst"
1384plate_file = "plate.typ"
1385description = "Test quill without template"
1386"#;
1387 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1388 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1389
1390 let quill = Quill::from_path(quill_dir).unwrap();
1392
1393 assert_eq!(quill.example, None);
1395
1396 assert_eq!(quill.plate.unwrap(), "plate content");
1398 }
1399
1400 #[test]
1401 fn test_from_tree() {
1402 let mut root_files = HashMap::new();
1404
1405 let quill_toml = r#"[Quill]
1407name = "test-from-tree"
1408backend = "typst"
1409plate_file = "plate.typ"
1410description = "A test quill from tree"
1411"#;
1412 root_files.insert(
1413 "Quill.toml".to_string(),
1414 FileTreeNode::File {
1415 contents: quill_toml.as_bytes().to_vec(),
1416 },
1417 );
1418
1419 let plate_content = "= Test Template\n\nThis is a test.";
1421 root_files.insert(
1422 "plate.typ".to_string(),
1423 FileTreeNode::File {
1424 contents: plate_content.as_bytes().to_vec(),
1425 },
1426 );
1427
1428 let root = FileTreeNode::Directory { files: root_files };
1429
1430 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1432
1433 assert_eq!(quill.name, "test-from-tree");
1435 assert_eq!(quill.plate.unwrap(), plate_content);
1436 assert!(quill.metadata.contains_key("backend"));
1437 assert!(quill.metadata.contains_key("description"));
1438 }
1439
1440 #[test]
1441 fn test_from_tree_with_template() {
1442 let mut root_files = HashMap::new();
1443
1444 let quill_toml = r#"[Quill]
1446name = "test-tree-template"
1447backend = "typst"
1448plate_file = "plate.typ"
1449example_file = "template.md"
1450description = "Test tree with template"
1451"#;
1452 root_files.insert(
1453 "Quill.toml".to_string(),
1454 FileTreeNode::File {
1455 contents: quill_toml.as_bytes().to_vec(),
1456 },
1457 );
1458
1459 root_files.insert(
1461 "plate.typ".to_string(),
1462 FileTreeNode::File {
1463 contents: b"plate content".to_vec(),
1464 },
1465 );
1466
1467 let template_content = "# {{ title }}\n\n{{ body }}";
1469 root_files.insert(
1470 "template.md".to_string(),
1471 FileTreeNode::File {
1472 contents: template_content.as_bytes().to_vec(),
1473 },
1474 );
1475
1476 let root = FileTreeNode::Directory { files: root_files };
1477
1478 let quill = Quill::from_tree(root, None).unwrap();
1480
1481 assert_eq!(quill.example, Some(template_content.to_string()));
1483 }
1484
1485 #[test]
1486 fn test_from_json() {
1487 let json_str = r#"{
1489 "metadata": {
1490 "name": "test-from-json"
1491 },
1492 "files": {
1493 "Quill.toml": {
1494 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill from JSON\"\n"
1495 },
1496 "plate.typ": {
1497 "contents": "= Test Plate\n\nThis is test content."
1498 }
1499 }
1500 }"#;
1501
1502 let quill = Quill::from_json(json_str).unwrap();
1504
1505 assert_eq!(quill.name, "test-from-json");
1507 assert!(quill.plate.unwrap().contains("Test Plate"));
1508 assert!(quill.metadata.contains_key("backend"));
1509 }
1510
1511 #[test]
1512 fn test_from_json_with_byte_array() {
1513 let json_str = r#"{
1515 "files": {
1516 "Quill.toml": {
1517 "contents": "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1518 },
1519 "plate.typ": {
1520 "contents": "test plate"
1521 }
1522 }
1523 }"#;
1524
1525 let quill = Quill::from_json(json_str).unwrap();
1527
1528 assert_eq!(quill.name, "test");
1530 assert_eq!(quill.plate.unwrap(), "test plate");
1531 }
1532
1533 #[test]
1534 fn test_from_json_missing_files() {
1535 let json_str = r#"{
1537 "metadata": {
1538 "name": "test"
1539 }
1540 }"#;
1541
1542 let result = Quill::from_json(json_str);
1543 assert!(result.is_err());
1544 assert!(result.unwrap_err().to_string().contains("files"));
1546 }
1547
1548 #[test]
1549 fn test_from_json_tree_structure() {
1550 let json_str = r#"{
1552 "files": {
1553 "Quill.toml": {
1554 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test tree JSON\"\n"
1555 },
1556 "plate.typ": {
1557 "contents": "= Test Plate\n\nTree structure content."
1558 }
1559 }
1560 }"#;
1561
1562 let quill = Quill::from_json(json_str).unwrap();
1563
1564 assert_eq!(quill.name, "test-tree-json");
1565 assert!(quill.plate.unwrap().contains("Tree structure content"));
1566 assert!(quill.metadata.contains_key("backend"));
1567 }
1568
1569 #[test]
1570 fn test_from_json_nested_tree_structure() {
1571 let json_str = r#"{
1573 "files": {
1574 "Quill.toml": {
1575 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Nested test\"\n"
1576 },
1577 "plate.typ": {
1578 "contents": "plate"
1579 },
1580 "src": {
1581 "main.rs": {
1582 "contents": "fn main() {}"
1583 },
1584 "lib.rs": {
1585 "contents": "// lib"
1586 }
1587 }
1588 }
1589 }"#;
1590
1591 let quill = Quill::from_json(json_str).unwrap();
1592
1593 assert_eq!(quill.name, "nested-test");
1594 assert!(quill.file_exists("src/main.rs"));
1596 assert!(quill.file_exists("src/lib.rs"));
1597
1598 let main_rs = quill.get_file("src/main.rs").unwrap();
1599 assert_eq!(main_rs, b"fn main() {}");
1600 }
1601
1602 #[test]
1603 fn test_from_tree_structure_direct() {
1604 let mut root_files = HashMap::new();
1606
1607 root_files.insert(
1608 "Quill.toml".to_string(),
1609 FileTreeNode::File {
1610 contents:
1611 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Direct tree test\"\n"
1612 .to_vec(),
1613 },
1614 );
1615
1616 root_files.insert(
1617 "plate.typ".to_string(),
1618 FileTreeNode::File {
1619 contents: b"plate content".to_vec(),
1620 },
1621 );
1622
1623 let mut src_files = HashMap::new();
1625 src_files.insert(
1626 "main.rs".to_string(),
1627 FileTreeNode::File {
1628 contents: b"fn main() {}".to_vec(),
1629 },
1630 );
1631
1632 root_files.insert(
1633 "src".to_string(),
1634 FileTreeNode::Directory { files: src_files },
1635 );
1636
1637 let root = FileTreeNode::Directory { files: root_files };
1638
1639 let quill = Quill::from_tree(root, None).unwrap();
1640
1641 assert_eq!(quill.name, "direct-tree");
1642 assert!(quill.file_exists("src/main.rs"));
1643 assert!(quill.file_exists("plate.typ"));
1644 }
1645
1646 #[test]
1647 fn test_from_json_with_metadata_override() {
1648 let json_str = r#"{
1650 "metadata": {
1651 "name": "override-name"
1652 },
1653 "files": {
1654 "Quill.toml": {
1655 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"TOML name test\"\n"
1656 },
1657 "plate.typ": {
1658 "contents": "= plate"
1659 }
1660 }
1661 }"#;
1662
1663 let quill = Quill::from_json(json_str).unwrap();
1664 assert_eq!(quill.name, "toml-name");
1667 }
1668
1669 #[test]
1670 fn test_from_json_empty_directory() {
1671 let json_str = r#"{
1673 "files": {
1674 "Quill.toml": {
1675 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Empty directory test\"\n"
1676 },
1677 "plate.typ": {
1678 "contents": "plate"
1679 },
1680 "empty_dir": {}
1681 }
1682 }"#;
1683
1684 let quill = Quill::from_json(json_str).unwrap();
1685 assert_eq!(quill.name, "empty-dir-test");
1686 assert!(quill.dir_exists("empty_dir"));
1687 assert!(!quill.file_exists("empty_dir"));
1688 }
1689
1690 #[test]
1691 fn test_dir_exists_and_list_apis() {
1692 let mut root_files = HashMap::new();
1693
1694 root_files.insert(
1696 "Quill.toml".to_string(),
1697 FileTreeNode::File {
1698 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1699 .to_vec(),
1700 },
1701 );
1702
1703 root_files.insert(
1705 "plate.typ".to_string(),
1706 FileTreeNode::File {
1707 contents: b"plate content".to_vec(),
1708 },
1709 );
1710
1711 let mut assets_files = HashMap::new();
1713 assets_files.insert(
1714 "logo.png".to_string(),
1715 FileTreeNode::File {
1716 contents: vec![137, 80, 78, 71],
1717 },
1718 );
1719 assets_files.insert(
1720 "icon.svg".to_string(),
1721 FileTreeNode::File {
1722 contents: b"<svg></svg>".to_vec(),
1723 },
1724 );
1725
1726 let mut fonts_files = HashMap::new();
1728 fonts_files.insert(
1729 "font.ttf".to_string(),
1730 FileTreeNode::File {
1731 contents: b"font data".to_vec(),
1732 },
1733 );
1734 assets_files.insert(
1735 "fonts".to_string(),
1736 FileTreeNode::Directory { files: fonts_files },
1737 );
1738
1739 root_files.insert(
1740 "assets".to_string(),
1741 FileTreeNode::Directory {
1742 files: assets_files,
1743 },
1744 );
1745
1746 root_files.insert(
1748 "empty".to_string(),
1749 FileTreeNode::Directory {
1750 files: HashMap::new(),
1751 },
1752 );
1753
1754 let root = FileTreeNode::Directory { files: root_files };
1755 let quill = Quill::from_tree(root, None).unwrap();
1756
1757 assert!(quill.dir_exists("assets"));
1759 assert!(quill.dir_exists("assets/fonts"));
1760 assert!(quill.dir_exists("empty"));
1761 assert!(!quill.dir_exists("nonexistent"));
1762 assert!(!quill.dir_exists("plate.typ")); assert!(quill.file_exists("plate.typ"));
1766 assert!(quill.file_exists("assets/logo.png"));
1767 assert!(quill.file_exists("assets/fonts/font.ttf"));
1768 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1772 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1774 assert!(root_files_list.contains(&"plate.typ".to_string()));
1775
1776 let assets_files_list = quill.list_files("assets");
1777 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1779 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1780
1781 let root_subdirs = quill.list_subdirectories("");
1783 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1785 assert!(root_subdirs.contains(&"empty".to_string()));
1786
1787 let assets_subdirs = quill.list_subdirectories("assets");
1788 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1790
1791 let empty_subdirs = quill.list_subdirectories("empty");
1792 assert_eq!(empty_subdirs.len(), 0);
1793 }
1794
1795 #[test]
1796 fn test_field_schemas_parsing() {
1797 let mut root_files = HashMap::new();
1798
1799 let quill_toml = r#"[Quill]
1801name = "taro"
1802backend = "typst"
1803plate_file = "plate.typ"
1804example_file = "taro.md"
1805description = "Test template for field schemas"
1806
1807[fields]
1808author = {description = "Author of document" }
1809ice_cream = {description = "favorite ice cream flavor"}
1810title = {description = "title of document" }
1811"#;
1812 root_files.insert(
1813 "Quill.toml".to_string(),
1814 FileTreeNode::File {
1815 contents: quill_toml.as_bytes().to_vec(),
1816 },
1817 );
1818
1819 let plate_content = "= Test Template\n\nThis is a test.";
1821 root_files.insert(
1822 "plate.typ".to_string(),
1823 FileTreeNode::File {
1824 contents: plate_content.as_bytes().to_vec(),
1825 },
1826 );
1827
1828 root_files.insert(
1830 "taro.md".to_string(),
1831 FileTreeNode::File {
1832 contents: b"# Template".to_vec(),
1833 },
1834 );
1835
1836 let root = FileTreeNode::Directory { files: root_files };
1837
1838 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1840
1841 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1843 assert!(quill.schema["properties"]
1844 .as_object()
1845 .unwrap()
1846 .contains_key("author"));
1847 assert!(quill.schema["properties"]
1848 .as_object()
1849 .unwrap()
1850 .contains_key("ice_cream"));
1851 assert!(quill.schema["properties"]
1852 .as_object()
1853 .unwrap()
1854 .contains_key("title"));
1855
1856 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1858 assert_eq!(author_schema["description"], "Author of document");
1859
1860 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1862 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1863
1864 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1866 assert_eq!(title_schema["description"], "title of document");
1867 }
1868
1869 #[test]
1870 fn test_field_schema_struct() {
1871 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1873 assert_eq!(schema1.description, "Test description");
1874 assert_eq!(schema1.r#type, None);
1875 assert_eq!(schema1.examples, None);
1876 assert_eq!(schema1.default, None);
1877
1878 let yaml_str = r#"
1880description: "Full field schema"
1881type: "string"
1882examples:
1883 - "Example value"
1884default: "Default value"
1885"#;
1886 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1887 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1888 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1889 assert_eq!(schema2.name, "test_name");
1890 assert_eq!(schema2.description, "Full field schema");
1891 assert_eq!(schema2.r#type, Some("string".to_string()));
1892 assert_eq!(
1893 schema2
1894 .examples
1895 .as_ref()
1896 .and_then(|v| v.as_array())
1897 .and_then(|arr| arr.first())
1898 .and_then(|v| v.as_str()),
1899 Some("Example value")
1900 );
1901 assert_eq!(
1902 schema2.default.as_ref().and_then(|v| v.as_str()),
1903 Some("Default value")
1904 );
1905 }
1906
1907 #[test]
1908 fn test_quill_without_plate_file() {
1909 let mut root_files = HashMap::new();
1911
1912 let quill_toml = r#"[Quill]
1914name = "test-no-plate"
1915backend = "typst"
1916description = "Test quill without plate file"
1917"#;
1918 root_files.insert(
1919 "Quill.toml".to_string(),
1920 FileTreeNode::File {
1921 contents: quill_toml.as_bytes().to_vec(),
1922 },
1923 );
1924
1925 let root = FileTreeNode::Directory { files: root_files };
1926
1927 let quill = Quill::from_tree(root, None).unwrap();
1929
1930 assert!(quill.plate.clone().is_none());
1932 assert_eq!(quill.name, "test-no-plate");
1933 }
1934
1935 #[test]
1936 fn test_quill_config_from_toml() {
1937 let toml_content = r#"[Quill]
1939name = "test-config"
1940backend = "typst"
1941description = "Test configuration parsing"
1942version = "1.0.0"
1943author = "Test Author"
1944plate_file = "plate.typ"
1945example_file = "example.md"
1946
1947[typst]
1948packages = ["@preview/bubble:0.2.2"]
1949
1950[fields]
1951title = {description = "Document title", type = "string"}
1952author = {description = "Document author"}
1953"#;
1954
1955 let config = QuillConfig::from_toml(toml_content).unwrap();
1956
1957 assert_eq!(config.name, "test-config");
1959 assert_eq!(config.backend, "typst");
1960 assert_eq!(config.description, "Test configuration parsing");
1961
1962 assert_eq!(config.version, Some("1.0.0".to_string()));
1964 assert_eq!(config.author, Some("Test Author".to_string()));
1965 assert_eq!(config.plate_file, Some("plate.typ".to_string()));
1966 assert_eq!(config.example_file, Some("example.md".to_string()));
1967
1968 assert!(config.typst_config.contains_key("packages"));
1970
1971 assert_eq!(config.fields.len(), 2);
1973 assert!(config.fields.contains_key("title"));
1974 assert!(config.fields.contains_key("author"));
1975
1976 let title_field = &config.fields["title"];
1977 assert_eq!(title_field.description, "Document title");
1978 assert_eq!(title_field.r#type, Some("string".to_string()));
1979 }
1980
1981 #[test]
1982 fn test_quill_config_missing_required_fields() {
1983 let toml_missing_name = r#"[Quill]
1985backend = "typst"
1986description = "Missing name"
1987"#;
1988 let result = QuillConfig::from_toml(toml_missing_name);
1989 assert!(result.is_err());
1990 assert!(result
1991 .unwrap_err()
1992 .to_string()
1993 .contains("Missing required 'name'"));
1994
1995 let toml_missing_backend = r#"[Quill]
1996name = "test"
1997description = "Missing backend"
1998"#;
1999 let result = QuillConfig::from_toml(toml_missing_backend);
2000 assert!(result.is_err());
2001 assert!(result
2002 .unwrap_err()
2003 .to_string()
2004 .contains("Missing required 'backend'"));
2005
2006 let toml_missing_description = r#"[Quill]
2007name = "test"
2008backend = "typst"
2009"#;
2010 let result = QuillConfig::from_toml(toml_missing_description);
2011 assert!(result.is_err());
2012 assert!(result
2013 .unwrap_err()
2014 .to_string()
2015 .contains("Missing required 'description'"));
2016 }
2017
2018 #[test]
2019 fn test_quill_config_empty_description() {
2020 let toml_empty_description = r#"[Quill]
2022name = "test"
2023backend = "typst"
2024description = " "
2025"#;
2026 let result = QuillConfig::from_toml(toml_empty_description);
2027 assert!(result.is_err());
2028 assert!(result
2029 .unwrap_err()
2030 .to_string()
2031 .contains("description' field in [Quill] section cannot be empty"));
2032 }
2033
2034 #[test]
2035 fn test_quill_config_missing_quill_section() {
2036 let toml_no_section = r#"[fields]
2038title = {description = "Title"}
2039"#;
2040 let result = QuillConfig::from_toml(toml_no_section);
2041 assert!(result.is_err());
2042 assert!(result
2043 .unwrap_err()
2044 .to_string()
2045 .contains("Missing required [Quill] section"));
2046 }
2047
2048 #[test]
2049 fn test_quill_from_config_metadata() {
2050 let mut root_files = HashMap::new();
2052
2053 let quill_toml = r#"[Quill]
2054name = "metadata-test"
2055backend = "typst"
2056description = "Test metadata flow"
2057author = "Test Author"
2058custom_field = "custom_value"
2059
2060[typst]
2061packages = ["@preview/bubble:0.2.2"]
2062"#;
2063 root_files.insert(
2064 "Quill.toml".to_string(),
2065 FileTreeNode::File {
2066 contents: quill_toml.as_bytes().to_vec(),
2067 },
2068 );
2069
2070 let root = FileTreeNode::Directory { files: root_files };
2071 let quill = Quill::from_tree(root, None).unwrap();
2072
2073 assert!(quill.metadata.contains_key("backend"));
2075 assert!(quill.metadata.contains_key("description"));
2076 assert!(quill.metadata.contains_key("author"));
2077
2078 assert!(quill.metadata.contains_key("custom_field"));
2080 assert_eq!(
2081 quill.metadata.get("custom_field").unwrap().as_str(),
2082 Some("custom_value")
2083 );
2084
2085 assert!(quill.metadata.contains_key("typst_packages"));
2087 }
2088
2089 #[test]
2090 fn test_extract_defaults_method() {
2091 let mut root_files = HashMap::new();
2093
2094 let quill_toml = r#"[Quill]
2095name = "defaults-test"
2096backend = "typst"
2097description = "Test defaults extraction"
2098
2099[fields]
2100title = {description = "Title"}
2101author = {description = "Author", default = "Anonymous"}
2102status = {description = "Status", default = "draft"}
2103"#;
2104
2105 root_files.insert(
2106 "Quill.toml".to_string(),
2107 FileTreeNode::File {
2108 contents: quill_toml.as_bytes().to_vec(),
2109 },
2110 );
2111
2112 let root = FileTreeNode::Directory { files: root_files };
2113 let quill = Quill::from_tree(root, None).unwrap();
2114
2115 let defaults = quill.extract_defaults();
2117
2118 assert_eq!(defaults.len(), 2);
2120 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2122 assert!(defaults.contains_key("status"));
2123
2124 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2126 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2127 }
2128
2129 #[test]
2130 fn test_field_order_preservation() {
2131 let toml_content = r#"[Quill]
2132name = "order-test"
2133backend = "typst"
2134description = "Test field order"
2135
2136[fields]
2137first = {description = "First field"}
2138second = {description = "Second field"}
2139third = {description = "Third field", ui = {group = "Test Group"}}
2140fourth = {description = "Fourth field"}
2141"#;
2142
2143 let config = QuillConfig::from_toml(toml_content).unwrap();
2144
2145 let first = config.fields.get("first").unwrap();
2149 assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2150
2151 let second = config.fields.get("second").unwrap();
2152 assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2153
2154 let third = config.fields.get("third").unwrap();
2155 assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2156 assert_eq!(
2157 third.ui.as_ref().unwrap().group,
2158 Some("Test Group".to_string())
2159 );
2160
2161 let fourth = config.fields.get("fourth").unwrap();
2162 assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2163 }
2164
2165 #[test]
2166 fn test_quill_with_all_ui_properties() {
2167 let toml_content = r#"[Quill]
2168name = "full-ui-test"
2169backend = "typst"
2170description = "Test all UI properties"
2171
2172[fields.author]
2173description = "The full name of the document author"
2174type = "str"
2175
2176[fields.author.ui]
2177group = "Author Info"
2178"#;
2179
2180 let config = QuillConfig::from_toml(toml_content).unwrap();
2181
2182 let author_field = &config.fields["author"];
2183 let ui = author_field.ui.as_ref().unwrap();
2184 assert_eq!(ui.group, Some("Author Info".to_string()));
2185 assert_eq!(ui.order, Some(0)); }
2187 #[test]
2188 fn test_field_schema_with_title_and_description() {
2189 let yaml = r#"
2191title: "Field Title"
2192description: "Detailed field description"
2193type: "string"
2194examples:
2195 - "Example value"
2196ui:
2197 group: "Test Group"
2198"#;
2199 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2200 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2201 let schema = FieldSchema::from_quill_value("test_field".to_string(), &quill_value).unwrap();
2202
2203 assert_eq!(schema.title, Some("Field Title".to_string()));
2204 assert_eq!(schema.description, "Detailed field description");
2205
2206 assert_eq!(
2207 schema
2208 .examples
2209 .as_ref()
2210 .and_then(|v| v.as_array())
2211 .and_then(|arr| arr.first())
2212 .and_then(|v| v.as_str()),
2213 Some("Example value")
2214 );
2215
2216 let ui = schema.ui.as_ref().unwrap();
2217 assert_eq!(ui.group, Some("Test Group".to_string()));
2218 }
2219
2220 #[test]
2221 fn test_parse_scope_field_type() {
2222 let yaml = r#"
2224type: "scope"
2225title: "Endorsements"
2226description: "Chain of endorsements for routing"
2227"#;
2228 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2229 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2230 let schema =
2231 FieldSchema::from_quill_value("endorsements".to_string(), &quill_value).unwrap();
2232
2233 assert_eq!(schema.name, "endorsements");
2234 assert_eq!(schema.r#type, Some("scope".to_string()));
2235 assert_eq!(schema.title, Some("Endorsements".to_string()));
2236 assert_eq!(schema.description, "Chain of endorsements for routing");
2237 assert!(schema.items.is_none()); }
2239
2240 #[test]
2241 fn test_parse_scope_items() {
2242 let yaml = r#"
2244type: "scope"
2245title: "Endorsements"
2246description: "Chain of endorsements"
2247items:
2248 name:
2249 type: "string"
2250 title: "Endorser Name"
2251 description: "Name of the endorsing official"
2252 org:
2253 type: "string"
2254 title: "Organization"
2255 description: "Endorser's organization"
2256 default: "Unknown"
2257"#;
2258 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2259 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2260 let schema =
2261 FieldSchema::from_quill_value("endorsements".to_string(), &quill_value).unwrap();
2262
2263 assert_eq!(schema.r#type, Some("scope".to_string()));
2264 assert!(schema.items.is_some());
2265
2266 let items = schema.items.as_ref().unwrap();
2267 assert_eq!(items.len(), 2);
2268
2269 let name_item = items.get("name").unwrap();
2271 assert_eq!(name_item.r#type, Some("string".to_string()));
2272 assert_eq!(name_item.title, Some("Endorser Name".to_string()));
2273 assert!(name_item.default.is_none());
2274
2275 let org_item = items.get("org").unwrap();
2277 assert_eq!(org_item.r#type, Some("string".to_string()));
2278 assert!(org_item.default.is_some());
2279 assert_eq!(org_item.default.as_ref().unwrap().as_str(), Some("Unknown"));
2280 }
2281
2282 #[test]
2283 fn test_scope_items_error_without_scope_type() {
2284 let yaml = r#"
2286type: "string"
2287description: "A string field"
2288items:
2289 sub_field:
2290 type: "string"
2291 description: "Nested field"
2292"#;
2293 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2294 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2295 let result = FieldSchema::from_quill_value("author".to_string(), &quill_value);
2296
2297 assert!(result.is_err());
2298 let err = result.unwrap_err();
2299 assert!(err.contains("'items' is only valid when type = \"scope\""));
2300 }
2301
2302 #[test]
2303 fn test_scope_nested_scope_error() {
2304 let yaml = r#"
2306type: "scope"
2307description: "Outer scope"
2308items:
2309 inner:
2310 type: "scope"
2311 description: "Illegal nested scope"
2312"#;
2313 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2314 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2315 let result = FieldSchema::from_quill_value("outer".to_string(), &quill_value);
2316
2317 assert!(result.is_err());
2318 let err = result.unwrap_err();
2319 assert!(err.contains("nested scopes are not supported"));
2320 }
2321}