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_card_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_card_items && field_type.as_deref() == Some("card") {
104 return Err(format!(
105 "Field '{}': nested cards 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("card") {
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, card_order): (Vec<String>, Vec<String>) = toml_content
547 .parse::<toml_edit::DocumentMut>()
548 .ok()
549 .map(|doc| {
550 let f_order = doc
551 .get("fields")
552 .and_then(|item| item.as_table())
553 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
554 .unwrap_or_default();
555
556 let s_order = doc
557 .get("cards")
558 .and_then(|item| item.as_table())
559 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
560 .unwrap_or_default();
561
562 (f_order, s_order)
563 })
564 .unwrap_or_default();
565
566 let quill_section = quill_toml
568 .get("Quill")
569 .ok_or("Missing required [Quill] section in Quill.toml")?;
570
571 let name = quill_section
573 .get("name")
574 .and_then(|v| v.as_str())
575 .ok_or("Missing required 'name' field in [Quill] section")?
576 .to_string();
577
578 let backend = quill_section
579 .get("backend")
580 .and_then(|v| v.as_str())
581 .ok_or("Missing required 'backend' field in [Quill] section")?
582 .to_string();
583
584 let description = quill_section
585 .get("description")
586 .and_then(|v| v.as_str())
587 .ok_or("Missing required 'description' field in [Quill] section")?;
588
589 if description.trim().is_empty() {
590 return Err("'description' field in [Quill] section cannot be empty".into());
591 }
592 let description = description.to_string();
593
594 let version = quill_section
596 .get("version")
597 .and_then(|v| v.as_str())
598 .map(|s| s.to_string());
599
600 let author = quill_section
601 .get("author")
602 .and_then(|v| v.as_str())
603 .map(|s| s.to_string());
604
605 let example_file = quill_section
606 .get("example_file")
607 .and_then(|v| v.as_str())
608 .map(|s| s.to_string());
609
610 let plate_file = quill_section
611 .get("plate_file")
612 .and_then(|v| v.as_str())
613 .map(|s| s.to_string());
614
615 let mut metadata = HashMap::new();
617 if let toml::Value::Table(table) = quill_section {
618 for (key, value) in table {
619 if key != "name"
621 && key != "backend"
622 && key != "description"
623 && key != "version"
624 && key != "author"
625 && key != "example_file"
626 && key != "plate_file"
627 {
628 match QuillValue::from_toml(value) {
629 Ok(quill_value) => {
630 metadata.insert(key.clone(), quill_value);
631 }
632 Err(e) => {
633 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
634 }
635 }
636 }
637 }
638 }
639
640 let mut typst_config = HashMap::new();
642 if let Some(typst_section) = quill_toml.get("typst") {
643 if let toml::Value::Table(table) = typst_section {
644 for (key, value) in table {
645 match QuillValue::from_toml(value) {
646 Ok(quill_value) => {
647 typst_config.insert(key.clone(), quill_value);
648 }
649 Err(e) => {
650 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
651 }
652 }
653 }
654 }
655 }
656
657 let mut fields = HashMap::new();
659 if let Some(fields_section) = quill_toml.get("fields") {
660 if let toml::Value::Table(fields_table) = fields_section {
661 let mut order_counter = 0;
662 for (field_name, field_schema) in fields_table {
663 let order = if let Some(idx) = field_order.iter().position(|k| k == field_name)
665 {
666 idx as i32
667 } else {
668 let o = field_order.len() as i32 + order_counter;
669 order_counter += 1;
670 o
671 };
672
673 match QuillValue::from_toml(field_schema) {
674 Ok(quill_value) => {
675 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
676 Ok(mut schema) => {
677 if schema.ui.is_none() {
679 schema.ui = Some(UiSchema {
680 group: None,
681 order: Some(order),
682 });
683 } else if let Some(ui) = &mut schema.ui {
684 ui.order = Some(order);
685 }
686
687 fields.insert(field_name.clone(), schema);
688 }
689 Err(e) => {
690 eprintln!(
691 "Warning: Failed to parse field schema '{}': {}",
692 field_name, e
693 );
694 }
695 }
696 }
697 Err(e) => {
698 eprintln!(
699 "Warning: Failed to convert field schema '{}': {}",
700 field_name, e
701 );
702 }
703 }
704 }
705 }
706 }
707
708 if let Some(cards_section) = quill_toml.get("cards") {
710 if let toml::Value::Table(cards_table) = cards_section {
711 let current_field_count = fields.len() as i32;
712 let mut order_counter = 0;
713
714 for (card_name, card_schema) in cards_table {
715 if fields.contains_key(card_name) {
717 return Err(format!(
718 "Card definition '{}' conflicts with an existing field name",
719 card_name
720 )
721 .into());
722 }
723
724 let order = if let Some(idx) = card_order.iter().position(|k| k == card_name) {
726 current_field_count + idx as i32
727 } else {
728 let o = current_field_count + card_order.len() as i32 + order_counter;
729 order_counter += 1;
730 o
731 };
732
733 match QuillValue::from_toml(card_schema) {
734 Ok(quill_value) => {
735 let mut json_val = quill_value.into_json();
737
738 if let Some(obj) = json_val.as_object_mut() {
739 if let Some(type_val) = obj.get("type") {
740 if type_val.as_str() != Some("card") {
741 return Err(format!(
742 "Card '{}' must have type=\"scope\" (found {:?})",
743 card_name,
744 type_val.as_str()
745 )
746 .into());
747 }
748 } else {
749 obj.insert(
751 "type".to_string(),
752 serde_json::Value::String("card".to_string()),
753 );
754 }
755 } else {
756 return Err(format!(
757 "Card definition '{}' must be an object",
758 card_name
759 )
760 .into());
761 }
762
763 let final_quill_value = QuillValue::from_json(json_val);
764
765 match FieldSchema::from_quill_value(
766 card_name.clone(),
767 &final_quill_value,
768 ) {
769 Ok(mut schema) => {
770 if schema.ui.is_none() {
772 schema.ui = Some(UiSchema {
773 group: None,
774 order: Some(order),
775 });
776 } else if let Some(ui) = &mut schema.ui {
777 ui.order = Some(order);
778 }
779
780 fields.insert(card_name.clone(), schema);
781 }
782 Err(e) => {
783 eprintln!(
784 "Warning: Failed to parse scope schema '{}': {}",
785 card_name, e
786 );
787 }
788 }
789 }
790 Err(e) => {
791 eprintln!(
792 "Warning: Failed to convert scope schema '{}': {}",
793 card_name, e
794 );
795 }
796 }
797 }
798 }
799 }
800
801 Ok(QuillConfig {
802 name,
803 description,
804 backend,
805 version,
806 author,
807 example_file,
808 plate_file,
809 fields,
810 metadata,
811 typst_config,
812 })
813 }
814}
815
816impl Quill {
817 pub fn from_path<P: AsRef<std::path::Path>>(
819 path: P,
820 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
821 use std::fs;
822
823 let path = path.as_ref();
824 let name = path
825 .file_name()
826 .and_then(|n| n.to_str())
827 .unwrap_or("unnamed")
828 .to_string();
829
830 let quillignore_path = path.join(".quillignore");
832 let ignore = if quillignore_path.exists() {
833 let ignore_content = fs::read_to_string(&quillignore_path)
834 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
835 QuillIgnore::from_content(&ignore_content)
836 } else {
837 QuillIgnore::new(vec![
839 ".git/".to_string(),
840 ".gitignore".to_string(),
841 ".quillignore".to_string(),
842 "target/".to_string(),
843 "node_modules/".to_string(),
844 ])
845 };
846
847 let root = Self::load_directory_as_tree(path, path, &ignore)?;
849
850 Self::from_tree(root, Some(name))
852 }
853
854 pub fn from_tree(
871 root: FileTreeNode,
872 _default_name: Option<String>,
873 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
874 let quill_toml_bytes = root
876 .get_file("Quill.toml")
877 .ok_or("Quill.toml not found in file tree")?;
878
879 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
880 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
881
882 let config = QuillConfig::from_toml(&quill_toml_content)?;
884
885 Self::from_config(config, root)
887 }
888
889 fn from_config(
906 config: QuillConfig,
907 root: FileTreeNode,
908 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
909 let mut metadata = config.metadata.clone();
911
912 metadata.insert(
914 "backend".to_string(),
915 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
916 );
917
918 metadata.insert(
920 "description".to_string(),
921 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
922 );
923
924 if let Some(ref author) = config.author {
926 metadata.insert(
927 "author".to_string(),
928 QuillValue::from_json(serde_json::Value::String(author.clone())),
929 );
930 }
931
932 for (key, value) in &config.typst_config {
934 metadata.insert(format!("typst_{}", key), value.clone());
935 }
936
937 let schema = build_schema_from_fields(&config.fields)
939 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
940
941 let plate_content: Option<String> = if let Some(ref plate_file_name) = config.plate_file {
943 let plate_bytes = root.get_file(plate_file_name).ok_or_else(|| {
944 format!("Plate file '{}' not found in file tree", plate_file_name)
945 })?;
946
947 let content = String::from_utf8(plate_bytes.to_vec()).map_err(|e| {
948 format!("Plate file '{}' is not valid UTF-8: {}", plate_file_name, e)
949 })?;
950 Some(content)
951 } else {
952 None
954 };
955
956 let example_content = if let Some(ref example_file_name) = config.example_file {
958 root.get_file(example_file_name).and_then(|bytes| {
959 String::from_utf8(bytes.to_vec())
960 .map_err(|e| {
961 eprintln!(
962 "Warning: Example file '{}' is not valid UTF-8: {}",
963 example_file_name, e
964 );
965 e
966 })
967 .ok()
968 })
969 } else {
970 None
971 };
972
973 let defaults = crate::schema::extract_defaults_from_schema(&schema);
975 let examples = crate::schema::extract_examples_from_schema(&schema);
976
977 let quill = Quill {
978 metadata,
979 name: config.name,
980 backend: config.backend,
981 plate: plate_content,
982 example: example_content,
983 schema,
984 defaults,
985 examples,
986 files: root,
987 };
988
989 Ok(quill)
990 }
991
992 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
999 use serde_json::Value as JsonValue;
1000
1001 let json: JsonValue =
1002 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1003
1004 let obj = json.as_object().ok_or("Root must be an object")?;
1005
1006 let default_name = obj
1008 .get("metadata")
1009 .and_then(|m| m.get("name"))
1010 .and_then(|v| v.as_str())
1011 .map(String::from);
1012
1013 let files_obj = obj
1015 .get("files")
1016 .and_then(|v| v.as_object())
1017 .ok_or("Missing or invalid 'files' key")?;
1018
1019 let mut root_files = HashMap::new();
1021 for (key, value) in files_obj {
1022 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
1023 }
1024
1025 let root = FileTreeNode::Directory { files: root_files };
1026
1027 Self::from_tree(root, default_name)
1029 }
1030
1031 fn load_directory_as_tree(
1033 current_dir: &Path,
1034 base_dir: &Path,
1035 ignore: &QuillIgnore,
1036 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
1037 use std::fs;
1038
1039 if !current_dir.exists() {
1040 return Ok(FileTreeNode::Directory {
1041 files: HashMap::new(),
1042 });
1043 }
1044
1045 let mut files = HashMap::new();
1046
1047 for entry in fs::read_dir(current_dir)? {
1048 let entry = entry?;
1049 let path = entry.path();
1050 let relative_path = path
1051 .strip_prefix(base_dir)
1052 .map_err(|e| format!("Failed to get relative path: {}", e))?
1053 .to_path_buf();
1054
1055 if ignore.is_ignored(&relative_path) {
1057 continue;
1058 }
1059
1060 let filename = path
1062 .file_name()
1063 .and_then(|n| n.to_str())
1064 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
1065 .to_string();
1066
1067 if path.is_file() {
1068 let contents = fs::read(&path)
1069 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
1070
1071 files.insert(filename, FileTreeNode::File { contents });
1072 } else if path.is_dir() {
1073 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
1075 files.insert(filename, subdir_tree);
1076 }
1077 }
1078
1079 Ok(FileTreeNode::Directory { files })
1080 }
1081
1082 pub fn typst_packages(&self) -> Vec<String> {
1084 self.metadata
1085 .get("typst_packages")
1086 .and_then(|v| v.as_array())
1087 .map(|arr| {
1088 arr.iter()
1089 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1090 .collect()
1091 })
1092 .unwrap_or_default()
1093 }
1094
1095 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
1103 &self.defaults
1104 }
1105
1106 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
1111 &self.examples
1112 }
1113
1114 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
1116 self.files.get_file(path)
1117 }
1118
1119 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1121 self.files.file_exists(path)
1122 }
1123
1124 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1126 self.files.dir_exists(path)
1127 }
1128
1129 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1131 self.files.list_files(path)
1132 }
1133
1134 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1136 self.files.list_subdirectories(path)
1137 }
1138
1139 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1141 let dir_path = dir_path.as_ref();
1142 let filenames = self.files.list_files(dir_path);
1143
1144 filenames
1146 .iter()
1147 .map(|name| {
1148 if dir_path == Path::new("") {
1149 PathBuf::from(name)
1150 } else {
1151 dir_path.join(name)
1152 }
1153 })
1154 .collect()
1155 }
1156
1157 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1159 let dir_path = dir_path.as_ref();
1160 let subdirs = self.files.list_subdirectories(dir_path);
1161
1162 subdirs
1164 .iter()
1165 .map(|name| {
1166 if dir_path == Path::new("") {
1167 PathBuf::from(name)
1168 } else {
1169 dir_path.join(name)
1170 }
1171 })
1172 .collect()
1173 }
1174
1175 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1177 let pattern_str = pattern.as_ref().to_string_lossy();
1178 let mut matches = Vec::new();
1179
1180 let glob_pattern = match glob::Pattern::new(&pattern_str) {
1182 Ok(pat) => pat,
1183 Err(_) => return matches, };
1185
1186 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1188
1189 matches.sort();
1190 matches
1191 }
1192
1193 fn find_files_recursive(
1195 &self,
1196 node: &FileTreeNode,
1197 current_path: &Path,
1198 pattern: &glob::Pattern,
1199 matches: &mut Vec<PathBuf>,
1200 ) {
1201 match node {
1202 FileTreeNode::File { .. } => {
1203 let path_str = current_path.to_string_lossy();
1204 if pattern.matches(&path_str) {
1205 matches.push(current_path.to_path_buf());
1206 }
1207 }
1208 FileTreeNode::Directory { files } => {
1209 for (name, child_node) in files {
1210 let child_path = if current_path == Path::new("") {
1211 PathBuf::from(name)
1212 } else {
1213 current_path.join(name)
1214 };
1215 self.find_files_recursive(child_node, &child_path, pattern, matches);
1216 }
1217 }
1218 }
1219 }
1220}
1221
1222#[cfg(test)]
1223mod tests {
1224 use super::*;
1225 use std::fs;
1226 use tempfile::TempDir;
1227
1228 #[test]
1229 fn test_quillignore_parsing() {
1230 let ignore_content = r#"
1231# This is a comment
1232*.tmp
1233target/
1234node_modules/
1235.git/
1236"#;
1237 let ignore = QuillIgnore::from_content(ignore_content);
1238 assert_eq!(ignore.patterns.len(), 4);
1239 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1240 assert!(ignore.patterns.contains(&"target/".to_string()));
1241 }
1242
1243 #[test]
1244 fn test_quillignore_matching() {
1245 let ignore = QuillIgnore::new(vec![
1246 "*.tmp".to_string(),
1247 "target/".to_string(),
1248 "node_modules/".to_string(),
1249 ".git/".to_string(),
1250 ]);
1251
1252 assert!(ignore.is_ignored("test.tmp"));
1254 assert!(ignore.is_ignored("path/to/file.tmp"));
1255 assert!(!ignore.is_ignored("test.txt"));
1256
1257 assert!(ignore.is_ignored("target"));
1259 assert!(ignore.is_ignored("target/debug"));
1260 assert!(ignore.is_ignored("target/debug/deps"));
1261 assert!(!ignore.is_ignored("src/target.rs"));
1262
1263 assert!(ignore.is_ignored("node_modules"));
1264 assert!(ignore.is_ignored("node_modules/package"));
1265 assert!(!ignore.is_ignored("my_node_modules"));
1266 }
1267
1268 #[test]
1269 fn test_in_memory_file_system() {
1270 let temp_dir = TempDir::new().unwrap();
1271 let quill_dir = temp_dir.path();
1272
1273 fs::write(
1275 quill_dir.join("Quill.toml"),
1276 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1277 )
1278 .unwrap();
1279 fs::write(quill_dir.join("plate.typ"), "test plate").unwrap();
1280
1281 let assets_dir = quill_dir.join("assets");
1282 fs::create_dir_all(&assets_dir).unwrap();
1283 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1284
1285 let packages_dir = quill_dir.join("packages");
1286 fs::create_dir_all(&packages_dir).unwrap();
1287 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1288
1289 let quill = Quill::from_path(quill_dir).unwrap();
1291
1292 assert!(quill.file_exists("plate.typ"));
1294 assert!(quill.file_exists("assets/test.txt"));
1295 assert!(quill.file_exists("packages/package.typ"));
1296 assert!(!quill.file_exists("nonexistent.txt"));
1297
1298 let asset_content = quill.get_file("assets/test.txt").unwrap();
1300 assert_eq!(asset_content, b"asset content");
1301
1302 let asset_files = quill.list_directory("assets");
1304 assert_eq!(asset_files.len(), 1);
1305 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1306 }
1307
1308 #[test]
1309 fn test_quillignore_integration() {
1310 let temp_dir = TempDir::new().unwrap();
1311 let quill_dir = temp_dir.path();
1312
1313 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1315
1316 fs::write(
1318 quill_dir.join("Quill.toml"),
1319 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1320 )
1321 .unwrap();
1322 fs::write(quill_dir.join("plate.typ"), "test template").unwrap();
1323 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1324
1325 let target_dir = quill_dir.join("target");
1326 fs::create_dir_all(&target_dir).unwrap();
1327 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1328
1329 let quill = Quill::from_path(quill_dir).unwrap();
1331
1332 assert!(quill.file_exists("plate.typ"));
1334 assert!(!quill.file_exists("should_ignore.tmp"));
1335 assert!(!quill.file_exists("target/debug.txt"));
1336 }
1337
1338 #[test]
1339 fn test_find_files_pattern() {
1340 let temp_dir = TempDir::new().unwrap();
1341 let quill_dir = temp_dir.path();
1342
1343 fs::write(
1345 quill_dir.join("Quill.toml"),
1346 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1347 )
1348 .unwrap();
1349 fs::write(quill_dir.join("plate.typ"), "template").unwrap();
1350
1351 let assets_dir = quill_dir.join("assets");
1352 fs::create_dir_all(&assets_dir).unwrap();
1353 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1354 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1355
1356 let fonts_dir = assets_dir.join("fonts");
1357 fs::create_dir_all(&fonts_dir).unwrap();
1358 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1359
1360 let quill = Quill::from_path(quill_dir).unwrap();
1362
1363 let all_assets = quill.find_files("assets/*");
1365 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1368 assert_eq!(typ_files.len(), 1);
1369 assert!(typ_files.contains(&PathBuf::from("plate.typ")));
1370 }
1371
1372 #[test]
1373 fn test_new_standardized_toml_format() {
1374 let temp_dir = TempDir::new().unwrap();
1375 let quill_dir = temp_dir.path();
1376
1377 let toml_content = r#"[Quill]
1379name = "my-custom-quill"
1380backend = "typst"
1381plate_file = "custom_plate.typ"
1382description = "Test quill with new format"
1383author = "Test Author"
1384"#;
1385 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1386 fs::write(
1387 quill_dir.join("custom_plate.typ"),
1388 "= Custom Template\n\nThis is a custom template.",
1389 )
1390 .unwrap();
1391
1392 let quill = Quill::from_path(quill_dir).unwrap();
1394
1395 assert_eq!(quill.name, "my-custom-quill");
1397
1398 assert!(quill.metadata.contains_key("backend"));
1400 if let Some(backend_val) = quill.metadata.get("backend") {
1401 if let Some(backend_str) = backend_val.as_str() {
1402 assert_eq!(backend_str, "typst");
1403 } else {
1404 panic!("Backend value is not a string");
1405 }
1406 }
1407
1408 assert!(quill.metadata.contains_key("description"));
1410 assert!(quill.metadata.contains_key("author"));
1411 assert!(!quill.metadata.contains_key("version")); assert!(quill.plate.unwrap().contains("Custom Template"));
1415 }
1416
1417 #[test]
1418 fn test_typst_packages_parsing() {
1419 let temp_dir = TempDir::new().unwrap();
1420 let quill_dir = temp_dir.path();
1421
1422 let toml_content = r#"
1423[Quill]
1424name = "test-quill"
1425backend = "typst"
1426plate_file = "plate.typ"
1427description = "Test quill for packages"
1428
1429[typst]
1430packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1431"#;
1432
1433 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1434 fs::write(quill_dir.join("plate.typ"), "test").unwrap();
1435
1436 let quill = Quill::from_path(quill_dir).unwrap();
1437 let packages = quill.typst_packages();
1438
1439 assert_eq!(packages.len(), 2);
1440 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1441 assert_eq!(packages[1], "@preview/example:1.0.0");
1442 }
1443
1444 #[test]
1445 fn test_template_loading() {
1446 let temp_dir = TempDir::new().unwrap();
1447 let quill_dir = temp_dir.path();
1448
1449 let toml_content = r#"[Quill]
1451name = "test-with-template"
1452backend = "typst"
1453plate_file = "plate.typ"
1454example_file = "example.md"
1455description = "Test quill with template"
1456"#;
1457 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1458 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1459 fs::write(
1460 quill_dir.join("example.md"),
1461 "---\ntitle: Test\n---\n\nThis is a test template.",
1462 )
1463 .unwrap();
1464
1465 let quill = Quill::from_path(quill_dir).unwrap();
1467
1468 assert!(quill.example.is_some());
1470 let example = quill.example.unwrap();
1471 assert!(example.contains("title: Test"));
1472 assert!(example.contains("This is a test template"));
1473
1474 assert_eq!(quill.plate.unwrap(), "plate content");
1476 }
1477
1478 #[test]
1479 fn test_template_optional() {
1480 let temp_dir = TempDir::new().unwrap();
1481 let quill_dir = temp_dir.path();
1482
1483 let toml_content = r#"[Quill]
1485name = "test-without-template"
1486backend = "typst"
1487plate_file = "plate.typ"
1488description = "Test quill without template"
1489"#;
1490 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1491 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1492
1493 let quill = Quill::from_path(quill_dir).unwrap();
1495
1496 assert_eq!(quill.example, None);
1498
1499 assert_eq!(quill.plate.unwrap(), "plate content");
1501 }
1502
1503 #[test]
1504 fn test_from_tree() {
1505 let mut root_files = HashMap::new();
1507
1508 let quill_toml = r#"[Quill]
1510name = "test-from-tree"
1511backend = "typst"
1512plate_file = "plate.typ"
1513description = "A test quill from tree"
1514"#;
1515 root_files.insert(
1516 "Quill.toml".to_string(),
1517 FileTreeNode::File {
1518 contents: quill_toml.as_bytes().to_vec(),
1519 },
1520 );
1521
1522 let plate_content = "= Test Template\n\nThis is a test.";
1524 root_files.insert(
1525 "plate.typ".to_string(),
1526 FileTreeNode::File {
1527 contents: plate_content.as_bytes().to_vec(),
1528 },
1529 );
1530
1531 let root = FileTreeNode::Directory { files: root_files };
1532
1533 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1535
1536 assert_eq!(quill.name, "test-from-tree");
1538 assert_eq!(quill.plate.unwrap(), plate_content);
1539 assert!(quill.metadata.contains_key("backend"));
1540 assert!(quill.metadata.contains_key("description"));
1541 }
1542
1543 #[test]
1544 fn test_from_tree_with_template() {
1545 let mut root_files = HashMap::new();
1546
1547 let quill_toml = r#"[Quill]
1549name = "test-tree-template"
1550backend = "typst"
1551plate_file = "plate.typ"
1552example_file = "template.md"
1553description = "Test tree with template"
1554"#;
1555 root_files.insert(
1556 "Quill.toml".to_string(),
1557 FileTreeNode::File {
1558 contents: quill_toml.as_bytes().to_vec(),
1559 },
1560 );
1561
1562 root_files.insert(
1564 "plate.typ".to_string(),
1565 FileTreeNode::File {
1566 contents: b"plate content".to_vec(),
1567 },
1568 );
1569
1570 let template_content = "# {{ title }}\n\n{{ body }}";
1572 root_files.insert(
1573 "template.md".to_string(),
1574 FileTreeNode::File {
1575 contents: template_content.as_bytes().to_vec(),
1576 },
1577 );
1578
1579 let root = FileTreeNode::Directory { files: root_files };
1580
1581 let quill = Quill::from_tree(root, None).unwrap();
1583
1584 assert_eq!(quill.example, Some(template_content.to_string()));
1586 }
1587
1588 #[test]
1589 fn test_from_json() {
1590 let json_str = r#"{
1592 "metadata": {
1593 "name": "test-from-json"
1594 },
1595 "files": {
1596 "Quill.toml": {
1597 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill from JSON\"\n"
1598 },
1599 "plate.typ": {
1600 "contents": "= Test Plate\n\nThis is test content."
1601 }
1602 }
1603 }"#;
1604
1605 let quill = Quill::from_json(json_str).unwrap();
1607
1608 assert_eq!(quill.name, "test-from-json");
1610 assert!(quill.plate.unwrap().contains("Test Plate"));
1611 assert!(quill.metadata.contains_key("backend"));
1612 }
1613
1614 #[test]
1615 fn test_from_json_with_byte_array() {
1616 let json_str = r#"{
1618 "files": {
1619 "Quill.toml": {
1620 "contents": "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1621 },
1622 "plate.typ": {
1623 "contents": "test plate"
1624 }
1625 }
1626 }"#;
1627
1628 let quill = Quill::from_json(json_str).unwrap();
1630
1631 assert_eq!(quill.name, "test");
1633 assert_eq!(quill.plate.unwrap(), "test plate");
1634 }
1635
1636 #[test]
1637 fn test_from_json_missing_files() {
1638 let json_str = r#"{
1640 "metadata": {
1641 "name": "test"
1642 }
1643 }"#;
1644
1645 let result = Quill::from_json(json_str);
1646 assert!(result.is_err());
1647 assert!(result.unwrap_err().to_string().contains("files"));
1649 }
1650
1651 #[test]
1652 fn test_from_json_tree_structure() {
1653 let json_str = r#"{
1655 "files": {
1656 "Quill.toml": {
1657 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test tree JSON\"\n"
1658 },
1659 "plate.typ": {
1660 "contents": "= Test Plate\n\nTree structure content."
1661 }
1662 }
1663 }"#;
1664
1665 let quill = Quill::from_json(json_str).unwrap();
1666
1667 assert_eq!(quill.name, "test-tree-json");
1668 assert!(quill.plate.unwrap().contains("Tree structure content"));
1669 assert!(quill.metadata.contains_key("backend"));
1670 }
1671
1672 #[test]
1673 fn test_from_json_nested_tree_structure() {
1674 let json_str = r#"{
1676 "files": {
1677 "Quill.toml": {
1678 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Nested test\"\n"
1679 },
1680 "plate.typ": {
1681 "contents": "plate"
1682 },
1683 "src": {
1684 "main.rs": {
1685 "contents": "fn main() {}"
1686 },
1687 "lib.rs": {
1688 "contents": "// lib"
1689 }
1690 }
1691 }
1692 }"#;
1693
1694 let quill = Quill::from_json(json_str).unwrap();
1695
1696 assert_eq!(quill.name, "nested-test");
1697 assert!(quill.file_exists("src/main.rs"));
1699 assert!(quill.file_exists("src/lib.rs"));
1700
1701 let main_rs = quill.get_file("src/main.rs").unwrap();
1702 assert_eq!(main_rs, b"fn main() {}");
1703 }
1704
1705 #[test]
1706 fn test_from_tree_structure_direct() {
1707 let mut root_files = HashMap::new();
1709
1710 root_files.insert(
1711 "Quill.toml".to_string(),
1712 FileTreeNode::File {
1713 contents:
1714 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Direct tree test\"\n"
1715 .to_vec(),
1716 },
1717 );
1718
1719 root_files.insert(
1720 "plate.typ".to_string(),
1721 FileTreeNode::File {
1722 contents: b"plate content".to_vec(),
1723 },
1724 );
1725
1726 let mut src_files = HashMap::new();
1728 src_files.insert(
1729 "main.rs".to_string(),
1730 FileTreeNode::File {
1731 contents: b"fn main() {}".to_vec(),
1732 },
1733 );
1734
1735 root_files.insert(
1736 "src".to_string(),
1737 FileTreeNode::Directory { files: src_files },
1738 );
1739
1740 let root = FileTreeNode::Directory { files: root_files };
1741
1742 let quill = Quill::from_tree(root, None).unwrap();
1743
1744 assert_eq!(quill.name, "direct-tree");
1745 assert!(quill.file_exists("src/main.rs"));
1746 assert!(quill.file_exists("plate.typ"));
1747 }
1748
1749 #[test]
1750 fn test_from_json_with_metadata_override() {
1751 let json_str = r#"{
1753 "metadata": {
1754 "name": "override-name"
1755 },
1756 "files": {
1757 "Quill.toml": {
1758 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"TOML name test\"\n"
1759 },
1760 "plate.typ": {
1761 "contents": "= plate"
1762 }
1763 }
1764 }"#;
1765
1766 let quill = Quill::from_json(json_str).unwrap();
1767 assert_eq!(quill.name, "toml-name");
1770 }
1771
1772 #[test]
1773 fn test_from_json_empty_directory() {
1774 let json_str = r#"{
1776 "files": {
1777 "Quill.toml": {
1778 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Empty directory test\"\n"
1779 },
1780 "plate.typ": {
1781 "contents": "plate"
1782 },
1783 "empty_dir": {}
1784 }
1785 }"#;
1786
1787 let quill = Quill::from_json(json_str).unwrap();
1788 assert_eq!(quill.name, "empty-dir-test");
1789 assert!(quill.dir_exists("empty_dir"));
1790 assert!(!quill.file_exists("empty_dir"));
1791 }
1792
1793 #[test]
1794 fn test_dir_exists_and_list_apis() {
1795 let mut root_files = HashMap::new();
1796
1797 root_files.insert(
1799 "Quill.toml".to_string(),
1800 FileTreeNode::File {
1801 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1802 .to_vec(),
1803 },
1804 );
1805
1806 root_files.insert(
1808 "plate.typ".to_string(),
1809 FileTreeNode::File {
1810 contents: b"plate content".to_vec(),
1811 },
1812 );
1813
1814 let mut assets_files = HashMap::new();
1816 assets_files.insert(
1817 "logo.png".to_string(),
1818 FileTreeNode::File {
1819 contents: vec![137, 80, 78, 71],
1820 },
1821 );
1822 assets_files.insert(
1823 "icon.svg".to_string(),
1824 FileTreeNode::File {
1825 contents: b"<svg></svg>".to_vec(),
1826 },
1827 );
1828
1829 let mut fonts_files = HashMap::new();
1831 fonts_files.insert(
1832 "font.ttf".to_string(),
1833 FileTreeNode::File {
1834 contents: b"font data".to_vec(),
1835 },
1836 );
1837 assets_files.insert(
1838 "fonts".to_string(),
1839 FileTreeNode::Directory { files: fonts_files },
1840 );
1841
1842 root_files.insert(
1843 "assets".to_string(),
1844 FileTreeNode::Directory {
1845 files: assets_files,
1846 },
1847 );
1848
1849 root_files.insert(
1851 "empty".to_string(),
1852 FileTreeNode::Directory {
1853 files: HashMap::new(),
1854 },
1855 );
1856
1857 let root = FileTreeNode::Directory { files: root_files };
1858 let quill = Quill::from_tree(root, None).unwrap();
1859
1860 assert!(quill.dir_exists("assets"));
1862 assert!(quill.dir_exists("assets/fonts"));
1863 assert!(quill.dir_exists("empty"));
1864 assert!(!quill.dir_exists("nonexistent"));
1865 assert!(!quill.dir_exists("plate.typ")); assert!(quill.file_exists("plate.typ"));
1869 assert!(quill.file_exists("assets/logo.png"));
1870 assert!(quill.file_exists("assets/fonts/font.ttf"));
1871 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1875 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1877 assert!(root_files_list.contains(&"plate.typ".to_string()));
1878
1879 let assets_files_list = quill.list_files("assets");
1880 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1882 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1883
1884 let root_subdirs = quill.list_subdirectories("");
1886 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1888 assert!(root_subdirs.contains(&"empty".to_string()));
1889
1890 let assets_subdirs = quill.list_subdirectories("assets");
1891 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1893
1894 let empty_subdirs = quill.list_subdirectories("empty");
1895 assert_eq!(empty_subdirs.len(), 0);
1896 }
1897
1898 #[test]
1899 fn test_field_schemas_parsing() {
1900 let mut root_files = HashMap::new();
1901
1902 let quill_toml = r#"[Quill]
1904name = "taro"
1905backend = "typst"
1906plate_file = "plate.typ"
1907example_file = "taro.md"
1908description = "Test template for field schemas"
1909
1910[fields]
1911author = {description = "Author of document" }
1912ice_cream = {description = "favorite ice cream flavor"}
1913title = {description = "title of document" }
1914"#;
1915 root_files.insert(
1916 "Quill.toml".to_string(),
1917 FileTreeNode::File {
1918 contents: quill_toml.as_bytes().to_vec(),
1919 },
1920 );
1921
1922 let plate_content = "= Test Template\n\nThis is a test.";
1924 root_files.insert(
1925 "plate.typ".to_string(),
1926 FileTreeNode::File {
1927 contents: plate_content.as_bytes().to_vec(),
1928 },
1929 );
1930
1931 root_files.insert(
1933 "taro.md".to_string(),
1934 FileTreeNode::File {
1935 contents: b"# Template".to_vec(),
1936 },
1937 );
1938
1939 let root = FileTreeNode::Directory { files: root_files };
1940
1941 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1943
1944 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1946 assert!(quill.schema["properties"]
1947 .as_object()
1948 .unwrap()
1949 .contains_key("author"));
1950 assert!(quill.schema["properties"]
1951 .as_object()
1952 .unwrap()
1953 .contains_key("ice_cream"));
1954 assert!(quill.schema["properties"]
1955 .as_object()
1956 .unwrap()
1957 .contains_key("title"));
1958
1959 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1961 assert_eq!(author_schema["description"], "Author of document");
1962
1963 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1965 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1966
1967 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1969 assert_eq!(title_schema["description"], "title of document");
1970 }
1971
1972 #[test]
1973 fn test_field_schema_struct() {
1974 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1976 assert_eq!(schema1.description, "Test description");
1977 assert_eq!(schema1.r#type, None);
1978 assert_eq!(schema1.examples, None);
1979 assert_eq!(schema1.default, None);
1980
1981 let yaml_str = r#"
1983description: "Full field schema"
1984type: "string"
1985examples:
1986 - "Example value"
1987default: "Default value"
1988"#;
1989 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1990 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1991 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1992 assert_eq!(schema2.name, "test_name");
1993 assert_eq!(schema2.description, "Full field schema");
1994 assert_eq!(schema2.r#type, Some("string".to_string()));
1995 assert_eq!(
1996 schema2
1997 .examples
1998 .as_ref()
1999 .and_then(|v| v.as_array())
2000 .and_then(|arr| arr.first())
2001 .and_then(|v| v.as_str()),
2002 Some("Example value")
2003 );
2004 assert_eq!(
2005 schema2.default.as_ref().and_then(|v| v.as_str()),
2006 Some("Default value")
2007 );
2008 }
2009
2010 #[test]
2011 fn test_quill_without_plate_file() {
2012 let mut root_files = HashMap::new();
2014
2015 let quill_toml = r#"[Quill]
2017name = "test-no-plate"
2018backend = "typst"
2019description = "Test quill without plate file"
2020"#;
2021 root_files.insert(
2022 "Quill.toml".to_string(),
2023 FileTreeNode::File {
2024 contents: quill_toml.as_bytes().to_vec(),
2025 },
2026 );
2027
2028 let root = FileTreeNode::Directory { files: root_files };
2029
2030 let quill = Quill::from_tree(root, None).unwrap();
2032
2033 assert!(quill.plate.clone().is_none());
2035 assert_eq!(quill.name, "test-no-plate");
2036 }
2037
2038 #[test]
2039 fn test_quill_config_from_toml() {
2040 let toml_content = r#"[Quill]
2042name = "test-config"
2043backend = "typst"
2044description = "Test configuration parsing"
2045version = "1.0.0"
2046author = "Test Author"
2047plate_file = "plate.typ"
2048example_file = "example.md"
2049
2050[typst]
2051packages = ["@preview/bubble:0.2.2"]
2052
2053[fields]
2054title = {description = "Document title", type = "string"}
2055author = {description = "Document author"}
2056"#;
2057
2058 let config = QuillConfig::from_toml(toml_content).unwrap();
2059
2060 assert_eq!(config.name, "test-config");
2062 assert_eq!(config.backend, "typst");
2063 assert_eq!(config.description, "Test configuration parsing");
2064
2065 assert_eq!(config.version, Some("1.0.0".to_string()));
2067 assert_eq!(config.author, Some("Test Author".to_string()));
2068 assert_eq!(config.plate_file, Some("plate.typ".to_string()));
2069 assert_eq!(config.example_file, Some("example.md".to_string()));
2070
2071 assert!(config.typst_config.contains_key("packages"));
2073
2074 assert_eq!(config.fields.len(), 2);
2076 assert!(config.fields.contains_key("title"));
2077 assert!(config.fields.contains_key("author"));
2078
2079 let title_field = &config.fields["title"];
2080 assert_eq!(title_field.description, "Document title");
2081 assert_eq!(title_field.r#type, Some("string".to_string()));
2082 }
2083
2084 #[test]
2085 fn test_quill_config_missing_required_fields() {
2086 let toml_missing_name = r#"[Quill]
2088backend = "typst"
2089description = "Missing name"
2090"#;
2091 let result = QuillConfig::from_toml(toml_missing_name);
2092 assert!(result.is_err());
2093 assert!(result
2094 .unwrap_err()
2095 .to_string()
2096 .contains("Missing required 'name'"));
2097
2098 let toml_missing_backend = r#"[Quill]
2099name = "test"
2100description = "Missing backend"
2101"#;
2102 let result = QuillConfig::from_toml(toml_missing_backend);
2103 assert!(result.is_err());
2104 assert!(result
2105 .unwrap_err()
2106 .to_string()
2107 .contains("Missing required 'backend'"));
2108
2109 let toml_missing_description = r#"[Quill]
2110name = "test"
2111backend = "typst"
2112"#;
2113 let result = QuillConfig::from_toml(toml_missing_description);
2114 assert!(result.is_err());
2115 assert!(result
2116 .unwrap_err()
2117 .to_string()
2118 .contains("Missing required 'description'"));
2119 }
2120
2121 #[test]
2122 fn test_quill_config_empty_description() {
2123 let toml_empty_description = r#"[Quill]
2125name = "test"
2126backend = "typst"
2127description = " "
2128"#;
2129 let result = QuillConfig::from_toml(toml_empty_description);
2130 assert!(result.is_err());
2131 assert!(result
2132 .unwrap_err()
2133 .to_string()
2134 .contains("description' field in [Quill] section cannot be empty"));
2135 }
2136
2137 #[test]
2138 fn test_quill_config_missing_quill_section() {
2139 let toml_no_section = r#"[fields]
2141title = {description = "Title"}
2142"#;
2143 let result = QuillConfig::from_toml(toml_no_section);
2144 assert!(result.is_err());
2145 assert!(result
2146 .unwrap_err()
2147 .to_string()
2148 .contains("Missing required [Quill] section"));
2149 }
2150
2151 #[test]
2152 fn test_quill_from_config_metadata() {
2153 let mut root_files = HashMap::new();
2155
2156 let quill_toml = r#"[Quill]
2157name = "metadata-test"
2158backend = "typst"
2159description = "Test metadata flow"
2160author = "Test Author"
2161custom_field = "custom_value"
2162
2163[typst]
2164packages = ["@preview/bubble:0.2.2"]
2165"#;
2166 root_files.insert(
2167 "Quill.toml".to_string(),
2168 FileTreeNode::File {
2169 contents: quill_toml.as_bytes().to_vec(),
2170 },
2171 );
2172
2173 let root = FileTreeNode::Directory { files: root_files };
2174 let quill = Quill::from_tree(root, None).unwrap();
2175
2176 assert!(quill.metadata.contains_key("backend"));
2178 assert!(quill.metadata.contains_key("description"));
2179 assert!(quill.metadata.contains_key("author"));
2180
2181 assert!(quill.metadata.contains_key("custom_field"));
2183 assert_eq!(
2184 quill.metadata.get("custom_field").unwrap().as_str(),
2185 Some("custom_value")
2186 );
2187
2188 assert!(quill.metadata.contains_key("typst_packages"));
2190 }
2191
2192 #[test]
2193 fn test_extract_defaults_method() {
2194 let mut root_files = HashMap::new();
2196
2197 let quill_toml = r#"[Quill]
2198name = "defaults-test"
2199backend = "typst"
2200description = "Test defaults extraction"
2201
2202[fields]
2203title = {description = "Title"}
2204author = {description = "Author", default = "Anonymous"}
2205status = {description = "Status", default = "draft"}
2206"#;
2207
2208 root_files.insert(
2209 "Quill.toml".to_string(),
2210 FileTreeNode::File {
2211 contents: quill_toml.as_bytes().to_vec(),
2212 },
2213 );
2214
2215 let root = FileTreeNode::Directory { files: root_files };
2216 let quill = Quill::from_tree(root, None).unwrap();
2217
2218 let defaults = quill.extract_defaults();
2220
2221 assert_eq!(defaults.len(), 2);
2223 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2225 assert!(defaults.contains_key("status"));
2226
2227 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2229 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2230 }
2231
2232 #[test]
2233 fn test_field_order_preservation() {
2234 let toml_content = r#"[Quill]
2235name = "order-test"
2236backend = "typst"
2237description = "Test field order"
2238
2239[fields]
2240first = {description = "First field"}
2241second = {description = "Second field"}
2242third = {description = "Third field", ui = {group = "Test Group"}}
2243fourth = {description = "Fourth field"}
2244"#;
2245
2246 let config = QuillConfig::from_toml(toml_content).unwrap();
2247
2248 let first = config.fields.get("first").unwrap();
2252 assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2253
2254 let second = config.fields.get("second").unwrap();
2255 assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2256
2257 let third = config.fields.get("third").unwrap();
2258 assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2259 assert_eq!(
2260 third.ui.as_ref().unwrap().group,
2261 Some("Test Group".to_string())
2262 );
2263
2264 let fourth = config.fields.get("fourth").unwrap();
2265 assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2266 }
2267
2268 #[test]
2269 fn test_quill_with_all_ui_properties() {
2270 let toml_content = r#"[Quill]
2271name = "full-ui-test"
2272backend = "typst"
2273description = "Test all UI properties"
2274
2275[fields.author]
2276description = "The full name of the document author"
2277type = "str"
2278
2279[fields.author.ui]
2280group = "Author Info"
2281"#;
2282
2283 let config = QuillConfig::from_toml(toml_content).unwrap();
2284
2285 let author_field = &config.fields["author"];
2286 let ui = author_field.ui.as_ref().unwrap();
2287 assert_eq!(ui.group, Some("Author Info".to_string()));
2288 assert_eq!(ui.order, Some(0)); }
2290 #[test]
2291 fn test_field_schema_with_title_and_description() {
2292 let yaml = r#"
2294title: "Field Title"
2295description: "Detailed field description"
2296type: "string"
2297examples:
2298 - "Example value"
2299ui:
2300 group: "Test Group"
2301"#;
2302 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2303 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2304 let schema = FieldSchema::from_quill_value("test_field".to_string(), &quill_value).unwrap();
2305
2306 assert_eq!(schema.title, Some("Field Title".to_string()));
2307 assert_eq!(schema.description, "Detailed field description");
2308
2309 assert_eq!(
2310 schema
2311 .examples
2312 .as_ref()
2313 .and_then(|v| v.as_array())
2314 .and_then(|arr| arr.first())
2315 .and_then(|v| v.as_str()),
2316 Some("Example value")
2317 );
2318
2319 let ui = schema.ui.as_ref().unwrap();
2320 assert_eq!(ui.group, Some("Test Group".to_string()));
2321 }
2322
2323 #[test]
2324 fn test_parse_card_field_type() {
2325 let yaml = r#"
2327type: "card"
2328title: "Endorsements"
2329description: "Chain of endorsements for routing"
2330"#;
2331 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2332 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2333 let schema =
2334 FieldSchema::from_quill_value("endorsements".to_string(), &quill_value).unwrap();
2335
2336 assert_eq!(schema.name, "endorsements");
2337 assert_eq!(schema.r#type, Some("card".to_string()));
2338 assert_eq!(schema.title, Some("Endorsements".to_string()));
2339 assert_eq!(schema.description, "Chain of endorsements for routing");
2340 assert!(schema.items.is_none()); }
2342
2343 #[test]
2344 fn test_parse_card_items() {
2345 let yaml = r#"
2347type: "card"
2348title: "Endorsements"
2349description: "Chain of endorsements"
2350items:
2351 name:
2352 type: "string"
2353 title: "Endorser Name"
2354 description: "Name of the endorsing official"
2355 org:
2356 type: "string"
2357 title: "Organization"
2358 description: "Endorser's organization"
2359 default: "Unknown"
2360"#;
2361 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2362 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2363 let schema =
2364 FieldSchema::from_quill_value("endorsements".to_string(), &quill_value).unwrap();
2365
2366 assert_eq!(schema.r#type, Some("card".to_string()));
2367 assert!(schema.items.is_some());
2368
2369 let items = schema.items.as_ref().unwrap();
2370 assert_eq!(items.len(), 2);
2371
2372 let name_item = items.get("name").unwrap();
2374 assert_eq!(name_item.r#type, Some("string".to_string()));
2375 assert_eq!(name_item.title, Some("Endorser Name".to_string()));
2376 assert!(name_item.default.is_none());
2377
2378 let org_item = items.get("org").unwrap();
2380 assert_eq!(org_item.r#type, Some("string".to_string()));
2381 assert!(org_item.default.is_some());
2382 assert_eq!(org_item.default.as_ref().unwrap().as_str(), Some("Unknown"));
2383 }
2384
2385 #[test]
2386 fn test_card_items_error_without_card_type() {
2387 let yaml = r#"
2389type: "string"
2390description: "A string field"
2391items:
2392 sub_field:
2393 type: "string"
2394 description: "Nested field"
2395"#;
2396 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2397 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2398 let result = FieldSchema::from_quill_value("author".to_string(), &quill_value);
2399
2400 assert!(result.is_err());
2401 let err = result.unwrap_err();
2402 assert!(err.contains("'items' is only valid when type = \"scope\""));
2403 }
2404
2405 #[test]
2406 fn test_card_nested_card_error() {
2407 let yaml = r#"
2409type: "card"
2410description: "Outer scope"
2411items:
2412 inner:
2413 type: "card"
2414 description: "Illegal nested scope"
2415"#;
2416 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2417 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2418 let result = FieldSchema::from_quill_value("outer".to_string(), &quill_value);
2419
2420 assert!(result.is_err());
2421 let err = result.unwrap_err();
2422 assert!(err.contains("nested cards are not supported"));
2423 }
2424
2425 #[test]
2426 fn test_quill_config_with_cards_section() {
2427 let toml_content = r#"[Quill]
2428name = "cards-test"
2429backend = "typst"
2430description = "Test [cards] section"
2431
2432[fields.regular]
2433description = "Regular field"
2434type = "string"
2435
2436[cards.indorsements]
2437title = "Routing Indorsements"
2438description = "Chain of endorsements"
2439items = { name = { title = "Name", type = "string", description = "Name" } }
2440"#;
2441
2442 let config = QuillConfig::from_toml(toml_content).unwrap();
2443
2444 assert!(config.fields.contains_key("regular"));
2446 let regular = config.fields.get("regular").unwrap();
2447 assert_eq!(regular.r#type, Some("string".to_string()));
2448
2449 assert!(config.fields.contains_key("indorsements"));
2451 let scope = config.fields.get("indorsements").unwrap();
2452 assert_eq!(scope.r#type, Some("card".to_string()));
2453 assert_eq!(scope.title, Some("Routing Indorsements".to_string()));
2454 assert!(scope.items.is_some());
2455 assert!(scope.items.as_ref().unwrap().contains_key("name"));
2456 }
2457
2458 #[test]
2459 fn test_quill_config_cards_auto_type() {
2460 let toml_content = r#"[Quill]
2462name = "cards-auto-type-test"
2463backend = "typst"
2464description = "Test [cards] auto type"
2465
2466[cards.myscope]
2467description = "My scope"
2468items = {}
2469"#;
2470
2471 let config = QuillConfig::from_toml(toml_content).unwrap();
2472 let scope = config.fields.get("myscope").unwrap();
2473 assert_eq!(scope.r#type, Some("card".to_string()));
2474 }
2475
2476 #[test]
2477 fn test_quill_config_card_collision() {
2478 let toml_content = r#"[Quill]
2480name = "collision-test"
2481backend = "typst"
2482description = "Test collision"
2483
2484[fields.conflict]
2485description = "Field"
2486type = "string"
2487
2488[cards.conflict]
2489description = "Card"
2490items = {}
2491"#;
2492
2493 let result = QuillConfig::from_toml(toml_content);
2494 assert!(result.is_err());
2495 assert!(result
2496 .unwrap_err()
2497 .to_string()
2498 .contains("conflicts with an existing field name"));
2499 }
2500
2501 #[test]
2502 fn test_quill_config_card_wrong_type() {
2503 let toml_content = r#"[Quill]
2505name = "wrong-type-test"
2506backend = "typst"
2507description = "Test wrong type"
2508
2509[cards.bad]
2510type = "string"
2511description = "Should be scope"
2512"#;
2513
2514 let result = QuillConfig::from_toml(toml_content);
2515 assert!(result.is_err());
2516 assert!(result
2517 .unwrap_err()
2518 .to_string()
2519 .contains("must have type=\"scope\""));
2520 }
2521
2522 #[test]
2523 fn test_quill_config_ordering_with_cards() {
2524 let toml_content = r#"[Quill]
2526name = "ordering-test"
2527backend = "typst"
2528description = "Test ordering"
2529
2530[fields.first]
2531description = "First"
2532
2533[cards.second]
2534description = "Second"
2535items = {}
2536
2537[fields.zero]
2538description = "Zero"
2539"#;
2540
2541 let config = QuillConfig::from_toml(toml_content).unwrap();
2542
2543 let first = config.fields.get("first").unwrap();
2544 let zero = config.fields.get("zero").unwrap();
2545 let second = config.fields.get("second").unwrap();
2546
2547 let ord_first = first.ui.as_ref().unwrap().order.unwrap();
2549 let ord_zero = zero.ui.as_ref().unwrap().order.unwrap();
2550 let ord_second = second.ui.as_ref().unwrap().order.unwrap();
2551
2552 assert!(ord_first < ord_second);
2557 assert!(ord_zero < ord_second);
2558
2559 assert!(ord_first < ord_zero);
2561
2562 assert_eq!(ord_first, 0);
2563 assert_eq!(ord_zero, 1);
2564 assert_eq!(ord_second, 2);
2565 }
2566}