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 json_schema_file: Option<String>,
476 pub fields: HashMap<String, FieldSchema>,
478 pub metadata: HashMap<String, QuillValue>,
480 pub typst_config: HashMap<String, QuillValue>,
482}
483
484impl QuillConfig {
485 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
487 let quill_toml: toml::Value = toml::from_str(toml_content)
488 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
489
490 let field_order: Vec<String> = toml_content
492 .parse::<toml_edit::DocumentMut>()
493 .ok()
494 .and_then(|doc| {
495 doc.get("fields")
496 .and_then(|item| item.as_table())
497 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
498 })
499 .unwrap_or_default();
500
501 let quill_section = quill_toml
503 .get("Quill")
504 .ok_or("Missing required [Quill] section in Quill.toml")?;
505
506 let name = quill_section
508 .get("name")
509 .and_then(|v| v.as_str())
510 .ok_or("Missing required 'name' field in [Quill] section")?
511 .to_string();
512
513 let backend = quill_section
514 .get("backend")
515 .and_then(|v| v.as_str())
516 .ok_or("Missing required 'backend' field in [Quill] section")?
517 .to_string();
518
519 let description = quill_section
520 .get("description")
521 .and_then(|v| v.as_str())
522 .ok_or("Missing required 'description' field in [Quill] section")?;
523
524 if description.trim().is_empty() {
525 return Err("'description' field in [Quill] section cannot be empty".into());
526 }
527 let description = description.to_string();
528
529 let version = quill_section
531 .get("version")
532 .and_then(|v| v.as_str())
533 .map(|s| s.to_string());
534
535 let author = quill_section
536 .get("author")
537 .and_then(|v| v.as_str())
538 .map(|s| s.to_string());
539
540 let example_file = quill_section
541 .get("example_file")
542 .and_then(|v| v.as_str())
543 .map(|s| s.to_string());
544
545 let glue_file = quill_section
546 .get("glue_file")
547 .and_then(|v| v.as_str())
548 .map(|s| s.to_string());
549
550 let json_schema_file = quill_section
551 .get("json_schema_file")
552 .and_then(|v| v.as_str())
553 .map(|s| s.to_string());
554
555 let mut metadata = HashMap::new();
557 if let toml::Value::Table(table) = quill_section {
558 for (key, value) in table {
559 if key != "name"
561 && key != "backend"
562 && key != "description"
563 && key != "version"
564 && key != "author"
565 && key != "example_file"
566 && key != "glue_file"
567 && key != "json_schema_file"
568 {
569 match QuillValue::from_toml(value) {
570 Ok(quill_value) => {
571 metadata.insert(key.clone(), quill_value);
572 }
573 Err(e) => {
574 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
575 }
576 }
577 }
578 }
579 }
580
581 let mut typst_config = HashMap::new();
583 if let Some(typst_section) = quill_toml.get("typst") {
584 if let toml::Value::Table(table) = typst_section {
585 for (key, value) in table {
586 match QuillValue::from_toml(value) {
587 Ok(quill_value) => {
588 typst_config.insert(key.clone(), quill_value);
589 }
590 Err(e) => {
591 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
592 }
593 }
594 }
595 }
596 }
597
598 let mut fields = HashMap::new();
600 if let Some(fields_section) = quill_toml.get("fields") {
601 if let toml::Value::Table(fields_table) = fields_section {
602 let mut order_counter = 0;
603 for (field_name, field_schema) in fields_table {
604 let order = if let Some(idx) = field_order.iter().position(|k| k == field_name)
606 {
607 idx as i32
608 } else {
609 let o = field_order.len() as i32 + order_counter;
610 order_counter += 1;
611 o
612 };
613
614 match QuillValue::from_toml(field_schema) {
615 Ok(quill_value) => {
616 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
617 Ok(mut schema) => {
618 if schema.ui.is_none() {
620 schema.ui = Some(UiSchema {
621 group: None,
622 tooltip: None,
623 order: Some(order),
624 });
625 } else if let Some(ui) = &mut schema.ui {
626 ui.order = Some(order);
627 }
628
629 fields.insert(field_name.clone(), schema);
630 }
631 Err(e) => {
632 eprintln!(
633 "Warning: Failed to parse field schema '{}': {}",
634 field_name, e
635 );
636 }
637 }
638 }
639 Err(e) => {
640 eprintln!(
641 "Warning: Failed to convert field schema '{}': {}",
642 field_name, e
643 );
644 }
645 }
646 }
647 }
648 }
649
650 Ok(QuillConfig {
651 name,
652 description,
653 backend,
654 version,
655 author,
656 example_file,
657 glue_file,
658 json_schema_file,
659 fields,
660 metadata,
661 typst_config,
662 })
663 }
664}
665
666impl Quill {
667 pub fn from_path<P: AsRef<std::path::Path>>(
669 path: P,
670 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
671 use std::fs;
672
673 let path = path.as_ref();
674 let name = path
675 .file_name()
676 .and_then(|n| n.to_str())
677 .unwrap_or("unnamed")
678 .to_string();
679
680 let quillignore_path = path.join(".quillignore");
682 let ignore = if quillignore_path.exists() {
683 let ignore_content = fs::read_to_string(&quillignore_path)
684 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
685 QuillIgnore::from_content(&ignore_content)
686 } else {
687 QuillIgnore::new(vec![
689 ".git/".to_string(),
690 ".gitignore".to_string(),
691 ".quillignore".to_string(),
692 "target/".to_string(),
693 "node_modules/".to_string(),
694 ])
695 };
696
697 let root = Self::load_directory_as_tree(path, path, &ignore)?;
699
700 Self::from_tree(root, Some(name))
702 }
703
704 pub fn from_tree(
721 root: FileTreeNode,
722 _default_name: Option<String>,
723 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
724 let quill_toml_bytes = root
726 .get_file("Quill.toml")
727 .ok_or("Quill.toml not found in file tree")?;
728
729 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
730 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
731
732 let config = QuillConfig::from_toml(&quill_toml_content)?;
734
735 Self::from_config(config, root)
737 }
738
739 fn from_config(
757 config: QuillConfig,
758 root: FileTreeNode,
759 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
760 let mut metadata = config.metadata.clone();
762
763 metadata.insert(
765 "backend".to_string(),
766 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
767 );
768
769 metadata.insert(
771 "description".to_string(),
772 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
773 );
774
775 if let Some(ref author) = config.author {
777 metadata.insert(
778 "author".to_string(),
779 QuillValue::from_json(serde_json::Value::String(author.clone())),
780 );
781 }
782
783 for (key, value) in &config.typst_config {
785 metadata.insert(format!("typst_{}", key), value.clone());
786 }
787
788 let schema = if let Some(ref json_schema_path) = config.json_schema_file {
790 let schema_bytes = root.get_file(json_schema_path).ok_or_else(|| {
792 format!(
793 "json_schema_file '{}' not found in file tree",
794 json_schema_path
795 )
796 })?;
797
798 let schema_json =
800 serde_json::from_slice::<serde_json::Value>(schema_bytes).map_err(|e| {
801 format!(
802 "json_schema_file '{}' is not valid JSON: {}",
803 json_schema_path, e
804 )
805 })?;
806
807 if !config.fields.is_empty() {
809 eprintln!("Warning: [fields] section is overridden by json_schema_file");
810 }
811
812 QuillValue::from_json(schema_json)
813 } else {
814 build_schema_from_fields(&config.fields)
816 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?
817 };
818
819 let glue_content: Option<String> = if let Some(ref glue_file_name) = config.glue_file {
821 let glue_bytes = root
822 .get_file(glue_file_name)
823 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
824
825 let content = String::from_utf8(glue_bytes.to_vec())
826 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?;
827 Some(content)
828 } else {
829 None
831 };
832
833 let example_content = if let Some(ref example_file_name) = config.example_file {
835 root.get_file(example_file_name).and_then(|bytes| {
836 String::from_utf8(bytes.to_vec())
837 .map_err(|e| {
838 eprintln!(
839 "Warning: Example file '{}' is not valid UTF-8: {}",
840 example_file_name, e
841 );
842 e
843 })
844 .ok()
845 })
846 } else {
847 None
848 };
849
850 let defaults = crate::schema::extract_defaults_from_schema(&schema);
852 let examples = crate::schema::extract_examples_from_schema(&schema);
853
854 let quill = Quill {
855 metadata,
856 name: config.name,
857 backend: config.backend,
858 glue: glue_content,
859 example: example_content,
860 schema,
861 defaults,
862 examples,
863 files: root,
864 };
865
866 Ok(quill)
867 }
868
869 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
876 use serde_json::Value as JsonValue;
877
878 let json: JsonValue =
879 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
880
881 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
882
883 let default_name = obj
885 .get("metadata")
886 .and_then(|m| m.get("name"))
887 .and_then(|v| v.as_str())
888 .map(String::from);
889
890 let files_obj = obj
892 .get("files")
893 .and_then(|v| v.as_object())
894 .ok_or_else(|| "Missing or invalid 'files' key")?;
895
896 let mut root_files = HashMap::new();
898 for (key, value) in files_obj {
899 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
900 }
901
902 let root = FileTreeNode::Directory { files: root_files };
903
904 Self::from_tree(root, default_name)
906 }
907
908 fn load_directory_as_tree(
910 current_dir: &Path,
911 base_dir: &Path,
912 ignore: &QuillIgnore,
913 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
914 use std::fs;
915
916 if !current_dir.exists() {
917 return Ok(FileTreeNode::Directory {
918 files: HashMap::new(),
919 });
920 }
921
922 let mut files = HashMap::new();
923
924 for entry in fs::read_dir(current_dir)? {
925 let entry = entry?;
926 let path = entry.path();
927 let relative_path = path
928 .strip_prefix(base_dir)
929 .map_err(|e| format!("Failed to get relative path: {}", e))?
930 .to_path_buf();
931
932 if ignore.is_ignored(&relative_path) {
934 continue;
935 }
936
937 let filename = path
939 .file_name()
940 .and_then(|n| n.to_str())
941 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
942 .to_string();
943
944 if path.is_file() {
945 let contents = fs::read(&path)
946 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
947
948 files.insert(filename, FileTreeNode::File { contents });
949 } else if path.is_dir() {
950 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
952 files.insert(filename, subdir_tree);
953 }
954 }
955
956 Ok(FileTreeNode::Directory { files })
957 }
958
959 pub fn typst_packages(&self) -> Vec<String> {
961 self.metadata
962 .get("typst_packages")
963 .and_then(|v| v.as_array())
964 .map(|arr| {
965 arr.iter()
966 .filter_map(|v| v.as_str().map(|s| s.to_string()))
967 .collect()
968 })
969 .unwrap_or_default()
970 }
971
972 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
980 &self.defaults
981 }
982
983 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
988 &self.examples
989 }
990
991 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
993 self.files.get_file(path)
994 }
995
996 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
998 self.files.file_exists(path)
999 }
1000
1001 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1003 self.files.dir_exists(path)
1004 }
1005
1006 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1008 self.files.list_files(path)
1009 }
1010
1011 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1013 self.files.list_subdirectories(path)
1014 }
1015
1016 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1018 let dir_path = dir_path.as_ref();
1019 let filenames = self.files.list_files(dir_path);
1020
1021 filenames
1023 .iter()
1024 .map(|name| {
1025 if dir_path == Path::new("") {
1026 PathBuf::from(name)
1027 } else {
1028 dir_path.join(name)
1029 }
1030 })
1031 .collect()
1032 }
1033
1034 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1036 let dir_path = dir_path.as_ref();
1037 let subdirs = self.files.list_subdirectories(dir_path);
1038
1039 subdirs
1041 .iter()
1042 .map(|name| {
1043 if dir_path == Path::new("") {
1044 PathBuf::from(name)
1045 } else {
1046 dir_path.join(name)
1047 }
1048 })
1049 .collect()
1050 }
1051
1052 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1054 let pattern_str = pattern.as_ref().to_string_lossy();
1055 let mut matches = Vec::new();
1056
1057 let glob_pattern = match glob::Pattern::new(&pattern_str) {
1059 Ok(pat) => pat,
1060 Err(_) => return matches, };
1062
1063 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1065
1066 matches.sort();
1067 matches
1068 }
1069
1070 fn find_files_recursive(
1072 &self,
1073 node: &FileTreeNode,
1074 current_path: &Path,
1075 pattern: &glob::Pattern,
1076 matches: &mut Vec<PathBuf>,
1077 ) {
1078 match node {
1079 FileTreeNode::File { .. } => {
1080 let path_str = current_path.to_string_lossy();
1081 if pattern.matches(&path_str) {
1082 matches.push(current_path.to_path_buf());
1083 }
1084 }
1085 FileTreeNode::Directory { files } => {
1086 for (name, child_node) in files {
1087 let child_path = if current_path == Path::new("") {
1088 PathBuf::from(name)
1089 } else {
1090 current_path.join(name)
1091 };
1092 self.find_files_recursive(child_node, &child_path, pattern, matches);
1093 }
1094 }
1095 }
1096 }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102 use std::fs;
1103 use tempfile::TempDir;
1104
1105 #[test]
1106 fn test_quillignore_parsing() {
1107 let ignore_content = r#"
1108# This is a comment
1109*.tmp
1110target/
1111node_modules/
1112.git/
1113"#;
1114 let ignore = QuillIgnore::from_content(ignore_content);
1115 assert_eq!(ignore.patterns.len(), 4);
1116 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1117 assert!(ignore.patterns.contains(&"target/".to_string()));
1118 }
1119
1120 #[test]
1121 fn test_quillignore_matching() {
1122 let ignore = QuillIgnore::new(vec![
1123 "*.tmp".to_string(),
1124 "target/".to_string(),
1125 "node_modules/".to_string(),
1126 ".git/".to_string(),
1127 ]);
1128
1129 assert!(ignore.is_ignored("test.tmp"));
1131 assert!(ignore.is_ignored("path/to/file.tmp"));
1132 assert!(!ignore.is_ignored("test.txt"));
1133
1134 assert!(ignore.is_ignored("target"));
1136 assert!(ignore.is_ignored("target/debug"));
1137 assert!(ignore.is_ignored("target/debug/deps"));
1138 assert!(!ignore.is_ignored("src/target.rs"));
1139
1140 assert!(ignore.is_ignored("node_modules"));
1141 assert!(ignore.is_ignored("node_modules/package"));
1142 assert!(!ignore.is_ignored("my_node_modules"));
1143 }
1144
1145 #[test]
1146 fn test_in_memory_file_system() {
1147 let temp_dir = TempDir::new().unwrap();
1148 let quill_dir = temp_dir.path();
1149
1150 fs::write(
1152 quill_dir.join("Quill.toml"),
1153 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1154 )
1155 .unwrap();
1156 fs::write(quill_dir.join("glue.typ"), "test glue").unwrap();
1157
1158 let assets_dir = quill_dir.join("assets");
1159 fs::create_dir_all(&assets_dir).unwrap();
1160 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1161
1162 let packages_dir = quill_dir.join("packages");
1163 fs::create_dir_all(&packages_dir).unwrap();
1164 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1165
1166 let quill = Quill::from_path(quill_dir).unwrap();
1168
1169 assert!(quill.file_exists("glue.typ"));
1171 assert!(quill.file_exists("assets/test.txt"));
1172 assert!(quill.file_exists("packages/package.typ"));
1173 assert!(!quill.file_exists("nonexistent.txt"));
1174
1175 let asset_content = quill.get_file("assets/test.txt").unwrap();
1177 assert_eq!(asset_content, b"asset content");
1178
1179 let asset_files = quill.list_directory("assets");
1181 assert_eq!(asset_files.len(), 1);
1182 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1183 }
1184
1185 #[test]
1186 fn test_quillignore_integration() {
1187 let temp_dir = TempDir::new().unwrap();
1188 let quill_dir = temp_dir.path();
1189
1190 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1192
1193 fs::write(
1195 quill_dir.join("Quill.toml"),
1196 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1197 )
1198 .unwrap();
1199 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
1200 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1201
1202 let target_dir = quill_dir.join("target");
1203 fs::create_dir_all(&target_dir).unwrap();
1204 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1205
1206 let quill = Quill::from_path(quill_dir).unwrap();
1208
1209 assert!(quill.file_exists("glue.typ"));
1211 assert!(!quill.file_exists("should_ignore.tmp"));
1212 assert!(!quill.file_exists("target/debug.txt"));
1213 }
1214
1215 #[test]
1216 fn test_find_files_pattern() {
1217 let temp_dir = TempDir::new().unwrap();
1218 let quill_dir = temp_dir.path();
1219
1220 fs::write(
1222 quill_dir.join("Quill.toml"),
1223 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1224 )
1225 .unwrap();
1226 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
1227
1228 let assets_dir = quill_dir.join("assets");
1229 fs::create_dir_all(&assets_dir).unwrap();
1230 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1231 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1232
1233 let fonts_dir = assets_dir.join("fonts");
1234 fs::create_dir_all(&fonts_dir).unwrap();
1235 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1236
1237 let quill = Quill::from_path(quill_dir).unwrap();
1239
1240 let all_assets = quill.find_files("assets/*");
1242 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1245 assert_eq!(typ_files.len(), 1);
1246 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1247 }
1248
1249 #[test]
1250 fn test_new_standardized_toml_format() {
1251 let temp_dir = TempDir::new().unwrap();
1252 let quill_dir = temp_dir.path();
1253
1254 let toml_content = r#"[Quill]
1256name = "my-custom-quill"
1257backend = "typst"
1258glue_file = "custom_glue.typ"
1259description = "Test quill with new format"
1260author = "Test Author"
1261"#;
1262 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1263 fs::write(
1264 quill_dir.join("custom_glue.typ"),
1265 "= Custom Template\n\nThis is a custom template.",
1266 )
1267 .unwrap();
1268
1269 let quill = Quill::from_path(quill_dir).unwrap();
1271
1272 assert_eq!(quill.name, "my-custom-quill");
1274
1275 assert!(quill.metadata.contains_key("backend"));
1277 if let Some(backend_val) = quill.metadata.get("backend") {
1278 if let Some(backend_str) = backend_val.as_str() {
1279 assert_eq!(backend_str, "typst");
1280 } else {
1281 panic!("Backend value is not a string");
1282 }
1283 }
1284
1285 assert!(quill.metadata.contains_key("description"));
1287 assert!(quill.metadata.contains_key("author"));
1288 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue.unwrap().contains("Custom Template"));
1292 }
1293
1294 #[test]
1295 fn test_typst_packages_parsing() {
1296 let temp_dir = TempDir::new().unwrap();
1297 let quill_dir = temp_dir.path();
1298
1299 let toml_content = r#"
1300[Quill]
1301name = "test-quill"
1302backend = "typst"
1303glue_file = "glue.typ"
1304description = "Test quill for packages"
1305
1306[typst]
1307packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1308"#;
1309
1310 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1311 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1312
1313 let quill = Quill::from_path(quill_dir).unwrap();
1314 let packages = quill.typst_packages();
1315
1316 assert_eq!(packages.len(), 2);
1317 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1318 assert_eq!(packages[1], "@preview/example:1.0.0");
1319 }
1320
1321 #[test]
1322 fn test_template_loading() {
1323 let temp_dir = TempDir::new().unwrap();
1324 let quill_dir = temp_dir.path();
1325
1326 let toml_content = r#"[Quill]
1328name = "test-with-template"
1329backend = "typst"
1330glue_file = "glue.typ"
1331example_file = "example.md"
1332description = "Test quill with template"
1333"#;
1334 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1335 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1336 fs::write(
1337 quill_dir.join("example.md"),
1338 "---\ntitle: Test\n---\n\nThis is a test template.",
1339 )
1340 .unwrap();
1341
1342 let quill = Quill::from_path(quill_dir).unwrap();
1344
1345 assert!(quill.example.is_some());
1347 let example = quill.example.unwrap();
1348 assert!(example.contains("title: Test"));
1349 assert!(example.contains("This is a test template"));
1350
1351 assert_eq!(quill.glue.unwrap(), "glue content");
1353 }
1354
1355 #[test]
1356 fn test_template_optional() {
1357 let temp_dir = TempDir::new().unwrap();
1358 let quill_dir = temp_dir.path();
1359
1360 let toml_content = r#"[Quill]
1362name = "test-without-template"
1363backend = "typst"
1364glue_file = "glue.typ"
1365description = "Test quill without template"
1366"#;
1367 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1368 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1369
1370 let quill = Quill::from_path(quill_dir).unwrap();
1372
1373 assert_eq!(quill.example, None);
1375
1376 assert_eq!(quill.glue.unwrap(), "glue content");
1378 }
1379
1380 #[test]
1381 fn test_from_tree() {
1382 let mut root_files = HashMap::new();
1384
1385 let quill_toml = r#"[Quill]
1387name = "test-from-tree"
1388backend = "typst"
1389glue_file = "glue.typ"
1390description = "A test quill from tree"
1391"#;
1392 root_files.insert(
1393 "Quill.toml".to_string(),
1394 FileTreeNode::File {
1395 contents: quill_toml.as_bytes().to_vec(),
1396 },
1397 );
1398
1399 let glue_content = "= Test Template\n\nThis is a test.";
1401 root_files.insert(
1402 "glue.typ".to_string(),
1403 FileTreeNode::File {
1404 contents: glue_content.as_bytes().to_vec(),
1405 },
1406 );
1407
1408 let root = FileTreeNode::Directory { files: root_files };
1409
1410 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1412
1413 assert_eq!(quill.name, "test-from-tree");
1415 assert_eq!(quill.glue.unwrap(), glue_content);
1416 assert!(quill.metadata.contains_key("backend"));
1417 assert!(quill.metadata.contains_key("description"));
1418 }
1419
1420 #[test]
1421 fn test_from_tree_with_template() {
1422 let mut root_files = HashMap::new();
1423
1424 let quill_toml = r#"[Quill]
1426name = "test-tree-template"
1427backend = "typst"
1428glue_file = "glue.typ"
1429example_file = "template.md"
1430description = "Test tree with template"
1431"#;
1432 root_files.insert(
1433 "Quill.toml".to_string(),
1434 FileTreeNode::File {
1435 contents: quill_toml.as_bytes().to_vec(),
1436 },
1437 );
1438
1439 root_files.insert(
1441 "glue.typ".to_string(),
1442 FileTreeNode::File {
1443 contents: b"glue content".to_vec(),
1444 },
1445 );
1446
1447 let template_content = "# {{ title }}\n\n{{ body }}";
1449 root_files.insert(
1450 "template.md".to_string(),
1451 FileTreeNode::File {
1452 contents: template_content.as_bytes().to_vec(),
1453 },
1454 );
1455
1456 let root = FileTreeNode::Directory { files: root_files };
1457
1458 let quill = Quill::from_tree(root, None).unwrap();
1460
1461 assert_eq!(quill.example, Some(template_content.to_string()));
1463 }
1464
1465 #[test]
1466 fn test_from_json() {
1467 let json_str = r#"{
1469 "metadata": {
1470 "name": "test-from-json"
1471 },
1472 "files": {
1473 "Quill.toml": {
1474 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1475 },
1476 "glue.typ": {
1477 "contents": "= Test Glue\n\nThis is test content."
1478 }
1479 }
1480 }"#;
1481
1482 let quill = Quill::from_json(json_str).unwrap();
1484
1485 assert_eq!(quill.name, "test-from-json");
1487 assert!(quill.glue.unwrap().contains("Test Glue"));
1488 assert!(quill.metadata.contains_key("backend"));
1489 }
1490
1491 #[test]
1492 fn test_from_json_with_byte_array() {
1493 let json_str = r#"{
1495 "files": {
1496 "Quill.toml": {
1497 "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]
1498 },
1499 "glue.typ": {
1500 "contents": "test glue"
1501 }
1502 }
1503 }"#;
1504
1505 let quill = Quill::from_json(json_str).unwrap();
1507
1508 assert_eq!(quill.name, "test");
1510 assert_eq!(quill.glue.unwrap(), "test glue");
1511 }
1512
1513 #[test]
1514 fn test_from_json_missing_files() {
1515 let json_str = r#"{
1517 "metadata": {
1518 "name": "test"
1519 }
1520 }"#;
1521
1522 let result = Quill::from_json(json_str);
1523 assert!(result.is_err());
1524 assert!(result.unwrap_err().to_string().contains("files"));
1526 }
1527
1528 #[test]
1529 fn test_from_json_tree_structure() {
1530 let json_str = r#"{
1532 "files": {
1533 "Quill.toml": {
1534 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1535 },
1536 "glue.typ": {
1537 "contents": "= Test Glue\n\nTree structure content."
1538 }
1539 }
1540 }"#;
1541
1542 let quill = Quill::from_json(json_str).unwrap();
1543
1544 assert_eq!(quill.name, "test-tree-json");
1545 assert!(quill.glue.unwrap().contains("Tree structure content"));
1546 assert!(quill.metadata.contains_key("backend"));
1547 }
1548
1549 #[test]
1550 fn test_from_json_nested_tree_structure() {
1551 let json_str = r#"{
1553 "files": {
1554 "Quill.toml": {
1555 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Nested test\"\n"
1556 },
1557 "glue.typ": {
1558 "contents": "glue"
1559 },
1560 "src": {
1561 "main.rs": {
1562 "contents": "fn main() {}"
1563 },
1564 "lib.rs": {
1565 "contents": "// lib"
1566 }
1567 }
1568 }
1569 }"#;
1570
1571 let quill = Quill::from_json(json_str).unwrap();
1572
1573 assert_eq!(quill.name, "nested-test");
1574 assert!(quill.file_exists("src/main.rs"));
1576 assert!(quill.file_exists("src/lib.rs"));
1577
1578 let main_rs = quill.get_file("src/main.rs").unwrap();
1579 assert_eq!(main_rs, b"fn main() {}");
1580 }
1581
1582 #[test]
1583 fn test_from_tree_structure_direct() {
1584 let mut root_files = HashMap::new();
1586
1587 root_files.insert(
1588 "Quill.toml".to_string(),
1589 FileTreeNode::File {
1590 contents:
1591 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1592 .to_vec(),
1593 },
1594 );
1595
1596 root_files.insert(
1597 "glue.typ".to_string(),
1598 FileTreeNode::File {
1599 contents: b"glue content".to_vec(),
1600 },
1601 );
1602
1603 let mut src_files = HashMap::new();
1605 src_files.insert(
1606 "main.rs".to_string(),
1607 FileTreeNode::File {
1608 contents: b"fn main() {}".to_vec(),
1609 },
1610 );
1611
1612 root_files.insert(
1613 "src".to_string(),
1614 FileTreeNode::Directory { files: src_files },
1615 );
1616
1617 let root = FileTreeNode::Directory { files: root_files };
1618
1619 let quill = Quill::from_tree(root, None).unwrap();
1620
1621 assert_eq!(quill.name, "direct-tree");
1622 assert!(quill.file_exists("src/main.rs"));
1623 assert!(quill.file_exists("glue.typ"));
1624 }
1625
1626 #[test]
1627 fn test_from_json_with_metadata_override() {
1628 let json_str = r#"{
1630 "metadata": {
1631 "name": "override-name"
1632 },
1633 "files": {
1634 "Quill.toml": {
1635 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1636 },
1637 "glue.typ": {
1638 "contents": "= glue"
1639 }
1640 }
1641 }"#;
1642
1643 let quill = Quill::from_json(json_str).unwrap();
1644 assert_eq!(quill.name, "toml-name");
1647 }
1648
1649 #[test]
1650 fn test_from_json_empty_directory() {
1651 let json_str = r#"{
1653 "files": {
1654 "Quill.toml": {
1655 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1656 },
1657 "glue.typ": {
1658 "contents": "glue"
1659 },
1660 "empty_dir": {}
1661 }
1662 }"#;
1663
1664 let quill = Quill::from_json(json_str).unwrap();
1665 assert_eq!(quill.name, "empty-dir-test");
1666 assert!(quill.dir_exists("empty_dir"));
1667 assert!(!quill.file_exists("empty_dir"));
1668 }
1669
1670 #[test]
1671 fn test_dir_exists_and_list_apis() {
1672 let mut root_files = HashMap::new();
1673
1674 root_files.insert(
1676 "Quill.toml".to_string(),
1677 FileTreeNode::File {
1678 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"\n"
1679 .to_vec(),
1680 },
1681 );
1682
1683 root_files.insert(
1685 "glue.typ".to_string(),
1686 FileTreeNode::File {
1687 contents: b"glue content".to_vec(),
1688 },
1689 );
1690
1691 let mut assets_files = HashMap::new();
1693 assets_files.insert(
1694 "logo.png".to_string(),
1695 FileTreeNode::File {
1696 contents: vec![137, 80, 78, 71],
1697 },
1698 );
1699 assets_files.insert(
1700 "icon.svg".to_string(),
1701 FileTreeNode::File {
1702 contents: b"<svg></svg>".to_vec(),
1703 },
1704 );
1705
1706 let mut fonts_files = HashMap::new();
1708 fonts_files.insert(
1709 "font.ttf".to_string(),
1710 FileTreeNode::File {
1711 contents: b"font data".to_vec(),
1712 },
1713 );
1714 assets_files.insert(
1715 "fonts".to_string(),
1716 FileTreeNode::Directory { files: fonts_files },
1717 );
1718
1719 root_files.insert(
1720 "assets".to_string(),
1721 FileTreeNode::Directory {
1722 files: assets_files,
1723 },
1724 );
1725
1726 root_files.insert(
1728 "empty".to_string(),
1729 FileTreeNode::Directory {
1730 files: HashMap::new(),
1731 },
1732 );
1733
1734 let root = FileTreeNode::Directory { files: root_files };
1735 let quill = Quill::from_tree(root, None).unwrap();
1736
1737 assert!(quill.dir_exists("assets"));
1739 assert!(quill.dir_exists("assets/fonts"));
1740 assert!(quill.dir_exists("empty"));
1741 assert!(!quill.dir_exists("nonexistent"));
1742 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1746 assert!(quill.file_exists("assets/logo.png"));
1747 assert!(quill.file_exists("assets/fonts/font.ttf"));
1748 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1752 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1754 assert!(root_files_list.contains(&"glue.typ".to_string()));
1755
1756 let assets_files_list = quill.list_files("assets");
1757 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1759 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1760
1761 let root_subdirs = quill.list_subdirectories("");
1763 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1765 assert!(root_subdirs.contains(&"empty".to_string()));
1766
1767 let assets_subdirs = quill.list_subdirectories("assets");
1768 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1770
1771 let empty_subdirs = quill.list_subdirectories("empty");
1772 assert_eq!(empty_subdirs.len(), 0);
1773 }
1774
1775 #[test]
1776 fn test_field_schemas_parsing() {
1777 let mut root_files = HashMap::new();
1778
1779 let quill_toml = r#"[Quill]
1781name = "taro"
1782backend = "typst"
1783glue_file = "glue.typ"
1784example_file = "taro.md"
1785description = "Test template for field schemas"
1786
1787[fields]
1788author = {description = "Author of document" }
1789ice_cream = {description = "favorite ice cream flavor"}
1790title = {description = "title of document" }
1791"#;
1792 root_files.insert(
1793 "Quill.toml".to_string(),
1794 FileTreeNode::File {
1795 contents: quill_toml.as_bytes().to_vec(),
1796 },
1797 );
1798
1799 let glue_content = "= Test Template\n\nThis is a test.";
1801 root_files.insert(
1802 "glue.typ".to_string(),
1803 FileTreeNode::File {
1804 contents: glue_content.as_bytes().to_vec(),
1805 },
1806 );
1807
1808 root_files.insert(
1810 "taro.md".to_string(),
1811 FileTreeNode::File {
1812 contents: b"# Template".to_vec(),
1813 },
1814 );
1815
1816 let root = FileTreeNode::Directory { files: root_files };
1817
1818 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1820
1821 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1823 assert!(quill.schema["properties"]
1824 .as_object()
1825 .unwrap()
1826 .contains_key("author"));
1827 assert!(quill.schema["properties"]
1828 .as_object()
1829 .unwrap()
1830 .contains_key("ice_cream"));
1831 assert!(quill.schema["properties"]
1832 .as_object()
1833 .unwrap()
1834 .contains_key("title"));
1835
1836 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1838 assert_eq!(author_schema["description"], "Author of document");
1839
1840 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1842 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1843
1844 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1846 assert_eq!(title_schema["description"], "title of document");
1847 }
1848
1849 #[test]
1850 fn test_field_schema_struct() {
1851 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1853 assert_eq!(schema1.description, "Test description");
1854 assert_eq!(schema1.r#type, None);
1855 assert_eq!(schema1.example, None);
1856 assert_eq!(schema1.default, None);
1857
1858 let yaml_str = r#"
1860description: "Full field schema"
1861type: "string"
1862example: "Example value"
1863default: "Default value"
1864"#;
1865 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1866 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1867 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1868 assert_eq!(schema2.name, "test_name");
1869 assert_eq!(schema2.description, "Full field schema");
1870 assert_eq!(schema2.r#type, Some("string".to_string()));
1871 assert_eq!(
1872 schema2.example.as_ref().and_then(|v| v.as_str()),
1873 Some("Example value")
1874 );
1875 assert_eq!(
1876 schema2.default.as_ref().and_then(|v| v.as_str()),
1877 Some("Default value")
1878 );
1879 }
1880
1881 #[test]
1882 fn test_quill_without_glue_file() {
1883 let mut root_files = HashMap::new();
1885
1886 let quill_toml = r#"[Quill]
1888name = "test-no-glue"
1889backend = "typst"
1890description = "Test quill without glue file"
1891"#;
1892 root_files.insert(
1893 "Quill.toml".to_string(),
1894 FileTreeNode::File {
1895 contents: quill_toml.as_bytes().to_vec(),
1896 },
1897 );
1898
1899 let root = FileTreeNode::Directory { files: root_files };
1900
1901 let quill = Quill::from_tree(root, None).unwrap();
1903
1904 assert!(quill.glue.clone().is_none());
1906 assert_eq!(quill.name, "test-no-glue");
1907 }
1908
1909 #[test]
1910 fn test_quill_config_from_toml() {
1911 let toml_content = r#"[Quill]
1913name = "test-config"
1914backend = "typst"
1915description = "Test configuration parsing"
1916version = "1.0.0"
1917author = "Test Author"
1918glue_file = "glue.typ"
1919example_file = "example.md"
1920
1921[typst]
1922packages = ["@preview/bubble:0.2.2"]
1923
1924[fields]
1925title = {description = "Document title", type = "string"}
1926author = {description = "Document author"}
1927"#;
1928
1929 let config = QuillConfig::from_toml(toml_content).unwrap();
1930
1931 assert_eq!(config.name, "test-config");
1933 assert_eq!(config.backend, "typst");
1934 assert_eq!(config.description, "Test configuration parsing");
1935
1936 assert_eq!(config.version, Some("1.0.0".to_string()));
1938 assert_eq!(config.author, Some("Test Author".to_string()));
1939 assert_eq!(config.glue_file, Some("glue.typ".to_string()));
1940 assert_eq!(config.example_file, Some("example.md".to_string()));
1941
1942 assert!(config.typst_config.contains_key("packages"));
1944
1945 assert_eq!(config.fields.len(), 2);
1947 assert!(config.fields.contains_key("title"));
1948 assert!(config.fields.contains_key("author"));
1949
1950 let title_field = &config.fields["title"];
1951 assert_eq!(title_field.description, "Document title");
1952 assert_eq!(title_field.r#type, Some("string".to_string()));
1953 }
1954
1955 #[test]
1956 fn test_quill_config_missing_required_fields() {
1957 let toml_missing_name = r#"[Quill]
1959backend = "typst"
1960description = "Missing name"
1961"#;
1962 let result = QuillConfig::from_toml(toml_missing_name);
1963 assert!(result.is_err());
1964 assert!(result
1965 .unwrap_err()
1966 .to_string()
1967 .contains("Missing required 'name'"));
1968
1969 let toml_missing_backend = r#"[Quill]
1970name = "test"
1971description = "Missing backend"
1972"#;
1973 let result = QuillConfig::from_toml(toml_missing_backend);
1974 assert!(result.is_err());
1975 assert!(result
1976 .unwrap_err()
1977 .to_string()
1978 .contains("Missing required 'backend'"));
1979
1980 let toml_missing_description = r#"[Quill]
1981name = "test"
1982backend = "typst"
1983"#;
1984 let result = QuillConfig::from_toml(toml_missing_description);
1985 assert!(result.is_err());
1986 assert!(result
1987 .unwrap_err()
1988 .to_string()
1989 .contains("Missing required 'description'"));
1990 }
1991
1992 #[test]
1993 fn test_quill_config_empty_description() {
1994 let toml_empty_description = r#"[Quill]
1996name = "test"
1997backend = "typst"
1998description = " "
1999"#;
2000 let result = QuillConfig::from_toml(toml_empty_description);
2001 assert!(result.is_err());
2002 assert!(result
2003 .unwrap_err()
2004 .to_string()
2005 .contains("description' field in [Quill] section cannot be empty"));
2006 }
2007
2008 #[test]
2009 fn test_quill_config_missing_quill_section() {
2010 let toml_no_section = r#"[fields]
2012title = {description = "Title"}
2013"#;
2014 let result = QuillConfig::from_toml(toml_no_section);
2015 assert!(result.is_err());
2016 assert!(result
2017 .unwrap_err()
2018 .to_string()
2019 .contains("Missing required [Quill] section"));
2020 }
2021
2022 #[test]
2023 fn test_quill_from_config_metadata() {
2024 let mut root_files = HashMap::new();
2026
2027 let quill_toml = r#"[Quill]
2028name = "metadata-test"
2029backend = "typst"
2030description = "Test metadata flow"
2031author = "Test Author"
2032custom_field = "custom_value"
2033
2034[typst]
2035packages = ["@preview/bubble:0.2.2"]
2036"#;
2037 root_files.insert(
2038 "Quill.toml".to_string(),
2039 FileTreeNode::File {
2040 contents: quill_toml.as_bytes().to_vec(),
2041 },
2042 );
2043
2044 let root = FileTreeNode::Directory { files: root_files };
2045 let quill = Quill::from_tree(root, None).unwrap();
2046
2047 assert!(quill.metadata.contains_key("backend"));
2049 assert!(quill.metadata.contains_key("description"));
2050 assert!(quill.metadata.contains_key("author"));
2051
2052 assert!(quill.metadata.contains_key("custom_field"));
2054 assert_eq!(
2055 quill.metadata.get("custom_field").unwrap().as_str(),
2056 Some("custom_value")
2057 );
2058
2059 assert!(quill.metadata.contains_key("typst_packages"));
2061 }
2062
2063 #[test]
2064 fn test_json_schema_file_override() {
2065 let mut root_files = HashMap::new();
2067
2068 let custom_schema = r#"{
2070 "$schema": "https://json-schema.org/draft/2019-09/schema",
2071 "type": "object",
2072 "properties": {
2073 "title": {
2074 "type": "string",
2075 "description": "Document title"
2076 },
2077 "author": {
2078 "type": "string",
2079 "description": "Document author",
2080 "default": "Schema Author"
2081 },
2082 "version": {
2083 "type": "number",
2084 "description": "Version number",
2085 "default": 2
2086 }
2087 },
2088 "required": ["title"]
2089 }"#;
2090
2091 root_files.insert(
2092 "schema.json".to_string(),
2093 FileTreeNode::File {
2094 contents: custom_schema.as_bytes().to_vec(),
2095 },
2096 );
2097
2098 let quill_toml = r#"[Quill]
2099name = "schema-file-test"
2100backend = "typst"
2101description = "Test json_schema_file"
2102json_schema_file = "schema.json"
2103
2104[fields]
2105author = {description = "This should be ignored", default = "Fields Author"}
2106status = {description = "This should also be ignored"}
2107"#;
2108
2109 root_files.insert(
2110 "Quill.toml".to_string(),
2111 FileTreeNode::File {
2112 contents: quill_toml.as_bytes().to_vec(),
2113 },
2114 );
2115
2116 let root = FileTreeNode::Directory { files: root_files };
2117 let quill = Quill::from_tree(root, None).unwrap();
2118
2119 let properties = quill.schema["properties"].as_object().unwrap();
2121 assert_eq!(properties.len(), 3); assert!(properties.contains_key("title"));
2123 assert!(properties.contains_key("author"));
2124 assert!(properties.contains_key("version"));
2125 assert!(!properties.contains_key("status")); let defaults = quill.extract_defaults();
2129 assert_eq!(defaults.len(), 2); assert_eq!(
2131 defaults.get("author").unwrap().as_str(),
2132 Some("Schema Author")
2133 );
2134 assert_eq!(defaults.get("version").unwrap().as_json().as_i64(), Some(2));
2135
2136 let required = quill.schema["required"].as_array().unwrap();
2138 assert_eq!(required.len(), 1);
2139 assert!(required.contains(&serde_json::json!("title")));
2140 }
2141
2142 #[test]
2143 fn test_extract_defaults_method() {
2144 let mut root_files = HashMap::new();
2146
2147 let quill_toml = r#"[Quill]
2148name = "defaults-test"
2149backend = "typst"
2150description = "Test defaults extraction"
2151
2152[fields]
2153title = {description = "Title"}
2154author = {description = "Author", default = "Anonymous"}
2155status = {description = "Status", default = "draft"}
2156"#;
2157
2158 root_files.insert(
2159 "Quill.toml".to_string(),
2160 FileTreeNode::File {
2161 contents: quill_toml.as_bytes().to_vec(),
2162 },
2163 );
2164
2165 let root = FileTreeNode::Directory { files: root_files };
2166 let quill = Quill::from_tree(root, None).unwrap();
2167
2168 let defaults = quill.extract_defaults();
2170
2171 assert_eq!(defaults.len(), 2);
2173 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2175 assert!(defaults.contains_key("status"));
2176
2177 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2179 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2180 }
2181
2182 #[test]
2183 fn test_field_order_preservation() {
2184 let toml_content = r#"[Quill]
2185name = "order-test"
2186backend = "typst"
2187description = "Test field order"
2188
2189[fields]
2190first = {description = "First field"}
2191second = {description = "Second field"}
2192third = {description = "Third field", ui = {group = "Test Group"}}
2193fourth = {description = "Fourth field"}
2194"#;
2195
2196 let config = QuillConfig::from_toml(toml_content).unwrap();
2197
2198 let first = config.fields.get("first").unwrap();
2202 assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2203
2204 let second = config.fields.get("second").unwrap();
2205 assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2206
2207 let third = config.fields.get("third").unwrap();
2208 assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2209 assert_eq!(
2210 third.ui.as_ref().unwrap().group,
2211 Some("Test Group".to_string())
2212 );
2213
2214 let fourth = config.fields.get("fourth").unwrap();
2215 assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2216 }
2217}