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}
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct FieldSchema {
24 pub name: String,
25 pub r#type: Option<String>,
27 pub description: String,
29 pub default: Option<QuillValue>,
31 pub example: Option<QuillValue>,
33 pub examples: Option<QuillValue>,
35 pub ui: Option<UiSchema>,
37}
38
39impl FieldSchema {
40 pub fn new(name: String, description: String) -> Self {
42 Self {
43 name,
44 r#type: None,
45 description,
46 default: None,
47 example: None,
48 examples: None,
49 ui: None,
50 }
51 }
52
53 pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
55 let obj = value
56 .as_object()
57 .ok_or_else(|| "Field schema must be an object".to_string())?;
58
59 for key in obj.keys() {
61 match key.as_str() {
62 "name" | "type" | "description" | "example" | "default" | "ui" => {}
63 _ => {
64 eprintln!("Warning: Unknown key '{}' in field schema", key);
66 }
67 }
68 }
69
70 let name = key.clone();
71
72 let description = obj
73 .get("description")
74 .and_then(|v| v.as_str())
75 .unwrap_or("")
76 .to_string();
77
78 let field_type = obj
79 .get("type")
80 .and_then(|v| v.as_str())
81 .map(|s| s.to_string());
82
83 let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
84
85 let example = obj.get("example").map(|v| QuillValue::from_json(v.clone()));
86
87 let examples = obj
88 .get("examples")
89 .map(|v| QuillValue::from_json(v.clone()));
90
91 let ui = if let Some(ui_value) = obj.get("ui") {
93 if let Some(ui_obj) = ui_value.as_object() {
94 let group = ui_obj
95 .get("group")
96 .and_then(|v| v.as_str())
97 .map(|s| s.to_string());
98
99 let tooltip = ui_obj
100 .get("tooltip")
101 .and_then(|v| v.as_str())
102 .map(|s| s.to_string());
103
104 for key in ui_obj.keys() {
106 match key.as_str() {
107 "group" | "tooltip" => {}
108 _ => {
109 eprintln!("Warning: Unknown UI property '{}'. Only 'group' and 'tooltip' are supported.", key);
111 }
112 }
113 }
114
115 Some(UiSchema {
116 group,
117 tooltip,
118 order: None, })
120 } else {
121 return Err("UI field must be an object".to_string());
122 }
123 } else {
124 None
125 };
126
127 Ok(Self {
128 name,
129 r#type: field_type,
130 description,
131 default,
132 example,
133 examples,
134 ui,
135 })
136 }
137}
138
139#[derive(Debug, Clone)]
141pub enum FileTreeNode {
142 File {
144 contents: Vec<u8>,
146 },
147 Directory {
149 files: HashMap<String, FileTreeNode>,
151 },
152}
153
154impl FileTreeNode {
155 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
157 let path = path.as_ref();
158
159 if path == Path::new("") {
161 return Some(self);
162 }
163
164 let components: Vec<_> = path
166 .components()
167 .filter_map(|c| {
168 if let std::path::Component::Normal(s) = c {
169 s.to_str()
170 } else {
171 None
172 }
173 })
174 .collect();
175
176 if components.is_empty() {
177 return Some(self);
178 }
179
180 let mut current_node = self;
182 for component in components {
183 match current_node {
184 FileTreeNode::Directory { files } => {
185 current_node = files.get(component)?;
186 }
187 FileTreeNode::File { .. } => {
188 return None; }
190 }
191 }
192
193 Some(current_node)
194 }
195
196 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
198 match self.get_node(path)? {
199 FileTreeNode::File { contents } => Some(contents.as_slice()),
200 FileTreeNode::Directory { .. } => None,
201 }
202 }
203
204 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
206 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
207 }
208
209 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
211 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
212 }
213
214 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
216 match self.get_node(dir_path) {
217 Some(FileTreeNode::Directory { files }) => files
218 .iter()
219 .filter_map(|(name, node)| {
220 if matches!(node, FileTreeNode::File { .. }) {
221 Some(name.clone())
222 } else {
223 None
224 }
225 })
226 .collect(),
227 _ => Vec::new(),
228 }
229 }
230
231 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
233 match self.get_node(dir_path) {
234 Some(FileTreeNode::Directory { files }) => files
235 .iter()
236 .filter_map(|(name, node)| {
237 if matches!(node, FileTreeNode::Directory { .. }) {
238 Some(name.clone())
239 } else {
240 None
241 }
242 })
243 .collect(),
244 _ => Vec::new(),
245 }
246 }
247
248 pub fn insert<P: AsRef<Path>>(
250 &mut self,
251 path: P,
252 node: FileTreeNode,
253 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
254 let path = path.as_ref();
255
256 let components: Vec<_> = path
258 .components()
259 .filter_map(|c| {
260 if let std::path::Component::Normal(s) = c {
261 s.to_str().map(|s| s.to_string())
262 } else {
263 None
264 }
265 })
266 .collect();
267
268 if components.is_empty() {
269 return Err("Cannot insert at root path".into());
270 }
271
272 let mut current_node = self;
274 for component in &components[..components.len() - 1] {
275 match current_node {
276 FileTreeNode::Directory { files } => {
277 current_node =
278 files
279 .entry(component.clone())
280 .or_insert_with(|| FileTreeNode::Directory {
281 files: HashMap::new(),
282 });
283 }
284 FileTreeNode::File { .. } => {
285 return Err("Cannot traverse into a file".into());
286 }
287 }
288 }
289
290 let filename = &components[components.len() - 1];
292 match current_node {
293 FileTreeNode::Directory { files } => {
294 files.insert(filename.clone(), node);
295 Ok(())
296 }
297 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
298 }
299 }
300
301 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
303 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
304 Ok(FileTreeNode::File {
306 contents: contents_str.as_bytes().to_vec(),
307 })
308 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
309 let contents: Vec<u8> = bytes_array
311 .iter()
312 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
313 .collect();
314 Ok(FileTreeNode::File { contents })
315 } else if let Some(obj) = value.as_object() {
316 let mut files = HashMap::new();
318 for (name, child_value) in obj {
319 files.insert(name.clone(), Self::from_json_value(child_value)?);
320 }
321 Ok(FileTreeNode::Directory { files })
323 } else {
324 Err(format!("Invalid file tree node: {:?}", value).into())
325 }
326 }
327
328 pub fn print_tree(&self) -> String {
329 self.__print_tree("", "", true)
330 }
331
332 pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
333 let mut result = String::new();
334
335 let connector = if is_last { "└── " } else { "├── " };
337 let extension = if is_last { " " } else { "│ " };
338
339 match self {
340 FileTreeNode::File { .. } => {
341 result.push_str(&format!("{}{}{}\n", prefix, connector, name));
342 }
343 FileTreeNode::Directory { files } => {
344 result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
346
347 let child_prefix = format!("{}{}", prefix, extension);
348 let count = files.len();
349
350 for (i, (child_name, node)) in files.iter().enumerate() {
351 let is_last_child = i == count - 1;
352 result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
353 }
354 }
355 }
356
357 result
358 }
359}
360
361#[derive(Debug, Clone)]
363pub struct QuillIgnore {
364 patterns: Vec<String>,
365}
366
367impl QuillIgnore {
368 pub fn new(patterns: Vec<String>) -> Self {
370 Self { patterns }
371 }
372
373 pub fn from_content(content: &str) -> Self {
375 let patterns = content
376 .lines()
377 .map(|line| line.trim())
378 .filter(|line| !line.is_empty() && !line.starts_with('#'))
379 .map(|line| line.to_string())
380 .collect();
381 Self::new(patterns)
382 }
383
384 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
386 let path = path.as_ref();
387 let path_str = path.to_string_lossy();
388
389 for pattern in &self.patterns {
390 if self.matches_pattern(pattern, &path_str) {
391 return true;
392 }
393 }
394 false
395 }
396
397 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
399 if pattern.ends_with('/') {
401 let pattern_prefix = &pattern[..pattern.len() - 1];
402 return path.starts_with(pattern_prefix)
403 && (path.len() == pattern_prefix.len()
404 || path.chars().nth(pattern_prefix.len()) == Some('/'));
405 }
406
407 if !pattern.contains('*') {
409 return path == pattern || path.ends_with(&format!("/{}", pattern));
410 }
411
412 if pattern == "*" {
414 return true;
415 }
416
417 let pattern_parts: Vec<&str> = pattern.split('*').collect();
419 if pattern_parts.len() == 2 {
420 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
421 if prefix.is_empty() {
422 return path.ends_with(suffix);
423 } else if suffix.is_empty() {
424 return path.starts_with(prefix);
425 } else {
426 return path.starts_with(prefix) && path.ends_with(suffix);
427 }
428 }
429
430 false
431 }
432}
433
434#[derive(Debug, Clone)]
436pub struct Quill {
437 pub metadata: HashMap<String, QuillValue>,
439 pub name: String,
441 pub backend: String,
443 pub glue: Option<String>,
445 pub example: Option<String>,
447 pub schema: QuillValue,
449 pub defaults: HashMap<String, QuillValue>,
451 pub examples: HashMap<String, Vec<QuillValue>>,
453 pub files: FileTreeNode,
455}
456
457#[derive(Debug, Clone)]
459pub struct QuillConfig {
460 pub name: String,
462 pub description: String,
464 pub backend: String,
466 pub version: Option<String>,
468 pub author: Option<String>,
470 pub example_file: Option<String>,
472 pub glue_file: Option<String>,
474 pub fields: HashMap<String, FieldSchema>,
476 pub metadata: HashMap<String, QuillValue>,
478 pub typst_config: HashMap<String, QuillValue>,
480}
481
482impl QuillConfig {
483 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
485 let quill_toml: toml::Value = toml::from_str(toml_content)
486 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
487
488 let field_order: Vec<String> = toml_content
490 .parse::<toml_edit::DocumentMut>()
491 .ok()
492 .and_then(|doc| {
493 doc.get("fields")
494 .and_then(|item| item.as_table())
495 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
496 })
497 .unwrap_or_default();
498
499 let quill_section = quill_toml
501 .get("Quill")
502 .ok_or("Missing required [Quill] section in Quill.toml")?;
503
504 let name = quill_section
506 .get("name")
507 .and_then(|v| v.as_str())
508 .ok_or("Missing required 'name' field in [Quill] section")?
509 .to_string();
510
511 let backend = quill_section
512 .get("backend")
513 .and_then(|v| v.as_str())
514 .ok_or("Missing required 'backend' field in [Quill] section")?
515 .to_string();
516
517 let description = quill_section
518 .get("description")
519 .and_then(|v| v.as_str())
520 .ok_or("Missing required 'description' field in [Quill] section")?;
521
522 if description.trim().is_empty() {
523 return Err("'description' field in [Quill] section cannot be empty".into());
524 }
525 let description = description.to_string();
526
527 let version = quill_section
529 .get("version")
530 .and_then(|v| v.as_str())
531 .map(|s| s.to_string());
532
533 let author = quill_section
534 .get("author")
535 .and_then(|v| v.as_str())
536 .map(|s| s.to_string());
537
538 let example_file = quill_section
539 .get("example_file")
540 .and_then(|v| v.as_str())
541 .map(|s| s.to_string());
542
543 let glue_file = quill_section
544 .get("glue_file")
545 .and_then(|v| v.as_str())
546 .map(|s| s.to_string());
547
548 let mut metadata = HashMap::new();
550 if let toml::Value::Table(table) = quill_section {
551 for (key, value) in table {
552 if key != "name"
554 && key != "backend"
555 && key != "description"
556 && key != "version"
557 && key != "author"
558 && key != "example_file"
559 && key != "glue_file"
560 {
561 match QuillValue::from_toml(value) {
562 Ok(quill_value) => {
563 metadata.insert(key.clone(), quill_value);
564 }
565 Err(e) => {
566 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
567 }
568 }
569 }
570 }
571 }
572
573 let mut typst_config = HashMap::new();
575 if let Some(typst_section) = quill_toml.get("typst") {
576 if let toml::Value::Table(table) = typst_section {
577 for (key, value) in table {
578 match QuillValue::from_toml(value) {
579 Ok(quill_value) => {
580 typst_config.insert(key.clone(), quill_value);
581 }
582 Err(e) => {
583 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
584 }
585 }
586 }
587 }
588 }
589
590 let mut fields = HashMap::new();
592 if let Some(fields_section) = quill_toml.get("fields") {
593 if let toml::Value::Table(fields_table) = fields_section {
594 let mut order_counter = 0;
595 for (field_name, field_schema) in fields_table {
596 let order = if let Some(idx) = field_order.iter().position(|k| k == field_name)
598 {
599 idx as i32
600 } else {
601 let o = field_order.len() as i32 + order_counter;
602 order_counter += 1;
603 o
604 };
605
606 match QuillValue::from_toml(field_schema) {
607 Ok(quill_value) => {
608 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
609 Ok(mut schema) => {
610 if schema.ui.is_none() {
612 schema.ui = Some(UiSchema {
613 group: None,
614 tooltip: None,
615 order: Some(order),
616 });
617 } else if let Some(ui) = &mut schema.ui {
618 ui.order = Some(order);
619 }
620
621 fields.insert(field_name.clone(), schema);
622 }
623 Err(e) => {
624 eprintln!(
625 "Warning: Failed to parse field schema '{}': {}",
626 field_name, e
627 );
628 }
629 }
630 }
631 Err(e) => {
632 eprintln!(
633 "Warning: Failed to convert field schema '{}': {}",
634 field_name, e
635 );
636 }
637 }
638 }
639 }
640 }
641
642 Ok(QuillConfig {
643 name,
644 description,
645 backend,
646 version,
647 author,
648 example_file,
649 glue_file,
650 fields,
651 metadata,
652 typst_config,
653 })
654 }
655}
656
657impl Quill {
658 pub fn from_path<P: AsRef<std::path::Path>>(
660 path: P,
661 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
662 use std::fs;
663
664 let path = path.as_ref();
665 let name = path
666 .file_name()
667 .and_then(|n| n.to_str())
668 .unwrap_or("unnamed")
669 .to_string();
670
671 let quillignore_path = path.join(".quillignore");
673 let ignore = if quillignore_path.exists() {
674 let ignore_content = fs::read_to_string(&quillignore_path)
675 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
676 QuillIgnore::from_content(&ignore_content)
677 } else {
678 QuillIgnore::new(vec![
680 ".git/".to_string(),
681 ".gitignore".to_string(),
682 ".quillignore".to_string(),
683 "target/".to_string(),
684 "node_modules/".to_string(),
685 ])
686 };
687
688 let root = Self::load_directory_as_tree(path, path, &ignore)?;
690
691 Self::from_tree(root, Some(name))
693 }
694
695 pub fn from_tree(
712 root: FileTreeNode,
713 _default_name: Option<String>,
714 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
715 let quill_toml_bytes = root
717 .get_file("Quill.toml")
718 .ok_or("Quill.toml not found in file tree")?;
719
720 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
721 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
722
723 let config = QuillConfig::from_toml(&quill_toml_content)?;
725
726 Self::from_config(config, root)
728 }
729
730 fn from_config(
747 config: QuillConfig,
748 root: FileTreeNode,
749 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
750 let mut metadata = config.metadata.clone();
752
753 metadata.insert(
755 "backend".to_string(),
756 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
757 );
758
759 metadata.insert(
761 "description".to_string(),
762 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
763 );
764
765 if let Some(ref author) = config.author {
767 metadata.insert(
768 "author".to_string(),
769 QuillValue::from_json(serde_json::Value::String(author.clone())),
770 );
771 }
772
773 for (key, value) in &config.typst_config {
775 metadata.insert(format!("typst_{}", key), value.clone());
776 }
777
778 let schema = build_schema_from_fields(&config.fields)
780 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
781
782 let glue_content: Option<String> = if let Some(ref glue_file_name) = config.glue_file {
784 let glue_bytes = root
785 .get_file(glue_file_name)
786 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
787
788 let content = String::from_utf8(glue_bytes.to_vec())
789 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?;
790 Some(content)
791 } else {
792 None
794 };
795
796 let example_content = if let Some(ref example_file_name) = config.example_file {
798 root.get_file(example_file_name).and_then(|bytes| {
799 String::from_utf8(bytes.to_vec())
800 .map_err(|e| {
801 eprintln!(
802 "Warning: Example file '{}' is not valid UTF-8: {}",
803 example_file_name, e
804 );
805 e
806 })
807 .ok()
808 })
809 } else {
810 None
811 };
812
813 let defaults = crate::schema::extract_defaults_from_schema(&schema);
815 let examples = crate::schema::extract_examples_from_schema(&schema);
816
817 let quill = Quill {
818 metadata,
819 name: config.name,
820 backend: config.backend,
821 glue: glue_content,
822 example: example_content,
823 schema,
824 defaults,
825 examples,
826 files: root,
827 };
828
829 Ok(quill)
830 }
831
832 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
839 use serde_json::Value as JsonValue;
840
841 let json: JsonValue =
842 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
843
844 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
845
846 let default_name = obj
848 .get("metadata")
849 .and_then(|m| m.get("name"))
850 .and_then(|v| v.as_str())
851 .map(String::from);
852
853 let files_obj = obj
855 .get("files")
856 .and_then(|v| v.as_object())
857 .ok_or_else(|| "Missing or invalid 'files' key")?;
858
859 let mut root_files = HashMap::new();
861 for (key, value) in files_obj {
862 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
863 }
864
865 let root = FileTreeNode::Directory { files: root_files };
866
867 Self::from_tree(root, default_name)
869 }
870
871 fn load_directory_as_tree(
873 current_dir: &Path,
874 base_dir: &Path,
875 ignore: &QuillIgnore,
876 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
877 use std::fs;
878
879 if !current_dir.exists() {
880 return Ok(FileTreeNode::Directory {
881 files: HashMap::new(),
882 });
883 }
884
885 let mut files = HashMap::new();
886
887 for entry in fs::read_dir(current_dir)? {
888 let entry = entry?;
889 let path = entry.path();
890 let relative_path = path
891 .strip_prefix(base_dir)
892 .map_err(|e| format!("Failed to get relative path: {}", e))?
893 .to_path_buf();
894
895 if ignore.is_ignored(&relative_path) {
897 continue;
898 }
899
900 let filename = path
902 .file_name()
903 .and_then(|n| n.to_str())
904 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
905 .to_string();
906
907 if path.is_file() {
908 let contents = fs::read(&path)
909 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
910
911 files.insert(filename, FileTreeNode::File { contents });
912 } else if path.is_dir() {
913 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
915 files.insert(filename, subdir_tree);
916 }
917 }
918
919 Ok(FileTreeNode::Directory { files })
920 }
921
922 pub fn typst_packages(&self) -> Vec<String> {
924 self.metadata
925 .get("typst_packages")
926 .and_then(|v| v.as_array())
927 .map(|arr| {
928 arr.iter()
929 .filter_map(|v| v.as_str().map(|s| s.to_string()))
930 .collect()
931 })
932 .unwrap_or_default()
933 }
934
935 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
943 &self.defaults
944 }
945
946 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
951 &self.examples
952 }
953
954 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
956 self.files.get_file(path)
957 }
958
959 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
961 self.files.file_exists(path)
962 }
963
964 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
966 self.files.dir_exists(path)
967 }
968
969 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
971 self.files.list_files(path)
972 }
973
974 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
976 self.files.list_subdirectories(path)
977 }
978
979 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
981 let dir_path = dir_path.as_ref();
982 let filenames = self.files.list_files(dir_path);
983
984 filenames
986 .iter()
987 .map(|name| {
988 if dir_path == Path::new("") {
989 PathBuf::from(name)
990 } else {
991 dir_path.join(name)
992 }
993 })
994 .collect()
995 }
996
997 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
999 let dir_path = dir_path.as_ref();
1000 let subdirs = self.files.list_subdirectories(dir_path);
1001
1002 subdirs
1004 .iter()
1005 .map(|name| {
1006 if dir_path == Path::new("") {
1007 PathBuf::from(name)
1008 } else {
1009 dir_path.join(name)
1010 }
1011 })
1012 .collect()
1013 }
1014
1015 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1017 let pattern_str = pattern.as_ref().to_string_lossy();
1018 let mut matches = Vec::new();
1019
1020 let glob_pattern = match glob::Pattern::new(&pattern_str) {
1022 Ok(pat) => pat,
1023 Err(_) => return matches, };
1025
1026 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1028
1029 matches.sort();
1030 matches
1031 }
1032
1033 fn find_files_recursive(
1035 &self,
1036 node: &FileTreeNode,
1037 current_path: &Path,
1038 pattern: &glob::Pattern,
1039 matches: &mut Vec<PathBuf>,
1040 ) {
1041 match node {
1042 FileTreeNode::File { .. } => {
1043 let path_str = current_path.to_string_lossy();
1044 if pattern.matches(&path_str) {
1045 matches.push(current_path.to_path_buf());
1046 }
1047 }
1048 FileTreeNode::Directory { files } => {
1049 for (name, child_node) in files {
1050 let child_path = if current_path == Path::new("") {
1051 PathBuf::from(name)
1052 } else {
1053 current_path.join(name)
1054 };
1055 self.find_files_recursive(child_node, &child_path, pattern, matches);
1056 }
1057 }
1058 }
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use std::fs;
1066 use tempfile::TempDir;
1067
1068 #[test]
1069 fn test_quillignore_parsing() {
1070 let ignore_content = r#"
1071# This is a comment
1072*.tmp
1073target/
1074node_modules/
1075.git/
1076"#;
1077 let ignore = QuillIgnore::from_content(ignore_content);
1078 assert_eq!(ignore.patterns.len(), 4);
1079 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1080 assert!(ignore.patterns.contains(&"target/".to_string()));
1081 }
1082
1083 #[test]
1084 fn test_quillignore_matching() {
1085 let ignore = QuillIgnore::new(vec![
1086 "*.tmp".to_string(),
1087 "target/".to_string(),
1088 "node_modules/".to_string(),
1089 ".git/".to_string(),
1090 ]);
1091
1092 assert!(ignore.is_ignored("test.tmp"));
1094 assert!(ignore.is_ignored("path/to/file.tmp"));
1095 assert!(!ignore.is_ignored("test.txt"));
1096
1097 assert!(ignore.is_ignored("target"));
1099 assert!(ignore.is_ignored("target/debug"));
1100 assert!(ignore.is_ignored("target/debug/deps"));
1101 assert!(!ignore.is_ignored("src/target.rs"));
1102
1103 assert!(ignore.is_ignored("node_modules"));
1104 assert!(ignore.is_ignored("node_modules/package"));
1105 assert!(!ignore.is_ignored("my_node_modules"));
1106 }
1107
1108 #[test]
1109 fn test_in_memory_file_system() {
1110 let temp_dir = TempDir::new().unwrap();
1111 let quill_dir = temp_dir.path();
1112
1113 fs::write(
1115 quill_dir.join("Quill.toml"),
1116 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1117 )
1118 .unwrap();
1119 fs::write(quill_dir.join("glue.typ"), "test glue").unwrap();
1120
1121 let assets_dir = quill_dir.join("assets");
1122 fs::create_dir_all(&assets_dir).unwrap();
1123 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1124
1125 let packages_dir = quill_dir.join("packages");
1126 fs::create_dir_all(&packages_dir).unwrap();
1127 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1128
1129 let quill = Quill::from_path(quill_dir).unwrap();
1131
1132 assert!(quill.file_exists("glue.typ"));
1134 assert!(quill.file_exists("assets/test.txt"));
1135 assert!(quill.file_exists("packages/package.typ"));
1136 assert!(!quill.file_exists("nonexistent.txt"));
1137
1138 let asset_content = quill.get_file("assets/test.txt").unwrap();
1140 assert_eq!(asset_content, b"asset content");
1141
1142 let asset_files = quill.list_directory("assets");
1144 assert_eq!(asset_files.len(), 1);
1145 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1146 }
1147
1148 #[test]
1149 fn test_quillignore_integration() {
1150 let temp_dir = TempDir::new().unwrap();
1151 let quill_dir = temp_dir.path();
1152
1153 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1155
1156 fs::write(
1158 quill_dir.join("Quill.toml"),
1159 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1160 )
1161 .unwrap();
1162 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
1163 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1164
1165 let target_dir = quill_dir.join("target");
1166 fs::create_dir_all(&target_dir).unwrap();
1167 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1168
1169 let quill = Quill::from_path(quill_dir).unwrap();
1171
1172 assert!(quill.file_exists("glue.typ"));
1174 assert!(!quill.file_exists("should_ignore.tmp"));
1175 assert!(!quill.file_exists("target/debug.txt"));
1176 }
1177
1178 #[test]
1179 fn test_find_files_pattern() {
1180 let temp_dir = TempDir::new().unwrap();
1181 let quill_dir = temp_dir.path();
1182
1183 fs::write(
1185 quill_dir.join("Quill.toml"),
1186 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1187 )
1188 .unwrap();
1189 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
1190
1191 let assets_dir = quill_dir.join("assets");
1192 fs::create_dir_all(&assets_dir).unwrap();
1193 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1194 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1195
1196 let fonts_dir = assets_dir.join("fonts");
1197 fs::create_dir_all(&fonts_dir).unwrap();
1198 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1199
1200 let quill = Quill::from_path(quill_dir).unwrap();
1202
1203 let all_assets = quill.find_files("assets/*");
1205 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1208 assert_eq!(typ_files.len(), 1);
1209 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1210 }
1211
1212 #[test]
1213 fn test_new_standardized_toml_format() {
1214 let temp_dir = TempDir::new().unwrap();
1215 let quill_dir = temp_dir.path();
1216
1217 let toml_content = r#"[Quill]
1219name = "my-custom-quill"
1220backend = "typst"
1221glue_file = "custom_glue.typ"
1222description = "Test quill with new format"
1223author = "Test Author"
1224"#;
1225 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1226 fs::write(
1227 quill_dir.join("custom_glue.typ"),
1228 "= Custom Template\n\nThis is a custom template.",
1229 )
1230 .unwrap();
1231
1232 let quill = Quill::from_path(quill_dir).unwrap();
1234
1235 assert_eq!(quill.name, "my-custom-quill");
1237
1238 assert!(quill.metadata.contains_key("backend"));
1240 if let Some(backend_val) = quill.metadata.get("backend") {
1241 if let Some(backend_str) = backend_val.as_str() {
1242 assert_eq!(backend_str, "typst");
1243 } else {
1244 panic!("Backend value is not a string");
1245 }
1246 }
1247
1248 assert!(quill.metadata.contains_key("description"));
1250 assert!(quill.metadata.contains_key("author"));
1251 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue.unwrap().contains("Custom Template"));
1255 }
1256
1257 #[test]
1258 fn test_typst_packages_parsing() {
1259 let temp_dir = TempDir::new().unwrap();
1260 let quill_dir = temp_dir.path();
1261
1262 let toml_content = r#"
1263[Quill]
1264name = "test-quill"
1265backend = "typst"
1266glue_file = "glue.typ"
1267description = "Test quill for packages"
1268
1269[typst]
1270packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1271"#;
1272
1273 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1274 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1275
1276 let quill = Quill::from_path(quill_dir).unwrap();
1277 let packages = quill.typst_packages();
1278
1279 assert_eq!(packages.len(), 2);
1280 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1281 assert_eq!(packages[1], "@preview/example:1.0.0");
1282 }
1283
1284 #[test]
1285 fn test_template_loading() {
1286 let temp_dir = TempDir::new().unwrap();
1287 let quill_dir = temp_dir.path();
1288
1289 let toml_content = r#"[Quill]
1291name = "test-with-template"
1292backend = "typst"
1293glue_file = "glue.typ"
1294example_file = "example.md"
1295description = "Test quill with template"
1296"#;
1297 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1298 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1299 fs::write(
1300 quill_dir.join("example.md"),
1301 "---\ntitle: Test\n---\n\nThis is a test template.",
1302 )
1303 .unwrap();
1304
1305 let quill = Quill::from_path(quill_dir).unwrap();
1307
1308 assert!(quill.example.is_some());
1310 let example = quill.example.unwrap();
1311 assert!(example.contains("title: Test"));
1312 assert!(example.contains("This is a test template"));
1313
1314 assert_eq!(quill.glue.unwrap(), "glue content");
1316 }
1317
1318 #[test]
1319 fn test_template_optional() {
1320 let temp_dir = TempDir::new().unwrap();
1321 let quill_dir = temp_dir.path();
1322
1323 let toml_content = r#"[Quill]
1325name = "test-without-template"
1326backend = "typst"
1327glue_file = "glue.typ"
1328description = "Test quill without template"
1329"#;
1330 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1331 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1332
1333 let quill = Quill::from_path(quill_dir).unwrap();
1335
1336 assert_eq!(quill.example, None);
1338
1339 assert_eq!(quill.glue.unwrap(), "glue content");
1341 }
1342
1343 #[test]
1344 fn test_from_tree() {
1345 let mut root_files = HashMap::new();
1347
1348 let quill_toml = r#"[Quill]
1350name = "test-from-tree"
1351backend = "typst"
1352glue_file = "glue.typ"
1353description = "A test quill from tree"
1354"#;
1355 root_files.insert(
1356 "Quill.toml".to_string(),
1357 FileTreeNode::File {
1358 contents: quill_toml.as_bytes().to_vec(),
1359 },
1360 );
1361
1362 let glue_content = "= Test Template\n\nThis is a test.";
1364 root_files.insert(
1365 "glue.typ".to_string(),
1366 FileTreeNode::File {
1367 contents: glue_content.as_bytes().to_vec(),
1368 },
1369 );
1370
1371 let root = FileTreeNode::Directory { files: root_files };
1372
1373 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1375
1376 assert_eq!(quill.name, "test-from-tree");
1378 assert_eq!(quill.glue.unwrap(), glue_content);
1379 assert!(quill.metadata.contains_key("backend"));
1380 assert!(quill.metadata.contains_key("description"));
1381 }
1382
1383 #[test]
1384 fn test_from_tree_with_template() {
1385 let mut root_files = HashMap::new();
1386
1387 let quill_toml = r#"[Quill]
1389name = "test-tree-template"
1390backend = "typst"
1391glue_file = "glue.typ"
1392example_file = "template.md"
1393description = "Test tree with template"
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 root_files.insert(
1404 "glue.typ".to_string(),
1405 FileTreeNode::File {
1406 contents: b"glue content".to_vec(),
1407 },
1408 );
1409
1410 let template_content = "# {{ title }}\n\n{{ body }}";
1412 root_files.insert(
1413 "template.md".to_string(),
1414 FileTreeNode::File {
1415 contents: template_content.as_bytes().to_vec(),
1416 },
1417 );
1418
1419 let root = FileTreeNode::Directory { files: root_files };
1420
1421 let quill = Quill::from_tree(root, None).unwrap();
1423
1424 assert_eq!(quill.example, Some(template_content.to_string()));
1426 }
1427
1428 #[test]
1429 fn test_from_json() {
1430 let json_str = r#"{
1432 "metadata": {
1433 "name": "test-from-json"
1434 },
1435 "files": {
1436 "Quill.toml": {
1437 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1438 },
1439 "glue.typ": {
1440 "contents": "= Test Glue\n\nThis is test content."
1441 }
1442 }
1443 }"#;
1444
1445 let quill = Quill::from_json(json_str).unwrap();
1447
1448 assert_eq!(quill.name, "test-from-json");
1450 assert!(quill.glue.unwrap().contains("Test Glue"));
1451 assert!(quill.metadata.contains_key("backend"));
1452 }
1453
1454 #[test]
1455 fn test_from_json_with_byte_array() {
1456 let json_str = r#"{
1458 "files": {
1459 "Quill.toml": {
1460 "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]
1461 },
1462 "glue.typ": {
1463 "contents": "test glue"
1464 }
1465 }
1466 }"#;
1467
1468 let quill = Quill::from_json(json_str).unwrap();
1470
1471 assert_eq!(quill.name, "test");
1473 assert_eq!(quill.glue.unwrap(), "test glue");
1474 }
1475
1476 #[test]
1477 fn test_from_json_missing_files() {
1478 let json_str = r#"{
1480 "metadata": {
1481 "name": "test"
1482 }
1483 }"#;
1484
1485 let result = Quill::from_json(json_str);
1486 assert!(result.is_err());
1487 assert!(result.unwrap_err().to_string().contains("files"));
1489 }
1490
1491 #[test]
1492 fn test_from_json_tree_structure() {
1493 let json_str = r#"{
1495 "files": {
1496 "Quill.toml": {
1497 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1498 },
1499 "glue.typ": {
1500 "contents": "= Test Glue\n\nTree structure content."
1501 }
1502 }
1503 }"#;
1504
1505 let quill = Quill::from_json(json_str).unwrap();
1506
1507 assert_eq!(quill.name, "test-tree-json");
1508 assert!(quill.glue.unwrap().contains("Tree structure content"));
1509 assert!(quill.metadata.contains_key("backend"));
1510 }
1511
1512 #[test]
1513 fn test_from_json_nested_tree_structure() {
1514 let json_str = r#"{
1516 "files": {
1517 "Quill.toml": {
1518 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Nested test\"\n"
1519 },
1520 "glue.typ": {
1521 "contents": "glue"
1522 },
1523 "src": {
1524 "main.rs": {
1525 "contents": "fn main() {}"
1526 },
1527 "lib.rs": {
1528 "contents": "// lib"
1529 }
1530 }
1531 }
1532 }"#;
1533
1534 let quill = Quill::from_json(json_str).unwrap();
1535
1536 assert_eq!(quill.name, "nested-test");
1537 assert!(quill.file_exists("src/main.rs"));
1539 assert!(quill.file_exists("src/lib.rs"));
1540
1541 let main_rs = quill.get_file("src/main.rs").unwrap();
1542 assert_eq!(main_rs, b"fn main() {}");
1543 }
1544
1545 #[test]
1546 fn test_from_tree_structure_direct() {
1547 let mut root_files = HashMap::new();
1549
1550 root_files.insert(
1551 "Quill.toml".to_string(),
1552 FileTreeNode::File {
1553 contents:
1554 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1555 .to_vec(),
1556 },
1557 );
1558
1559 root_files.insert(
1560 "glue.typ".to_string(),
1561 FileTreeNode::File {
1562 contents: b"glue content".to_vec(),
1563 },
1564 );
1565
1566 let mut src_files = HashMap::new();
1568 src_files.insert(
1569 "main.rs".to_string(),
1570 FileTreeNode::File {
1571 contents: b"fn main() {}".to_vec(),
1572 },
1573 );
1574
1575 root_files.insert(
1576 "src".to_string(),
1577 FileTreeNode::Directory { files: src_files },
1578 );
1579
1580 let root = FileTreeNode::Directory { files: root_files };
1581
1582 let quill = Quill::from_tree(root, None).unwrap();
1583
1584 assert_eq!(quill.name, "direct-tree");
1585 assert!(quill.file_exists("src/main.rs"));
1586 assert!(quill.file_exists("glue.typ"));
1587 }
1588
1589 #[test]
1590 fn test_from_json_with_metadata_override() {
1591 let json_str = r#"{
1593 "metadata": {
1594 "name": "override-name"
1595 },
1596 "files": {
1597 "Quill.toml": {
1598 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1599 },
1600 "glue.typ": {
1601 "contents": "= glue"
1602 }
1603 }
1604 }"#;
1605
1606 let quill = Quill::from_json(json_str).unwrap();
1607 assert_eq!(quill.name, "toml-name");
1610 }
1611
1612 #[test]
1613 fn test_from_json_empty_directory() {
1614 let json_str = r#"{
1616 "files": {
1617 "Quill.toml": {
1618 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1619 },
1620 "glue.typ": {
1621 "contents": "glue"
1622 },
1623 "empty_dir": {}
1624 }
1625 }"#;
1626
1627 let quill = Quill::from_json(json_str).unwrap();
1628 assert_eq!(quill.name, "empty-dir-test");
1629 assert!(quill.dir_exists("empty_dir"));
1630 assert!(!quill.file_exists("empty_dir"));
1631 }
1632
1633 #[test]
1634 fn test_dir_exists_and_list_apis() {
1635 let mut root_files = HashMap::new();
1636
1637 root_files.insert(
1639 "Quill.toml".to_string(),
1640 FileTreeNode::File {
1641 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"\n"
1642 .to_vec(),
1643 },
1644 );
1645
1646 root_files.insert(
1648 "glue.typ".to_string(),
1649 FileTreeNode::File {
1650 contents: b"glue content".to_vec(),
1651 },
1652 );
1653
1654 let mut assets_files = HashMap::new();
1656 assets_files.insert(
1657 "logo.png".to_string(),
1658 FileTreeNode::File {
1659 contents: vec![137, 80, 78, 71],
1660 },
1661 );
1662 assets_files.insert(
1663 "icon.svg".to_string(),
1664 FileTreeNode::File {
1665 contents: b"<svg></svg>".to_vec(),
1666 },
1667 );
1668
1669 let mut fonts_files = HashMap::new();
1671 fonts_files.insert(
1672 "font.ttf".to_string(),
1673 FileTreeNode::File {
1674 contents: b"font data".to_vec(),
1675 },
1676 );
1677 assets_files.insert(
1678 "fonts".to_string(),
1679 FileTreeNode::Directory { files: fonts_files },
1680 );
1681
1682 root_files.insert(
1683 "assets".to_string(),
1684 FileTreeNode::Directory {
1685 files: assets_files,
1686 },
1687 );
1688
1689 root_files.insert(
1691 "empty".to_string(),
1692 FileTreeNode::Directory {
1693 files: HashMap::new(),
1694 },
1695 );
1696
1697 let root = FileTreeNode::Directory { files: root_files };
1698 let quill = Quill::from_tree(root, None).unwrap();
1699
1700 assert!(quill.dir_exists("assets"));
1702 assert!(quill.dir_exists("assets/fonts"));
1703 assert!(quill.dir_exists("empty"));
1704 assert!(!quill.dir_exists("nonexistent"));
1705 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1709 assert!(quill.file_exists("assets/logo.png"));
1710 assert!(quill.file_exists("assets/fonts/font.ttf"));
1711 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1715 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1717 assert!(root_files_list.contains(&"glue.typ".to_string()));
1718
1719 let assets_files_list = quill.list_files("assets");
1720 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1722 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1723
1724 let root_subdirs = quill.list_subdirectories("");
1726 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1728 assert!(root_subdirs.contains(&"empty".to_string()));
1729
1730 let assets_subdirs = quill.list_subdirectories("assets");
1731 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1733
1734 let empty_subdirs = quill.list_subdirectories("empty");
1735 assert_eq!(empty_subdirs.len(), 0);
1736 }
1737
1738 #[test]
1739 fn test_field_schemas_parsing() {
1740 let mut root_files = HashMap::new();
1741
1742 let quill_toml = r#"[Quill]
1744name = "taro"
1745backend = "typst"
1746glue_file = "glue.typ"
1747example_file = "taro.md"
1748description = "Test template for field schemas"
1749
1750[fields]
1751author = {description = "Author of document" }
1752ice_cream = {description = "favorite ice cream flavor"}
1753title = {description = "title of document" }
1754"#;
1755 root_files.insert(
1756 "Quill.toml".to_string(),
1757 FileTreeNode::File {
1758 contents: quill_toml.as_bytes().to_vec(),
1759 },
1760 );
1761
1762 let glue_content = "= Test Template\n\nThis is a test.";
1764 root_files.insert(
1765 "glue.typ".to_string(),
1766 FileTreeNode::File {
1767 contents: glue_content.as_bytes().to_vec(),
1768 },
1769 );
1770
1771 root_files.insert(
1773 "taro.md".to_string(),
1774 FileTreeNode::File {
1775 contents: b"# Template".to_vec(),
1776 },
1777 );
1778
1779 let root = FileTreeNode::Directory { files: root_files };
1780
1781 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1783
1784 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1786 assert!(quill.schema["properties"]
1787 .as_object()
1788 .unwrap()
1789 .contains_key("author"));
1790 assert!(quill.schema["properties"]
1791 .as_object()
1792 .unwrap()
1793 .contains_key("ice_cream"));
1794 assert!(quill.schema["properties"]
1795 .as_object()
1796 .unwrap()
1797 .contains_key("title"));
1798
1799 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1801 assert_eq!(author_schema["description"], "Author of document");
1802
1803 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1805 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1806
1807 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1809 assert_eq!(title_schema["description"], "title of document");
1810 }
1811
1812 #[test]
1813 fn test_field_schema_struct() {
1814 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1816 assert_eq!(schema1.description, "Test description");
1817 assert_eq!(schema1.r#type, None);
1818 assert_eq!(schema1.example, None);
1819 assert_eq!(schema1.default, None);
1820
1821 let yaml_str = r#"
1823description: "Full field schema"
1824type: "string"
1825example: "Example value"
1826default: "Default value"
1827"#;
1828 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1829 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1830 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1831 assert_eq!(schema2.name, "test_name");
1832 assert_eq!(schema2.description, "Full field schema");
1833 assert_eq!(schema2.r#type, Some("string".to_string()));
1834 assert_eq!(
1835 schema2.example.as_ref().and_then(|v| v.as_str()),
1836 Some("Example value")
1837 );
1838 assert_eq!(
1839 schema2.default.as_ref().and_then(|v| v.as_str()),
1840 Some("Default value")
1841 );
1842 }
1843
1844 #[test]
1845 fn test_quill_without_glue_file() {
1846 let mut root_files = HashMap::new();
1848
1849 let quill_toml = r#"[Quill]
1851name = "test-no-glue"
1852backend = "typst"
1853description = "Test quill without glue file"
1854"#;
1855 root_files.insert(
1856 "Quill.toml".to_string(),
1857 FileTreeNode::File {
1858 contents: quill_toml.as_bytes().to_vec(),
1859 },
1860 );
1861
1862 let root = FileTreeNode::Directory { files: root_files };
1863
1864 let quill = Quill::from_tree(root, None).unwrap();
1866
1867 assert!(quill.glue.clone().is_none());
1869 assert_eq!(quill.name, "test-no-glue");
1870 }
1871
1872 #[test]
1873 fn test_quill_config_from_toml() {
1874 let toml_content = r#"[Quill]
1876name = "test-config"
1877backend = "typst"
1878description = "Test configuration parsing"
1879version = "1.0.0"
1880author = "Test Author"
1881glue_file = "glue.typ"
1882example_file = "example.md"
1883
1884[typst]
1885packages = ["@preview/bubble:0.2.2"]
1886
1887[fields]
1888title = {description = "Document title", type = "string"}
1889author = {description = "Document author"}
1890"#;
1891
1892 let config = QuillConfig::from_toml(toml_content).unwrap();
1893
1894 assert_eq!(config.name, "test-config");
1896 assert_eq!(config.backend, "typst");
1897 assert_eq!(config.description, "Test configuration parsing");
1898
1899 assert_eq!(config.version, Some("1.0.0".to_string()));
1901 assert_eq!(config.author, Some("Test Author".to_string()));
1902 assert_eq!(config.glue_file, Some("glue.typ".to_string()));
1903 assert_eq!(config.example_file, Some("example.md".to_string()));
1904
1905 assert!(config.typst_config.contains_key("packages"));
1907
1908 assert_eq!(config.fields.len(), 2);
1910 assert!(config.fields.contains_key("title"));
1911 assert!(config.fields.contains_key("author"));
1912
1913 let title_field = &config.fields["title"];
1914 assert_eq!(title_field.description, "Document title");
1915 assert_eq!(title_field.r#type, Some("string".to_string()));
1916 }
1917
1918 #[test]
1919 fn test_quill_config_missing_required_fields() {
1920 let toml_missing_name = r#"[Quill]
1922backend = "typst"
1923description = "Missing name"
1924"#;
1925 let result = QuillConfig::from_toml(toml_missing_name);
1926 assert!(result.is_err());
1927 assert!(result
1928 .unwrap_err()
1929 .to_string()
1930 .contains("Missing required 'name'"));
1931
1932 let toml_missing_backend = r#"[Quill]
1933name = "test"
1934description = "Missing backend"
1935"#;
1936 let result = QuillConfig::from_toml(toml_missing_backend);
1937 assert!(result.is_err());
1938 assert!(result
1939 .unwrap_err()
1940 .to_string()
1941 .contains("Missing required 'backend'"));
1942
1943 let toml_missing_description = r#"[Quill]
1944name = "test"
1945backend = "typst"
1946"#;
1947 let result = QuillConfig::from_toml(toml_missing_description);
1948 assert!(result.is_err());
1949 assert!(result
1950 .unwrap_err()
1951 .to_string()
1952 .contains("Missing required 'description'"));
1953 }
1954
1955 #[test]
1956 fn test_quill_config_empty_description() {
1957 let toml_empty_description = r#"[Quill]
1959name = "test"
1960backend = "typst"
1961description = " "
1962"#;
1963 let result = QuillConfig::from_toml(toml_empty_description);
1964 assert!(result.is_err());
1965 assert!(result
1966 .unwrap_err()
1967 .to_string()
1968 .contains("description' field in [Quill] section cannot be empty"));
1969 }
1970
1971 #[test]
1972 fn test_quill_config_missing_quill_section() {
1973 let toml_no_section = r#"[fields]
1975title = {description = "Title"}
1976"#;
1977 let result = QuillConfig::from_toml(toml_no_section);
1978 assert!(result.is_err());
1979 assert!(result
1980 .unwrap_err()
1981 .to_string()
1982 .contains("Missing required [Quill] section"));
1983 }
1984
1985 #[test]
1986 fn test_quill_from_config_metadata() {
1987 let mut root_files = HashMap::new();
1989
1990 let quill_toml = r#"[Quill]
1991name = "metadata-test"
1992backend = "typst"
1993description = "Test metadata flow"
1994author = "Test Author"
1995custom_field = "custom_value"
1996
1997[typst]
1998packages = ["@preview/bubble:0.2.2"]
1999"#;
2000 root_files.insert(
2001 "Quill.toml".to_string(),
2002 FileTreeNode::File {
2003 contents: quill_toml.as_bytes().to_vec(),
2004 },
2005 );
2006
2007 let root = FileTreeNode::Directory { files: root_files };
2008 let quill = Quill::from_tree(root, None).unwrap();
2009
2010 assert!(quill.metadata.contains_key("backend"));
2012 assert!(quill.metadata.contains_key("description"));
2013 assert!(quill.metadata.contains_key("author"));
2014
2015 assert!(quill.metadata.contains_key("custom_field"));
2017 assert_eq!(
2018 quill.metadata.get("custom_field").unwrap().as_str(),
2019 Some("custom_value")
2020 );
2021
2022 assert!(quill.metadata.contains_key("typst_packages"));
2024 }
2025
2026 #[test]
2027 fn test_extract_defaults_method() {
2028 let mut root_files = HashMap::new();
2030
2031 let quill_toml = r#"[Quill]
2032name = "defaults-test"
2033backend = "typst"
2034description = "Test defaults extraction"
2035
2036[fields]
2037title = {description = "Title"}
2038author = {description = "Author", default = "Anonymous"}
2039status = {description = "Status", default = "draft"}
2040"#;
2041
2042 root_files.insert(
2043 "Quill.toml".to_string(),
2044 FileTreeNode::File {
2045 contents: quill_toml.as_bytes().to_vec(),
2046 },
2047 );
2048
2049 let root = FileTreeNode::Directory { files: root_files };
2050 let quill = Quill::from_tree(root, None).unwrap();
2051
2052 let defaults = quill.extract_defaults();
2054
2055 assert_eq!(defaults.len(), 2);
2057 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2059 assert!(defaults.contains_key("status"));
2060
2061 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2063 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2064 }
2065
2066 #[test]
2067 fn test_field_order_preservation() {
2068 let toml_content = r#"[Quill]
2069name = "order-test"
2070backend = "typst"
2071description = "Test field order"
2072
2073[fields]
2074first = {description = "First field"}
2075second = {description = "Second field"}
2076third = {description = "Third field", ui = {group = "Test Group"}}
2077fourth = {description = "Fourth field"}
2078"#;
2079
2080 let config = QuillConfig::from_toml(toml_content).unwrap();
2081
2082 let first = config.fields.get("first").unwrap();
2086 assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2087
2088 let second = config.fields.get("second").unwrap();
2089 assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2090
2091 let third = config.fields.get("third").unwrap();
2092 assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2093 assert_eq!(
2094 third.ui.as_ref().unwrap().group,
2095 Some("Test Group".to_string())
2096 );
2097
2098 let fourth = config.fields.get("fourth").unwrap();
2099 assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2100 }
2101
2102 #[test]
2103 fn test_quill_with_all_ui_properties() {
2104 let toml_content = r#"[Quill]
2105name = "full-ui-test"
2106backend = "typst"
2107description = "Test all UI properties"
2108
2109[fields.author]
2110description = "The full name of the document author"
2111type = "str"
2112
2113[fields.author.ui]
2114group = "Author Info"
2115tooltip = "Your full name"
2116"#;
2117
2118 let config = QuillConfig::from_toml(toml_content).unwrap();
2119
2120 let author_field = &config.fields["author"];
2121 let ui = author_field.ui.as_ref().unwrap();
2122 assert_eq!(ui.group, Some("Author Info".to_string()));
2123 assert_eq!(ui.tooltip, Some("Your full name".to_string()));
2124 assert_eq!(ui.order, Some(0)); }
2126}