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 tooltip: Option<String>,
17 pub order: Option<i32>,
19 pub extra: HashMap<String, QuillValue>,
21}
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct FieldSchema {
26 pub name: String,
27 pub r#type: Option<String>,
29 pub description: String,
31 pub default: Option<QuillValue>,
33 pub example: Option<QuillValue>,
35 pub examples: Option<QuillValue>,
37 pub ui: Option<UiSchema>,
39}
40
41impl FieldSchema {
42 pub fn new(name: String, description: String) -> Self {
44 Self {
45 name,
46 r#type: None,
47 description,
48 default: None,
49 example: None,
50 examples: None,
51 ui: None,
52 }
53 }
54
55 pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
57 let obj = value
58 .as_object()
59 .ok_or_else(|| "Field schema must be an object".to_string())?;
60
61 for key in obj.keys() {
63 match key.as_str() {
64 "name" | "type" | "description" | "example" | "default" | "ui" => {}
65 _ => {
66 return Err(format!("Unknown key '{}' in field schema", key));
67 }
68 }
69 }
70
71 let name = key.clone();
72
73 let description = obj
74 .get("description")
75 .and_then(|v| v.as_str())
76 .unwrap_or("")
77 .to_string();
78
79 let field_type = obj
80 .get("type")
81 .and_then(|v| v.as_str())
82 .map(|s| s.to_string());
83
84 let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
85
86 let example = obj.get("example").map(|v| QuillValue::from_json(v.clone()));
87
88 let examples = obj
89 .get("examples")
90 .map(|v| QuillValue::from_json(v.clone()));
91
92 let ui = if let Some(ui_value) = obj.get("ui") {
94 if let Some(ui_obj) = ui_value.as_object() {
95 let group = ui_obj
96 .get("group")
97 .and_then(|v| v.as_str())
98 .map(|s| s.to_string());
99
100 let tooltip = ui_obj
101 .get("tooltip")
102 .and_then(|v| v.as_str())
103 .map(|s| s.to_string());
104
105 let mut extra = HashMap::new();
107 for (ui_key, ui_val) in ui_obj {
108 match ui_key.as_str() {
109 "group" | "tooltip" => {}
110 _ => {
111 extra.insert(ui_key.clone(), QuillValue::from_json(ui_val.clone()));
112 }
113 }
114 }
115
116 Some(UiSchema {
117 group,
118 tooltip,
119 order: None, extra,
121 })
122 } else {
123 return Err("UI field must be an object".to_string());
124 }
125 } else {
126 None
127 };
128
129 Ok(Self {
130 name,
131 r#type: field_type,
132 description,
133 default,
134 example,
135 examples,
136 ui,
137 })
138 }
139}
140
141#[derive(Debug, Clone)]
143pub enum FileTreeNode {
144 File {
146 contents: Vec<u8>,
148 },
149 Directory {
151 files: HashMap<String, FileTreeNode>,
153 },
154}
155
156impl FileTreeNode {
157 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
159 let path = path.as_ref();
160
161 if path == Path::new("") {
163 return Some(self);
164 }
165
166 let components: Vec<_> = path
168 .components()
169 .filter_map(|c| {
170 if let std::path::Component::Normal(s) = c {
171 s.to_str()
172 } else {
173 None
174 }
175 })
176 .collect();
177
178 if components.is_empty() {
179 return Some(self);
180 }
181
182 let mut current_node = self;
184 for component in components {
185 match current_node {
186 FileTreeNode::Directory { files } => {
187 current_node = files.get(component)?;
188 }
189 FileTreeNode::File { .. } => {
190 return None; }
192 }
193 }
194
195 Some(current_node)
196 }
197
198 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
200 match self.get_node(path)? {
201 FileTreeNode::File { contents } => Some(contents.as_slice()),
202 FileTreeNode::Directory { .. } => None,
203 }
204 }
205
206 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
208 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
209 }
210
211 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
213 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
214 }
215
216 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
218 match self.get_node(dir_path) {
219 Some(FileTreeNode::Directory { files }) => files
220 .iter()
221 .filter_map(|(name, node)| {
222 if matches!(node, FileTreeNode::File { .. }) {
223 Some(name.clone())
224 } else {
225 None
226 }
227 })
228 .collect(),
229 _ => Vec::new(),
230 }
231 }
232
233 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
235 match self.get_node(dir_path) {
236 Some(FileTreeNode::Directory { files }) => files
237 .iter()
238 .filter_map(|(name, node)| {
239 if matches!(node, FileTreeNode::Directory { .. }) {
240 Some(name.clone())
241 } else {
242 None
243 }
244 })
245 .collect(),
246 _ => Vec::new(),
247 }
248 }
249
250 pub fn insert<P: AsRef<Path>>(
252 &mut self,
253 path: P,
254 node: FileTreeNode,
255 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
256 let path = path.as_ref();
257
258 let components: Vec<_> = path
260 .components()
261 .filter_map(|c| {
262 if let std::path::Component::Normal(s) = c {
263 s.to_str().map(|s| s.to_string())
264 } else {
265 None
266 }
267 })
268 .collect();
269
270 if components.is_empty() {
271 return Err("Cannot insert at root path".into());
272 }
273
274 let mut current_node = self;
276 for component in &components[..components.len() - 1] {
277 match current_node {
278 FileTreeNode::Directory { files } => {
279 current_node =
280 files
281 .entry(component.clone())
282 .or_insert_with(|| FileTreeNode::Directory {
283 files: HashMap::new(),
284 });
285 }
286 FileTreeNode::File { .. } => {
287 return Err("Cannot traverse into a file".into());
288 }
289 }
290 }
291
292 let filename = &components[components.len() - 1];
294 match current_node {
295 FileTreeNode::Directory { files } => {
296 files.insert(filename.clone(), node);
297 Ok(())
298 }
299 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
300 }
301 }
302
303 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
305 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
306 Ok(FileTreeNode::File {
308 contents: contents_str.as_bytes().to_vec(),
309 })
310 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
311 let contents: Vec<u8> = bytes_array
313 .iter()
314 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
315 .collect();
316 Ok(FileTreeNode::File { contents })
317 } else if let Some(obj) = value.as_object() {
318 let mut files = HashMap::new();
320 for (name, child_value) in obj {
321 files.insert(name.clone(), Self::from_json_value(child_value)?);
322 }
323 Ok(FileTreeNode::Directory { files })
325 } else {
326 Err(format!("Invalid file tree node: {:?}", value).into())
327 }
328 }
329
330 pub fn print_tree(&self) -> String {
331 self.__print_tree("", "", true)
332 }
333
334 pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
335 let mut result = String::new();
336
337 let connector = if is_last { "└── " } else { "├── " };
339 let extension = if is_last { " " } else { "│ " };
340
341 match self {
342 FileTreeNode::File { .. } => {
343 result.push_str(&format!("{}{}{}\n", prefix, connector, name));
344 }
345 FileTreeNode::Directory { files } => {
346 result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
348
349 let child_prefix = format!("{}{}", prefix, extension);
350 let count = files.len();
351
352 for (i, (child_name, node)) in files.iter().enumerate() {
353 let is_last_child = i == count - 1;
354 result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
355 }
356 }
357 }
358
359 result
360 }
361}
362
363#[derive(Debug, Clone)]
365pub struct QuillIgnore {
366 patterns: Vec<String>,
367}
368
369impl QuillIgnore {
370 pub fn new(patterns: Vec<String>) -> Self {
372 Self { patterns }
373 }
374
375 pub fn from_content(content: &str) -> Self {
377 let patterns = content
378 .lines()
379 .map(|line| line.trim())
380 .filter(|line| !line.is_empty() && !line.starts_with('#'))
381 .map(|line| line.to_string())
382 .collect();
383 Self::new(patterns)
384 }
385
386 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
388 let path = path.as_ref();
389 let path_str = path.to_string_lossy();
390
391 for pattern in &self.patterns {
392 if self.matches_pattern(pattern, &path_str) {
393 return true;
394 }
395 }
396 false
397 }
398
399 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
401 if pattern.ends_with('/') {
403 let pattern_prefix = &pattern[..pattern.len() - 1];
404 return path.starts_with(pattern_prefix)
405 && (path.len() == pattern_prefix.len()
406 || path.chars().nth(pattern_prefix.len()) == Some('/'));
407 }
408
409 if !pattern.contains('*') {
411 return path == pattern || path.ends_with(&format!("/{}", pattern));
412 }
413
414 if pattern == "*" {
416 return true;
417 }
418
419 let pattern_parts: Vec<&str> = pattern.split('*').collect();
421 if pattern_parts.len() == 2 {
422 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
423 if prefix.is_empty() {
424 return path.ends_with(suffix);
425 } else if suffix.is_empty() {
426 return path.starts_with(prefix);
427 } else {
428 return path.starts_with(prefix) && path.ends_with(suffix);
429 }
430 }
431
432 false
433 }
434}
435
436#[derive(Debug, Clone)]
438pub struct Quill {
439 pub metadata: HashMap<String, QuillValue>,
441 pub name: String,
443 pub backend: String,
445 pub glue: Option<String>,
447 pub example: Option<String>,
449 pub schema: QuillValue,
451 pub defaults: HashMap<String, QuillValue>,
453 pub examples: HashMap<String, Vec<QuillValue>>,
455 pub files: FileTreeNode,
457}
458
459#[derive(Debug, Clone)]
461pub struct QuillConfig {
462 pub name: String,
464 pub description: String,
466 pub backend: String,
468 pub version: Option<String>,
470 pub author: Option<String>,
472 pub example_file: Option<String>,
474 pub glue_file: Option<String>,
476 pub json_schema_file: Option<String>,
478 pub fields: HashMap<String, FieldSchema>,
480 pub metadata: HashMap<String, QuillValue>,
482 pub typst_config: HashMap<String, QuillValue>,
484}
485
486impl QuillConfig {
487 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
489 let quill_toml: toml::Value = toml::from_str(toml_content)
490 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
491
492 let field_order: Vec<String> = toml_content
494 .parse::<toml_edit::DocumentMut>()
495 .ok()
496 .and_then(|doc| {
497 doc.get("fields")
498 .and_then(|item| item.as_table())
499 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
500 })
501 .unwrap_or_default();
502
503 let quill_section = quill_toml
505 .get("Quill")
506 .ok_or("Missing required [Quill] section in Quill.toml")?;
507
508 let name = quill_section
510 .get("name")
511 .and_then(|v| v.as_str())
512 .ok_or("Missing required 'name' field in [Quill] section")?
513 .to_string();
514
515 let backend = quill_section
516 .get("backend")
517 .and_then(|v| v.as_str())
518 .ok_or("Missing required 'backend' field in [Quill] section")?
519 .to_string();
520
521 let description = quill_section
522 .get("description")
523 .and_then(|v| v.as_str())
524 .ok_or("Missing required 'description' field in [Quill] section")?;
525
526 if description.trim().is_empty() {
527 return Err("'description' field in [Quill] section cannot be empty".into());
528 }
529 let description = description.to_string();
530
531 let version = quill_section
533 .get("version")
534 .and_then(|v| v.as_str())
535 .map(|s| s.to_string());
536
537 let author = quill_section
538 .get("author")
539 .and_then(|v| v.as_str())
540 .map(|s| s.to_string());
541
542 let example_file = quill_section
543 .get("example_file")
544 .and_then(|v| v.as_str())
545 .map(|s| s.to_string());
546
547 let glue_file = quill_section
548 .get("glue_file")
549 .and_then(|v| v.as_str())
550 .map(|s| s.to_string());
551
552 let json_schema_file = quill_section
553 .get("json_schema_file")
554 .and_then(|v| v.as_str())
555 .map(|s| s.to_string());
556
557 let mut metadata = HashMap::new();
559 if let toml::Value::Table(table) = quill_section {
560 for (key, value) in table {
561 if key != "name"
563 && key != "backend"
564 && key != "description"
565 && key != "version"
566 && key != "author"
567 && key != "example_file"
568 && key != "glue_file"
569 && key != "json_schema_file"
570 {
571 match QuillValue::from_toml(value) {
572 Ok(quill_value) => {
573 metadata.insert(key.clone(), quill_value);
574 }
575 Err(e) => {
576 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
577 }
578 }
579 }
580 }
581 }
582
583 let mut typst_config = HashMap::new();
585 if let Some(typst_section) = quill_toml.get("typst") {
586 if let toml::Value::Table(table) = typst_section {
587 for (key, value) in table {
588 match QuillValue::from_toml(value) {
589 Ok(quill_value) => {
590 typst_config.insert(key.clone(), quill_value);
591 }
592 Err(e) => {
593 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
594 }
595 }
596 }
597 }
598 }
599
600 let mut fields = HashMap::new();
602 if let Some(fields_section) = quill_toml.get("fields") {
603 if let toml::Value::Table(fields_table) = fields_section {
604 let mut order_counter = 0;
605 for (field_name, field_schema) in fields_table {
606 let order = if let Some(idx) = field_order.iter().position(|k| k == field_name)
608 {
609 idx as i32
610 } else {
611 let o = field_order.len() as i32 + order_counter;
612 order_counter += 1;
613 o
614 };
615
616 match QuillValue::from_toml(field_schema) {
617 Ok(quill_value) => {
618 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
619 Ok(mut schema) => {
620 if schema.ui.is_none() {
622 schema.ui = Some(UiSchema {
623 group: None,
624 tooltip: None,
625 order: Some(order),
626 extra: HashMap::new(),
627 });
628 } else if let Some(ui) = &mut schema.ui {
629 ui.order = Some(order);
630 }
631
632 fields.insert(field_name.clone(), schema);
633 }
634 Err(e) => {
635 eprintln!(
636 "Warning: Failed to parse field schema '{}': {}",
637 field_name, e
638 );
639 }
640 }
641 }
642 Err(e) => {
643 eprintln!(
644 "Warning: Failed to convert field schema '{}': {}",
645 field_name, e
646 );
647 }
648 }
649 }
650 }
651 }
652
653 Ok(QuillConfig {
654 name,
655 description,
656 backend,
657 version,
658 author,
659 example_file,
660 glue_file,
661 json_schema_file,
662 fields,
663 metadata,
664 typst_config,
665 })
666 }
667}
668
669impl Quill {
670 pub fn from_path<P: AsRef<std::path::Path>>(
672 path: P,
673 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
674 use std::fs;
675
676 let path = path.as_ref();
677 let name = path
678 .file_name()
679 .and_then(|n| n.to_str())
680 .unwrap_or("unnamed")
681 .to_string();
682
683 let quillignore_path = path.join(".quillignore");
685 let ignore = if quillignore_path.exists() {
686 let ignore_content = fs::read_to_string(&quillignore_path)
687 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
688 QuillIgnore::from_content(&ignore_content)
689 } else {
690 QuillIgnore::new(vec![
692 ".git/".to_string(),
693 ".gitignore".to_string(),
694 ".quillignore".to_string(),
695 "target/".to_string(),
696 "node_modules/".to_string(),
697 ])
698 };
699
700 let root = Self::load_directory_as_tree(path, path, &ignore)?;
702
703 Self::from_tree(root, Some(name))
705 }
706
707 pub fn from_tree(
724 root: FileTreeNode,
725 _default_name: Option<String>,
726 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
727 let quill_toml_bytes = root
729 .get_file("Quill.toml")
730 .ok_or("Quill.toml not found in file tree")?;
731
732 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
733 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
734
735 let config = QuillConfig::from_toml(&quill_toml_content)?;
737
738 Self::from_config(config, root)
740 }
741
742 fn from_config(
760 config: QuillConfig,
761 root: FileTreeNode,
762 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
763 let mut metadata = config.metadata.clone();
765
766 metadata.insert(
768 "backend".to_string(),
769 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
770 );
771
772 metadata.insert(
774 "description".to_string(),
775 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
776 );
777
778 if let Some(ref author) = config.author {
780 metadata.insert(
781 "author".to_string(),
782 QuillValue::from_json(serde_json::Value::String(author.clone())),
783 );
784 }
785
786 for (key, value) in &config.typst_config {
788 metadata.insert(format!("typst_{}", key), value.clone());
789 }
790
791 let schema = if let Some(ref json_schema_path) = config.json_schema_file {
793 let schema_bytes = root.get_file(json_schema_path).ok_or_else(|| {
795 format!(
796 "json_schema_file '{}' not found in file tree",
797 json_schema_path
798 )
799 })?;
800
801 let schema_json =
803 serde_json::from_slice::<serde_json::Value>(schema_bytes).map_err(|e| {
804 format!(
805 "json_schema_file '{}' is not valid JSON: {}",
806 json_schema_path, e
807 )
808 })?;
809
810 if !config.fields.is_empty() {
812 eprintln!("Warning: [fields] section is overridden by json_schema_file");
813 }
814
815 QuillValue::from_json(schema_json)
816 } else {
817 build_schema_from_fields(&config.fields)
819 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?
820 };
821
822 let glue_content: Option<String> = if let Some(ref glue_file_name) = config.glue_file {
824 let glue_bytes = root
825 .get_file(glue_file_name)
826 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
827
828 let content = String::from_utf8(glue_bytes.to_vec())
829 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?;
830 Some(content)
831 } else {
832 None
834 };
835
836 let example_content = if let Some(ref example_file_name) = config.example_file {
838 root.get_file(example_file_name).and_then(|bytes| {
839 String::from_utf8(bytes.to_vec())
840 .map_err(|e| {
841 eprintln!(
842 "Warning: Example file '{}' is not valid UTF-8: {}",
843 example_file_name, e
844 );
845 e
846 })
847 .ok()
848 })
849 } else {
850 None
851 };
852
853 let defaults = crate::schema::extract_defaults_from_schema(&schema);
855 let examples = crate::schema::extract_examples_from_schema(&schema);
856
857 let quill = Quill {
858 metadata,
859 name: config.name,
860 backend: config.backend,
861 glue: glue_content,
862 example: example_content,
863 schema,
864 defaults,
865 examples,
866 files: root,
867 };
868
869 Ok(quill)
870 }
871
872 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
879 use serde_json::Value as JsonValue;
880
881 let json: JsonValue =
882 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
883
884 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
885
886 let default_name = obj
888 .get("metadata")
889 .and_then(|m| m.get("name"))
890 .and_then(|v| v.as_str())
891 .map(String::from);
892
893 let files_obj = obj
895 .get("files")
896 .and_then(|v| v.as_object())
897 .ok_or_else(|| "Missing or invalid 'files' key")?;
898
899 let mut root_files = HashMap::new();
901 for (key, value) in files_obj {
902 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
903 }
904
905 let root = FileTreeNode::Directory { files: root_files };
906
907 Self::from_tree(root, default_name)
909 }
910
911 fn load_directory_as_tree(
913 current_dir: &Path,
914 base_dir: &Path,
915 ignore: &QuillIgnore,
916 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
917 use std::fs;
918
919 if !current_dir.exists() {
920 return Ok(FileTreeNode::Directory {
921 files: HashMap::new(),
922 });
923 }
924
925 let mut files = HashMap::new();
926
927 for entry in fs::read_dir(current_dir)? {
928 let entry = entry?;
929 let path = entry.path();
930 let relative_path = path
931 .strip_prefix(base_dir)
932 .map_err(|e| format!("Failed to get relative path: {}", e))?
933 .to_path_buf();
934
935 if ignore.is_ignored(&relative_path) {
937 continue;
938 }
939
940 let filename = path
942 .file_name()
943 .and_then(|n| n.to_str())
944 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
945 .to_string();
946
947 if path.is_file() {
948 let contents = fs::read(&path)
949 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
950
951 files.insert(filename, FileTreeNode::File { contents });
952 } else if path.is_dir() {
953 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
955 files.insert(filename, subdir_tree);
956 }
957 }
958
959 Ok(FileTreeNode::Directory { files })
960 }
961
962 pub fn typst_packages(&self) -> Vec<String> {
964 self.metadata
965 .get("typst_packages")
966 .and_then(|v| v.as_array())
967 .map(|arr| {
968 arr.iter()
969 .filter_map(|v| v.as_str().map(|s| s.to_string()))
970 .collect()
971 })
972 .unwrap_or_default()
973 }
974
975 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
983 &self.defaults
984 }
985
986 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
991 &self.examples
992 }
993
994 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
996 self.files.get_file(path)
997 }
998
999 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1001 self.files.file_exists(path)
1002 }
1003
1004 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1006 self.files.dir_exists(path)
1007 }
1008
1009 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1011 self.files.list_files(path)
1012 }
1013
1014 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1016 self.files.list_subdirectories(path)
1017 }
1018
1019 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1021 let dir_path = dir_path.as_ref();
1022 let filenames = self.files.list_files(dir_path);
1023
1024 filenames
1026 .iter()
1027 .map(|name| {
1028 if dir_path == Path::new("") {
1029 PathBuf::from(name)
1030 } else {
1031 dir_path.join(name)
1032 }
1033 })
1034 .collect()
1035 }
1036
1037 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1039 let dir_path = dir_path.as_ref();
1040 let subdirs = self.files.list_subdirectories(dir_path);
1041
1042 subdirs
1044 .iter()
1045 .map(|name| {
1046 if dir_path == Path::new("") {
1047 PathBuf::from(name)
1048 } else {
1049 dir_path.join(name)
1050 }
1051 })
1052 .collect()
1053 }
1054
1055 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1057 let pattern_str = pattern.as_ref().to_string_lossy();
1058 let mut matches = Vec::new();
1059
1060 let glob_pattern = match glob::Pattern::new(&pattern_str) {
1062 Ok(pat) => pat,
1063 Err(_) => return matches, };
1065
1066 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1068
1069 matches.sort();
1070 matches
1071 }
1072
1073 fn find_files_recursive(
1075 &self,
1076 node: &FileTreeNode,
1077 current_path: &Path,
1078 pattern: &glob::Pattern,
1079 matches: &mut Vec<PathBuf>,
1080 ) {
1081 match node {
1082 FileTreeNode::File { .. } => {
1083 let path_str = current_path.to_string_lossy();
1084 if pattern.matches(&path_str) {
1085 matches.push(current_path.to_path_buf());
1086 }
1087 }
1088 FileTreeNode::Directory { files } => {
1089 for (name, child_node) in files {
1090 let child_path = if current_path == Path::new("") {
1091 PathBuf::from(name)
1092 } else {
1093 current_path.join(name)
1094 };
1095 self.find_files_recursive(child_node, &child_path, pattern, matches);
1096 }
1097 }
1098 }
1099 }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105 use std::fs;
1106 use tempfile::TempDir;
1107
1108 #[test]
1109 fn test_quillignore_parsing() {
1110 let ignore_content = r#"
1111# This is a comment
1112*.tmp
1113target/
1114node_modules/
1115.git/
1116"#;
1117 let ignore = QuillIgnore::from_content(ignore_content);
1118 assert_eq!(ignore.patterns.len(), 4);
1119 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1120 assert!(ignore.patterns.contains(&"target/".to_string()));
1121 }
1122
1123 #[test]
1124 fn test_quillignore_matching() {
1125 let ignore = QuillIgnore::new(vec![
1126 "*.tmp".to_string(),
1127 "target/".to_string(),
1128 "node_modules/".to_string(),
1129 ".git/".to_string(),
1130 ]);
1131
1132 assert!(ignore.is_ignored("test.tmp"));
1134 assert!(ignore.is_ignored("path/to/file.tmp"));
1135 assert!(!ignore.is_ignored("test.txt"));
1136
1137 assert!(ignore.is_ignored("target"));
1139 assert!(ignore.is_ignored("target/debug"));
1140 assert!(ignore.is_ignored("target/debug/deps"));
1141 assert!(!ignore.is_ignored("src/target.rs"));
1142
1143 assert!(ignore.is_ignored("node_modules"));
1144 assert!(ignore.is_ignored("node_modules/package"));
1145 assert!(!ignore.is_ignored("my_node_modules"));
1146 }
1147
1148 #[test]
1149 fn test_in_memory_file_system() {
1150 let temp_dir = TempDir::new().unwrap();
1151 let quill_dir = temp_dir.path();
1152
1153 fs::write(
1155 quill_dir.join("Quill.toml"),
1156 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1157 )
1158 .unwrap();
1159 fs::write(quill_dir.join("glue.typ"), "test glue").unwrap();
1160
1161 let assets_dir = quill_dir.join("assets");
1162 fs::create_dir_all(&assets_dir).unwrap();
1163 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1164
1165 let packages_dir = quill_dir.join("packages");
1166 fs::create_dir_all(&packages_dir).unwrap();
1167 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1168
1169 let quill = Quill::from_path(quill_dir).unwrap();
1171
1172 assert!(quill.file_exists("glue.typ"));
1174 assert!(quill.file_exists("assets/test.txt"));
1175 assert!(quill.file_exists("packages/package.typ"));
1176 assert!(!quill.file_exists("nonexistent.txt"));
1177
1178 let asset_content = quill.get_file("assets/test.txt").unwrap();
1180 assert_eq!(asset_content, b"asset content");
1181
1182 let asset_files = quill.list_directory("assets");
1184 assert_eq!(asset_files.len(), 1);
1185 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1186 }
1187
1188 #[test]
1189 fn test_quillignore_integration() {
1190 let temp_dir = TempDir::new().unwrap();
1191 let quill_dir = temp_dir.path();
1192
1193 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1195
1196 fs::write(
1198 quill_dir.join("Quill.toml"),
1199 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1200 )
1201 .unwrap();
1202 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
1203 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1204
1205 let target_dir = quill_dir.join("target");
1206 fs::create_dir_all(&target_dir).unwrap();
1207 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1208
1209 let quill = Quill::from_path(quill_dir).unwrap();
1211
1212 assert!(quill.file_exists("glue.typ"));
1214 assert!(!quill.file_exists("should_ignore.tmp"));
1215 assert!(!quill.file_exists("target/debug.txt"));
1216 }
1217
1218 #[test]
1219 fn test_find_files_pattern() {
1220 let temp_dir = TempDir::new().unwrap();
1221 let quill_dir = temp_dir.path();
1222
1223 fs::write(
1225 quill_dir.join("Quill.toml"),
1226 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1227 )
1228 .unwrap();
1229 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
1230
1231 let assets_dir = quill_dir.join("assets");
1232 fs::create_dir_all(&assets_dir).unwrap();
1233 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1234 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1235
1236 let fonts_dir = assets_dir.join("fonts");
1237 fs::create_dir_all(&fonts_dir).unwrap();
1238 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1239
1240 let quill = Quill::from_path(quill_dir).unwrap();
1242
1243 let all_assets = quill.find_files("assets/*");
1245 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1248 assert_eq!(typ_files.len(), 1);
1249 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1250 }
1251
1252 #[test]
1253 fn test_new_standardized_toml_format() {
1254 let temp_dir = TempDir::new().unwrap();
1255 let quill_dir = temp_dir.path();
1256
1257 let toml_content = r#"[Quill]
1259name = "my-custom-quill"
1260backend = "typst"
1261glue_file = "custom_glue.typ"
1262description = "Test quill with new format"
1263author = "Test Author"
1264"#;
1265 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1266 fs::write(
1267 quill_dir.join("custom_glue.typ"),
1268 "= Custom Template\n\nThis is a custom template.",
1269 )
1270 .unwrap();
1271
1272 let quill = Quill::from_path(quill_dir).unwrap();
1274
1275 assert_eq!(quill.name, "my-custom-quill");
1277
1278 assert!(quill.metadata.contains_key("backend"));
1280 if let Some(backend_val) = quill.metadata.get("backend") {
1281 if let Some(backend_str) = backend_val.as_str() {
1282 assert_eq!(backend_str, "typst");
1283 } else {
1284 panic!("Backend value is not a string");
1285 }
1286 }
1287
1288 assert!(quill.metadata.contains_key("description"));
1290 assert!(quill.metadata.contains_key("author"));
1291 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue.unwrap().contains("Custom Template"));
1295 }
1296
1297 #[test]
1298 fn test_typst_packages_parsing() {
1299 let temp_dir = TempDir::new().unwrap();
1300 let quill_dir = temp_dir.path();
1301
1302 let toml_content = r#"
1303[Quill]
1304name = "test-quill"
1305backend = "typst"
1306glue_file = "glue.typ"
1307description = "Test quill for packages"
1308
1309[typst]
1310packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1311"#;
1312
1313 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1314 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1315
1316 let quill = Quill::from_path(quill_dir).unwrap();
1317 let packages = quill.typst_packages();
1318
1319 assert_eq!(packages.len(), 2);
1320 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1321 assert_eq!(packages[1], "@preview/example:1.0.0");
1322 }
1323
1324 #[test]
1325 fn test_template_loading() {
1326 let temp_dir = TempDir::new().unwrap();
1327 let quill_dir = temp_dir.path();
1328
1329 let toml_content = r#"[Quill]
1331name = "test-with-template"
1332backend = "typst"
1333glue_file = "glue.typ"
1334example_file = "example.md"
1335description = "Test quill with template"
1336"#;
1337 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1338 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1339 fs::write(
1340 quill_dir.join("example.md"),
1341 "---\ntitle: Test\n---\n\nThis is a test template.",
1342 )
1343 .unwrap();
1344
1345 let quill = Quill::from_path(quill_dir).unwrap();
1347
1348 assert!(quill.example.is_some());
1350 let example = quill.example.unwrap();
1351 assert!(example.contains("title: Test"));
1352 assert!(example.contains("This is a test template"));
1353
1354 assert_eq!(quill.glue.unwrap(), "glue content");
1356 }
1357
1358 #[test]
1359 fn test_template_optional() {
1360 let temp_dir = TempDir::new().unwrap();
1361 let quill_dir = temp_dir.path();
1362
1363 let toml_content = r#"[Quill]
1365name = "test-without-template"
1366backend = "typst"
1367glue_file = "glue.typ"
1368description = "Test quill without template"
1369"#;
1370 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1371 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1372
1373 let quill = Quill::from_path(quill_dir).unwrap();
1375
1376 assert_eq!(quill.example, None);
1378
1379 assert_eq!(quill.glue.unwrap(), "glue content");
1381 }
1382
1383 #[test]
1384 fn test_from_tree() {
1385 let mut root_files = HashMap::new();
1387
1388 let quill_toml = r#"[Quill]
1390name = "test-from-tree"
1391backend = "typst"
1392glue_file = "glue.typ"
1393description = "A test quill from tree"
1394"#;
1395 root_files.insert(
1396 "Quill.toml".to_string(),
1397 FileTreeNode::File {
1398 contents: quill_toml.as_bytes().to_vec(),
1399 },
1400 );
1401
1402 let glue_content = "= Test Template\n\nThis is a test.";
1404 root_files.insert(
1405 "glue.typ".to_string(),
1406 FileTreeNode::File {
1407 contents: glue_content.as_bytes().to_vec(),
1408 },
1409 );
1410
1411 let root = FileTreeNode::Directory { files: root_files };
1412
1413 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1415
1416 assert_eq!(quill.name, "test-from-tree");
1418 assert_eq!(quill.glue.unwrap(), glue_content);
1419 assert!(quill.metadata.contains_key("backend"));
1420 assert!(quill.metadata.contains_key("description"));
1421 }
1422
1423 #[test]
1424 fn test_from_tree_with_template() {
1425 let mut root_files = HashMap::new();
1426
1427 let quill_toml = r#"[Quill]
1429name = "test-tree-template"
1430backend = "typst"
1431glue_file = "glue.typ"
1432example_file = "template.md"
1433description = "Test tree with template"
1434"#;
1435 root_files.insert(
1436 "Quill.toml".to_string(),
1437 FileTreeNode::File {
1438 contents: quill_toml.as_bytes().to_vec(),
1439 },
1440 );
1441
1442 root_files.insert(
1444 "glue.typ".to_string(),
1445 FileTreeNode::File {
1446 contents: b"glue content".to_vec(),
1447 },
1448 );
1449
1450 let template_content = "# {{ title }}\n\n{{ body }}";
1452 root_files.insert(
1453 "template.md".to_string(),
1454 FileTreeNode::File {
1455 contents: template_content.as_bytes().to_vec(),
1456 },
1457 );
1458
1459 let root = FileTreeNode::Directory { files: root_files };
1460
1461 let quill = Quill::from_tree(root, None).unwrap();
1463
1464 assert_eq!(quill.example, Some(template_content.to_string()));
1466 }
1467
1468 #[test]
1469 fn test_from_json() {
1470 let json_str = r#"{
1472 "metadata": {
1473 "name": "test-from-json"
1474 },
1475 "files": {
1476 "Quill.toml": {
1477 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1478 },
1479 "glue.typ": {
1480 "contents": "= Test Glue\n\nThis is test content."
1481 }
1482 }
1483 }"#;
1484
1485 let quill = Quill::from_json(json_str).unwrap();
1487
1488 assert_eq!(quill.name, "test-from-json");
1490 assert!(quill.glue.unwrap().contains("Test Glue"));
1491 assert!(quill.metadata.contains_key("backend"));
1492 }
1493
1494 #[test]
1495 fn test_from_json_with_byte_array() {
1496 let json_str = r#"{
1498 "files": {
1499 "Quill.toml": {
1500 "contents": [91, 81, 117, 105, 108, 108, 93, 10, 110, 97, 109, 101, 32, 61, 32, 34, 116, 101, 115, 116, 34, 10, 98, 97, 99, 107, 101, 110, 100, 32, 61, 32, 34, 116, 121, 112, 115, 116, 34, 10, 103, 108, 117, 101, 95, 102, 105, 108, 101, 32, 61, 32, 34, 103, 108, 117, 101, 46, 116, 121, 112, 34, 10, 100, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 32, 61, 32, 34, 84, 101, 115, 116, 32, 113, 117, 105, 108, 108, 34, 10]
1501 },
1502 "glue.typ": {
1503 "contents": "test glue"
1504 }
1505 }
1506 }"#;
1507
1508 let quill = Quill::from_json(json_str).unwrap();
1510
1511 assert_eq!(quill.name, "test");
1513 assert_eq!(quill.glue.unwrap(), "test glue");
1514 }
1515
1516 #[test]
1517 fn test_from_json_missing_files() {
1518 let json_str = r#"{
1520 "metadata": {
1521 "name": "test"
1522 }
1523 }"#;
1524
1525 let result = Quill::from_json(json_str);
1526 assert!(result.is_err());
1527 assert!(result.unwrap_err().to_string().contains("files"));
1529 }
1530
1531 #[test]
1532 fn test_from_json_tree_structure() {
1533 let json_str = r#"{
1535 "files": {
1536 "Quill.toml": {
1537 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1538 },
1539 "glue.typ": {
1540 "contents": "= Test Glue\n\nTree structure content."
1541 }
1542 }
1543 }"#;
1544
1545 let quill = Quill::from_json(json_str).unwrap();
1546
1547 assert_eq!(quill.name, "test-tree-json");
1548 assert!(quill.glue.unwrap().contains("Tree structure content"));
1549 assert!(quill.metadata.contains_key("backend"));
1550 }
1551
1552 #[test]
1553 fn test_from_json_nested_tree_structure() {
1554 let json_str = r#"{
1556 "files": {
1557 "Quill.toml": {
1558 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Nested test\"\n"
1559 },
1560 "glue.typ": {
1561 "contents": "glue"
1562 },
1563 "src": {
1564 "main.rs": {
1565 "contents": "fn main() {}"
1566 },
1567 "lib.rs": {
1568 "contents": "// lib"
1569 }
1570 }
1571 }
1572 }"#;
1573
1574 let quill = Quill::from_json(json_str).unwrap();
1575
1576 assert_eq!(quill.name, "nested-test");
1577 assert!(quill.file_exists("src/main.rs"));
1579 assert!(quill.file_exists("src/lib.rs"));
1580
1581 let main_rs = quill.get_file("src/main.rs").unwrap();
1582 assert_eq!(main_rs, b"fn main() {}");
1583 }
1584
1585 #[test]
1586 fn test_from_tree_structure_direct() {
1587 let mut root_files = HashMap::new();
1589
1590 root_files.insert(
1591 "Quill.toml".to_string(),
1592 FileTreeNode::File {
1593 contents:
1594 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1595 .to_vec(),
1596 },
1597 );
1598
1599 root_files.insert(
1600 "glue.typ".to_string(),
1601 FileTreeNode::File {
1602 contents: b"glue content".to_vec(),
1603 },
1604 );
1605
1606 let mut src_files = HashMap::new();
1608 src_files.insert(
1609 "main.rs".to_string(),
1610 FileTreeNode::File {
1611 contents: b"fn main() {}".to_vec(),
1612 },
1613 );
1614
1615 root_files.insert(
1616 "src".to_string(),
1617 FileTreeNode::Directory { files: src_files },
1618 );
1619
1620 let root = FileTreeNode::Directory { files: root_files };
1621
1622 let quill = Quill::from_tree(root, None).unwrap();
1623
1624 assert_eq!(quill.name, "direct-tree");
1625 assert!(quill.file_exists("src/main.rs"));
1626 assert!(quill.file_exists("glue.typ"));
1627 }
1628
1629 #[test]
1630 fn test_from_json_with_metadata_override() {
1631 let json_str = r#"{
1633 "metadata": {
1634 "name": "override-name"
1635 },
1636 "files": {
1637 "Quill.toml": {
1638 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1639 },
1640 "glue.typ": {
1641 "contents": "= glue"
1642 }
1643 }
1644 }"#;
1645
1646 let quill = Quill::from_json(json_str).unwrap();
1647 assert_eq!(quill.name, "toml-name");
1650 }
1651
1652 #[test]
1653 fn test_from_json_empty_directory() {
1654 let json_str = r#"{
1656 "files": {
1657 "Quill.toml": {
1658 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1659 },
1660 "glue.typ": {
1661 "contents": "glue"
1662 },
1663 "empty_dir": {}
1664 }
1665 }"#;
1666
1667 let quill = Quill::from_json(json_str).unwrap();
1668 assert_eq!(quill.name, "empty-dir-test");
1669 assert!(quill.dir_exists("empty_dir"));
1670 assert!(!quill.file_exists("empty_dir"));
1671 }
1672
1673 #[test]
1674 fn test_dir_exists_and_list_apis() {
1675 let mut root_files = HashMap::new();
1676
1677 root_files.insert(
1679 "Quill.toml".to_string(),
1680 FileTreeNode::File {
1681 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"\n"
1682 .to_vec(),
1683 },
1684 );
1685
1686 root_files.insert(
1688 "glue.typ".to_string(),
1689 FileTreeNode::File {
1690 contents: b"glue content".to_vec(),
1691 },
1692 );
1693
1694 let mut assets_files = HashMap::new();
1696 assets_files.insert(
1697 "logo.png".to_string(),
1698 FileTreeNode::File {
1699 contents: vec![137, 80, 78, 71],
1700 },
1701 );
1702 assets_files.insert(
1703 "icon.svg".to_string(),
1704 FileTreeNode::File {
1705 contents: b"<svg></svg>".to_vec(),
1706 },
1707 );
1708
1709 let mut fonts_files = HashMap::new();
1711 fonts_files.insert(
1712 "font.ttf".to_string(),
1713 FileTreeNode::File {
1714 contents: b"font data".to_vec(),
1715 },
1716 );
1717 assets_files.insert(
1718 "fonts".to_string(),
1719 FileTreeNode::Directory { files: fonts_files },
1720 );
1721
1722 root_files.insert(
1723 "assets".to_string(),
1724 FileTreeNode::Directory {
1725 files: assets_files,
1726 },
1727 );
1728
1729 root_files.insert(
1731 "empty".to_string(),
1732 FileTreeNode::Directory {
1733 files: HashMap::new(),
1734 },
1735 );
1736
1737 let root = FileTreeNode::Directory { files: root_files };
1738 let quill = Quill::from_tree(root, None).unwrap();
1739
1740 assert!(quill.dir_exists("assets"));
1742 assert!(quill.dir_exists("assets/fonts"));
1743 assert!(quill.dir_exists("empty"));
1744 assert!(!quill.dir_exists("nonexistent"));
1745 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1749 assert!(quill.file_exists("assets/logo.png"));
1750 assert!(quill.file_exists("assets/fonts/font.ttf"));
1751 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1755 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1757 assert!(root_files_list.contains(&"glue.typ".to_string()));
1758
1759 let assets_files_list = quill.list_files("assets");
1760 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1762 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1763
1764 let root_subdirs = quill.list_subdirectories("");
1766 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1768 assert!(root_subdirs.contains(&"empty".to_string()));
1769
1770 let assets_subdirs = quill.list_subdirectories("assets");
1771 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1773
1774 let empty_subdirs = quill.list_subdirectories("empty");
1775 assert_eq!(empty_subdirs.len(), 0);
1776 }
1777
1778 #[test]
1779 fn test_field_schemas_parsing() {
1780 let mut root_files = HashMap::new();
1781
1782 let quill_toml = r#"[Quill]
1784name = "taro"
1785backend = "typst"
1786glue_file = "glue.typ"
1787example_file = "taro.md"
1788description = "Test template for field schemas"
1789
1790[fields]
1791author = {description = "Author of document" }
1792ice_cream = {description = "favorite ice cream flavor"}
1793title = {description = "title of document" }
1794"#;
1795 root_files.insert(
1796 "Quill.toml".to_string(),
1797 FileTreeNode::File {
1798 contents: quill_toml.as_bytes().to_vec(),
1799 },
1800 );
1801
1802 let glue_content = "= Test Template\n\nThis is a test.";
1804 root_files.insert(
1805 "glue.typ".to_string(),
1806 FileTreeNode::File {
1807 contents: glue_content.as_bytes().to_vec(),
1808 },
1809 );
1810
1811 root_files.insert(
1813 "taro.md".to_string(),
1814 FileTreeNode::File {
1815 contents: b"# Template".to_vec(),
1816 },
1817 );
1818
1819 let root = FileTreeNode::Directory { files: root_files };
1820
1821 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1823
1824 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1826 assert!(quill.schema["properties"]
1827 .as_object()
1828 .unwrap()
1829 .contains_key("author"));
1830 assert!(quill.schema["properties"]
1831 .as_object()
1832 .unwrap()
1833 .contains_key("ice_cream"));
1834 assert!(quill.schema["properties"]
1835 .as_object()
1836 .unwrap()
1837 .contains_key("title"));
1838
1839 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1841 assert_eq!(author_schema["description"], "Author of document");
1842
1843 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1845 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1846
1847 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1849 assert_eq!(title_schema["description"], "title of document");
1850 }
1851
1852 #[test]
1853 fn test_field_schema_struct() {
1854 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1856 assert_eq!(schema1.description, "Test description");
1857 assert_eq!(schema1.r#type, None);
1858 assert_eq!(schema1.example, None);
1859 assert_eq!(schema1.default, None);
1860
1861 let yaml_str = r#"
1863description: "Full field schema"
1864type: "string"
1865example: "Example value"
1866default: "Default value"
1867"#;
1868 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1869 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1870 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1871 assert_eq!(schema2.name, "test_name");
1872 assert_eq!(schema2.description, "Full field schema");
1873 assert_eq!(schema2.r#type, Some("string".to_string()));
1874 assert_eq!(
1875 schema2.example.as_ref().and_then(|v| v.as_str()),
1876 Some("Example value")
1877 );
1878 assert_eq!(
1879 schema2.default.as_ref().and_then(|v| v.as_str()),
1880 Some("Default value")
1881 );
1882 }
1883
1884 #[test]
1885 fn test_quill_without_glue_file() {
1886 let mut root_files = HashMap::new();
1888
1889 let quill_toml = r#"[Quill]
1891name = "test-no-glue"
1892backend = "typst"
1893description = "Test quill without glue file"
1894"#;
1895 root_files.insert(
1896 "Quill.toml".to_string(),
1897 FileTreeNode::File {
1898 contents: quill_toml.as_bytes().to_vec(),
1899 },
1900 );
1901
1902 let root = FileTreeNode::Directory { files: root_files };
1903
1904 let quill = Quill::from_tree(root, None).unwrap();
1906
1907 assert!(quill.glue.clone().is_none());
1909 assert_eq!(quill.name, "test-no-glue");
1910 }
1911
1912 #[test]
1913 fn test_quill_config_from_toml() {
1914 let toml_content = r#"[Quill]
1916name = "test-config"
1917backend = "typst"
1918description = "Test configuration parsing"
1919version = "1.0.0"
1920author = "Test Author"
1921glue_file = "glue.typ"
1922example_file = "example.md"
1923
1924[typst]
1925packages = ["@preview/bubble:0.2.2"]
1926
1927[fields]
1928title = {description = "Document title", type = "string"}
1929author = {description = "Document author"}
1930"#;
1931
1932 let config = QuillConfig::from_toml(toml_content).unwrap();
1933
1934 assert_eq!(config.name, "test-config");
1936 assert_eq!(config.backend, "typst");
1937 assert_eq!(config.description, "Test configuration parsing");
1938
1939 assert_eq!(config.version, Some("1.0.0".to_string()));
1941 assert_eq!(config.author, Some("Test Author".to_string()));
1942 assert_eq!(config.glue_file, Some("glue.typ".to_string()));
1943 assert_eq!(config.example_file, Some("example.md".to_string()));
1944
1945 assert!(config.typst_config.contains_key("packages"));
1947
1948 assert_eq!(config.fields.len(), 2);
1950 assert!(config.fields.contains_key("title"));
1951 assert!(config.fields.contains_key("author"));
1952
1953 let title_field = &config.fields["title"];
1954 assert_eq!(title_field.description, "Document title");
1955 assert_eq!(title_field.r#type, Some("string".to_string()));
1956 }
1957
1958 #[test]
1959 fn test_quill_config_missing_required_fields() {
1960 let toml_missing_name = r#"[Quill]
1962backend = "typst"
1963description = "Missing name"
1964"#;
1965 let result = QuillConfig::from_toml(toml_missing_name);
1966 assert!(result.is_err());
1967 assert!(result
1968 .unwrap_err()
1969 .to_string()
1970 .contains("Missing required 'name'"));
1971
1972 let toml_missing_backend = r#"[Quill]
1973name = "test"
1974description = "Missing backend"
1975"#;
1976 let result = QuillConfig::from_toml(toml_missing_backend);
1977 assert!(result.is_err());
1978 assert!(result
1979 .unwrap_err()
1980 .to_string()
1981 .contains("Missing required 'backend'"));
1982
1983 let toml_missing_description = r#"[Quill]
1984name = "test"
1985backend = "typst"
1986"#;
1987 let result = QuillConfig::from_toml(toml_missing_description);
1988 assert!(result.is_err());
1989 assert!(result
1990 .unwrap_err()
1991 .to_string()
1992 .contains("Missing required 'description'"));
1993 }
1994
1995 #[test]
1996 fn test_quill_config_empty_description() {
1997 let toml_empty_description = r#"[Quill]
1999name = "test"
2000backend = "typst"
2001description = " "
2002"#;
2003 let result = QuillConfig::from_toml(toml_empty_description);
2004 assert!(result.is_err());
2005 assert!(result
2006 .unwrap_err()
2007 .to_string()
2008 .contains("description' field in [Quill] section cannot be empty"));
2009 }
2010
2011 #[test]
2012 fn test_quill_config_missing_quill_section() {
2013 let toml_no_section = r#"[fields]
2015title = {description = "Title"}
2016"#;
2017 let result = QuillConfig::from_toml(toml_no_section);
2018 assert!(result.is_err());
2019 assert!(result
2020 .unwrap_err()
2021 .to_string()
2022 .contains("Missing required [Quill] section"));
2023 }
2024
2025 #[test]
2026 fn test_quill_from_config_metadata() {
2027 let mut root_files = HashMap::new();
2029
2030 let quill_toml = r#"[Quill]
2031name = "metadata-test"
2032backend = "typst"
2033description = "Test metadata flow"
2034author = "Test Author"
2035custom_field = "custom_value"
2036
2037[typst]
2038packages = ["@preview/bubble:0.2.2"]
2039"#;
2040 root_files.insert(
2041 "Quill.toml".to_string(),
2042 FileTreeNode::File {
2043 contents: quill_toml.as_bytes().to_vec(),
2044 },
2045 );
2046
2047 let root = FileTreeNode::Directory { files: root_files };
2048 let quill = Quill::from_tree(root, None).unwrap();
2049
2050 assert!(quill.metadata.contains_key("backend"));
2052 assert!(quill.metadata.contains_key("description"));
2053 assert!(quill.metadata.contains_key("author"));
2054
2055 assert!(quill.metadata.contains_key("custom_field"));
2057 assert_eq!(
2058 quill.metadata.get("custom_field").unwrap().as_str(),
2059 Some("custom_value")
2060 );
2061
2062 assert!(quill.metadata.contains_key("typst_packages"));
2064 }
2065
2066 #[test]
2067 fn test_json_schema_file_override() {
2068 let mut root_files = HashMap::new();
2070
2071 let custom_schema = r#"{
2073 "$schema": "https://json-schema.org/draft/2019-09/schema",
2074 "type": "object",
2075 "properties": {
2076 "title": {
2077 "type": "string",
2078 "description": "Document title"
2079 },
2080 "author": {
2081 "type": "string",
2082 "description": "Document author",
2083 "default": "Schema Author"
2084 },
2085 "version": {
2086 "type": "number",
2087 "description": "Version number",
2088 "default": 2
2089 }
2090 },
2091 "required": ["title"]
2092 }"#;
2093
2094 root_files.insert(
2095 "schema.json".to_string(),
2096 FileTreeNode::File {
2097 contents: custom_schema.as_bytes().to_vec(),
2098 },
2099 );
2100
2101 let quill_toml = r#"[Quill]
2102name = "schema-file-test"
2103backend = "typst"
2104description = "Test json_schema_file"
2105json_schema_file = "schema.json"
2106
2107[fields]
2108author = {description = "This should be ignored", default = "Fields Author"}
2109status = {description = "This should also be ignored"}
2110"#;
2111
2112 root_files.insert(
2113 "Quill.toml".to_string(),
2114 FileTreeNode::File {
2115 contents: quill_toml.as_bytes().to_vec(),
2116 },
2117 );
2118
2119 let root = FileTreeNode::Directory { files: root_files };
2120 let quill = Quill::from_tree(root, None).unwrap();
2121
2122 let properties = quill.schema["properties"].as_object().unwrap();
2124 assert_eq!(properties.len(), 3); assert!(properties.contains_key("title"));
2126 assert!(properties.contains_key("author"));
2127 assert!(properties.contains_key("version"));
2128 assert!(!properties.contains_key("status")); let defaults = quill.extract_defaults();
2132 assert_eq!(defaults.len(), 2); assert_eq!(
2134 defaults.get("author").unwrap().as_str(),
2135 Some("Schema Author")
2136 );
2137 assert_eq!(defaults.get("version").unwrap().as_json().as_i64(), Some(2));
2138
2139 let required = quill.schema["required"].as_array().unwrap();
2141 assert_eq!(required.len(), 1);
2142 assert!(required.contains(&serde_json::json!("title")));
2143 }
2144
2145 #[test]
2146 fn test_extract_defaults_method() {
2147 let mut root_files = HashMap::new();
2149
2150 let quill_toml = r#"[Quill]
2151name = "defaults-test"
2152backend = "typst"
2153description = "Test defaults extraction"
2154
2155[fields]
2156title = {description = "Title"}
2157author = {description = "Author", default = "Anonymous"}
2158status = {description = "Status", default = "draft"}
2159"#;
2160
2161 root_files.insert(
2162 "Quill.toml".to_string(),
2163 FileTreeNode::File {
2164 contents: quill_toml.as_bytes().to_vec(),
2165 },
2166 );
2167
2168 let root = FileTreeNode::Directory { files: root_files };
2169 let quill = Quill::from_tree(root, None).unwrap();
2170
2171 let defaults = quill.extract_defaults();
2173
2174 assert_eq!(defaults.len(), 2);
2176 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2178 assert!(defaults.contains_key("status"));
2179
2180 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2182 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2183 }
2184
2185 #[test]
2186 fn test_field_order_preservation() {
2187 let toml_content = r#"[Quill]
2188name = "order-test"
2189backend = "typst"
2190description = "Test field order"
2191
2192[fields]
2193first = {description = "First field"}
2194second = {description = "Second field"}
2195third = {description = "Third field", ui = {order = 10}}
2196fourth = {description = "Fourth field"}
2197"#;
2198
2199 let config = QuillConfig::from_toml(toml_content).unwrap();
2200
2201 let first = config.fields.get("first").unwrap();
2205 assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2206
2207 let second = config.fields.get("second").unwrap();
2208 assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2209
2210 let third = config.fields.get("third").unwrap();
2211 assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2213
2214 let fourth = config.fields.get("fourth").unwrap();
2215 assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2216 }
2217}