1use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use crate::value::QuillValue;
8
9pub mod field_key {
13 pub const NAME: &str = "name";
15 pub const TITLE: &str = "title";
17 pub const TYPE: &str = "type";
19 pub const DESCRIPTION: &str = "description";
21 pub const DEFAULT: &str = "default";
23 pub const EXAMPLES: &str = "examples";
25 pub const UI: &str = "ui";
27 pub const REQUIRED: &str = "required";
29 pub const ENUM: &str = "enum";
31 pub const FORMAT: &str = "format";
33}
34
35pub mod ui_key {
37 pub const GROUP: &str = "group";
39 pub const ORDER: &str = "order";
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct UiSchema {
46 pub group: Option<String>,
48 pub order: Option<i32>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct CardSchema {
55 pub name: String,
57 pub title: Option<String>,
59 pub description: String,
61 pub ui: Option<UiSchema>,
63 pub fields: HashMap<String, FieldSchema>,
65}
66
67#[derive(Debug, Clone, PartialEq)]
69pub enum FieldType {
70 String,
72 Str,
74 Number,
76 Boolean,
78 Array,
80 Dict,
82 Date,
84 DateTime,
86}
87
88impl FieldType {
89 pub fn from_str(s: &str) -> Option<Self> {
91 match s {
92 "string" => Some(FieldType::String),
93 "str" => Some(FieldType::Str),
94 "number" => Some(FieldType::Number),
95 "boolean" => Some(FieldType::Boolean),
96 "array" => Some(FieldType::Array),
97 "dict" => Some(FieldType::Dict),
98 "date" => Some(FieldType::Date),
99 "datetime" => Some(FieldType::DateTime),
100 _ => None,
101 }
102 }
103
104 pub fn as_str(&self) -> &'static str {
106 match self {
107 FieldType::String => "string",
108 FieldType::Str => "str",
109 FieldType::Number => "number",
110 FieldType::Boolean => "boolean",
111 FieldType::Array => "array",
112 FieldType::Dict => "dict",
113 FieldType::Date => "date",
114 FieldType::DateTime => "datetime",
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct FieldSchema {
122 pub name: String,
123 pub title: Option<String>,
125 pub r#type: Option<FieldType>,
127 pub description: String,
129 pub default: Option<QuillValue>,
131 pub examples: Option<QuillValue>,
133 pub ui: Option<UiSchema>,
135 pub required: bool,
137 pub enum_values: Option<Vec<String>>,
139}
140
141impl FieldSchema {
142 pub fn new(name: String, description: String) -> Self {
144 Self {
145 name,
146 title: None,
147 r#type: None,
148 description,
149 default: None,
150 examples: None,
151 ui: None,
152 required: false,
153 enum_values: None,
154 }
155 }
156
157 pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
159 let obj = value
160 .as_object()
161 .ok_or_else(|| "Field schema must be an object".to_string())?;
162
163 for key in obj.keys() {
165 match key.as_str() {
166 field_key::NAME
167 | field_key::TITLE
168 | field_key::TYPE
169 | field_key::DESCRIPTION
170 | field_key::EXAMPLES
171 | field_key::DEFAULT
172 | field_key::UI
173 | field_key::REQUIRED
174 | field_key::ENUM => {}
175 _ => {
176 eprintln!("Warning: Unknown key '{}' in field schema", key);
178 }
179 }
180 }
181
182 let name = key.clone();
183
184 let title = obj
185 .get(field_key::TITLE)
186 .and_then(|v| v.as_str())
187 .map(|s| s.to_string());
188
189 let description = obj
190 .get(field_key::DESCRIPTION)
191 .and_then(|v| v.as_str())
192 .unwrap_or("")
193 .to_string();
194
195 let field_type = obj
196 .get(field_key::TYPE)
197 .and_then(|v| v.as_str())
198 .and_then(|s| {
199 let parsed = FieldType::from_str(s);
200 if parsed.is_none() {
201 eprintln!("Warning: Unknown field type '{}', ignoring", s);
202 }
203 parsed
204 });
205
206 let default = obj
207 .get(field_key::DEFAULT)
208 .map(|v| QuillValue::from_json(v.clone()));
209
210 let examples = obj
211 .get(field_key::EXAMPLES)
212 .map(|v| QuillValue::from_json(v.clone()));
213
214 let required = obj
216 .get(field_key::REQUIRED)
217 .and_then(|v| v.as_bool())
218 .unwrap_or(false);
219
220 let enum_values = obj
222 .get(field_key::ENUM)
223 .and_then(|v| v.as_array())
224 .map(|arr| {
225 arr.iter()
226 .filter_map(|v| v.as_str().map(|s| s.to_string()))
227 .collect::<Vec<String>>()
228 })
229 .filter(|v| !v.is_empty());
230
231 let ui = if let Some(ui_value) = obj.get(field_key::UI) {
233 if let Some(ui_obj) = ui_value.as_object() {
234 let group = ui_obj
235 .get(ui_key::GROUP)
236 .and_then(|v| v.as_str())
237 .map(|s| s.to_string());
238
239 for key in ui_obj.keys() {
241 match key.as_str() {
242 ui_key::GROUP => {}
243 _ => {
244 eprintln!(
245 "Warning: Unknown UI property '{}'. Only 'group' is supported.",
246 key
247 );
248 }
249 }
250 }
251
252 Some(UiSchema {
253 group,
254 order: None, })
256 } else {
257 return Err("UI field must be an object".to_string());
258 }
259 } else {
260 None
261 };
262
263 Ok(Self {
264 name,
265 title,
266 r#type: field_type,
267 description,
268 default,
269 examples,
270 ui,
271 required,
272 enum_values,
273 })
274 }
275}
276
277#[derive(Debug, Clone)]
279pub enum FileTreeNode {
280 File {
282 contents: Vec<u8>,
284 },
285 Directory {
287 files: HashMap<String, FileTreeNode>,
289 },
290}
291
292impl FileTreeNode {
293 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
295 let path = path.as_ref();
296
297 if path == Path::new("") {
299 return Some(self);
300 }
301
302 let components: Vec<_> = path
304 .components()
305 .filter_map(|c| {
306 if let std::path::Component::Normal(s) = c {
307 s.to_str()
308 } else {
309 None
310 }
311 })
312 .collect();
313
314 if components.is_empty() {
315 return Some(self);
316 }
317
318 let mut current_node = self;
320 for component in components {
321 match current_node {
322 FileTreeNode::Directory { files } => {
323 current_node = files.get(component)?;
324 }
325 FileTreeNode::File { .. } => {
326 return None; }
328 }
329 }
330
331 Some(current_node)
332 }
333
334 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
336 match self.get_node(path)? {
337 FileTreeNode::File { contents } => Some(contents.as_slice()),
338 FileTreeNode::Directory { .. } => None,
339 }
340 }
341
342 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
344 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
345 }
346
347 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
349 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
350 }
351
352 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
354 match self.get_node(dir_path) {
355 Some(FileTreeNode::Directory { files }) => files
356 .iter()
357 .filter_map(|(name, node)| {
358 if matches!(node, FileTreeNode::File { .. }) {
359 Some(name.clone())
360 } else {
361 None
362 }
363 })
364 .collect(),
365 _ => Vec::new(),
366 }
367 }
368
369 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
371 match self.get_node(dir_path) {
372 Some(FileTreeNode::Directory { files }) => files
373 .iter()
374 .filter_map(|(name, node)| {
375 if matches!(node, FileTreeNode::Directory { .. }) {
376 Some(name.clone())
377 } else {
378 None
379 }
380 })
381 .collect(),
382 _ => Vec::new(),
383 }
384 }
385
386 pub fn insert<P: AsRef<Path>>(
388 &mut self,
389 path: P,
390 node: FileTreeNode,
391 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
392 let path = path.as_ref();
393
394 let components: Vec<_> = path
396 .components()
397 .filter_map(|c| {
398 if let std::path::Component::Normal(s) = c {
399 s.to_str().map(|s| s.to_string())
400 } else {
401 None
402 }
403 })
404 .collect();
405
406 if components.is_empty() {
407 return Err("Cannot insert at root path".into());
408 }
409
410 let mut current_node = self;
412 for component in &components[..components.len() - 1] {
413 match current_node {
414 FileTreeNode::Directory { files } => {
415 current_node =
416 files
417 .entry(component.clone())
418 .or_insert_with(|| FileTreeNode::Directory {
419 files: HashMap::new(),
420 });
421 }
422 FileTreeNode::File { .. } => {
423 return Err("Cannot traverse into a file".into());
424 }
425 }
426 }
427
428 let filename = &components[components.len() - 1];
430 match current_node {
431 FileTreeNode::Directory { files } => {
432 files.insert(filename.clone(), node);
433 Ok(())
434 }
435 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
436 }
437 }
438
439 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
441 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
442 Ok(FileTreeNode::File {
444 contents: contents_str.as_bytes().to_vec(),
445 })
446 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
447 let contents: Vec<u8> = bytes_array
449 .iter()
450 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
451 .collect();
452 Ok(FileTreeNode::File { contents })
453 } else if let Some(obj) = value.as_object() {
454 let mut files = HashMap::new();
456 for (name, child_value) in obj {
457 files.insert(name.clone(), Self::from_json_value(child_value)?);
458 }
459 Ok(FileTreeNode::Directory { files })
461 } else {
462 Err(format!("Invalid file tree node: {:?}", value).into())
463 }
464 }
465
466 pub fn print_tree(&self) -> String {
467 self.__print_tree("", "", true)
468 }
469
470 pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
471 let mut result = String::new();
472
473 let connector = if is_last { "└── " } else { "├── " };
475 let extension = if is_last { " " } else { "│ " };
476
477 match self {
478 FileTreeNode::File { .. } => {
479 result.push_str(&format!("{}{}{}\n", prefix, connector, name));
480 }
481 FileTreeNode::Directory { files } => {
482 result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
484
485 let child_prefix = format!("{}{}", prefix, extension);
486 let count = files.len();
487
488 for (i, (child_name, node)) in files.iter().enumerate() {
489 let is_last_child = i == count - 1;
490 result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
491 }
492 }
493 }
494
495 result
496 }
497}
498
499#[derive(Debug, Clone)]
501pub struct QuillIgnore {
502 patterns: Vec<String>,
503}
504
505impl QuillIgnore {
506 pub fn new(patterns: Vec<String>) -> Self {
508 Self { patterns }
509 }
510
511 pub fn from_content(content: &str) -> Self {
513 let patterns = content
514 .lines()
515 .map(|line| line.trim())
516 .filter(|line| !line.is_empty() && !line.starts_with('#'))
517 .map(|line| line.to_string())
518 .collect();
519 Self::new(patterns)
520 }
521
522 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
524 let path = path.as_ref();
525 let path_str = path.to_string_lossy();
526
527 for pattern in &self.patterns {
528 if self.matches_pattern(pattern, &path_str) {
529 return true;
530 }
531 }
532 false
533 }
534
535 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
537 if let Some(pattern_prefix) = pattern.strip_suffix('/') {
539 return path.starts_with(pattern_prefix)
540 && (path.len() == pattern_prefix.len()
541 || path.chars().nth(pattern_prefix.len()) == Some('/'));
542 }
543
544 if !pattern.contains('*') {
546 return path == pattern || path.ends_with(&format!("/{}", pattern));
547 }
548
549 if pattern == "*" {
551 return true;
552 }
553
554 let pattern_parts: Vec<&str> = pattern.split('*').collect();
556 if pattern_parts.len() == 2 {
557 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
558 if prefix.is_empty() {
559 return path.ends_with(suffix);
560 } else if suffix.is_empty() {
561 return path.starts_with(prefix);
562 } else {
563 return path.starts_with(prefix) && path.ends_with(suffix);
564 }
565 }
566
567 false
568 }
569}
570
571#[derive(Debug, Clone)]
573pub struct Quill {
574 pub metadata: HashMap<String, QuillValue>,
576 pub name: String,
578 pub backend: String,
580 pub plate: Option<String>,
582 pub example: Option<String>,
584 pub schema: QuillValue,
586 pub defaults: HashMap<String, QuillValue>,
588 pub examples: HashMap<String, Vec<QuillValue>>,
590 pub files: FileTreeNode,
592}
593
594#[derive(Debug, Clone)]
596pub struct QuillConfig {
597 pub name: String,
599 pub description: String,
601 pub backend: String,
603 pub version: Option<String>,
605 pub author: Option<String>,
607 pub example_file: Option<String>,
609 pub plate_file: Option<String>,
611 pub fields: HashMap<String, FieldSchema>,
613 pub cards: HashMap<String, CardSchema>,
615 pub metadata: HashMap<String, QuillValue>,
617 pub typst_config: HashMap<String, QuillValue>,
619}
620
621impl QuillConfig {
622 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
624 let quill_toml: toml::Value = toml::from_str(toml_content)
625 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
626
627 let (field_order, card_order): (Vec<String>, Vec<String>) = toml_content
629 .parse::<toml_edit::DocumentMut>()
630 .ok()
631 .map(|doc| {
632 let f_order = doc
633 .get("fields")
634 .and_then(|item| item.as_table())
635 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
636 .unwrap_or_default();
637
638 let s_order = doc
639 .get("cards")
640 .and_then(|item| item.as_table())
641 .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
642 .unwrap_or_default();
643
644 (f_order, s_order)
645 })
646 .unwrap_or_default();
647
648 let quill_section = quill_toml
650 .get("Quill")
651 .ok_or("Missing required [Quill] section in Quill.toml")?;
652
653 let name = quill_section
655 .get("name")
656 .and_then(|v| v.as_str())
657 .ok_or("Missing required 'name' field in [Quill] section")?
658 .to_string();
659
660 let backend = quill_section
661 .get("backend")
662 .and_then(|v| v.as_str())
663 .ok_or("Missing required 'backend' field in [Quill] section")?
664 .to_string();
665
666 let description = quill_section
667 .get("description")
668 .and_then(|v| v.as_str())
669 .ok_or("Missing required 'description' field in [Quill] section")?;
670
671 if description.trim().is_empty() {
672 return Err("'description' field in [Quill] section cannot be empty".into());
673 }
674 let description = description.to_string();
675
676 let version = quill_section
678 .get("version")
679 .and_then(|v| v.as_str())
680 .map(|s| s.to_string());
681
682 let author = quill_section
683 .get("author")
684 .and_then(|v| v.as_str())
685 .map(|s| s.to_string());
686
687 let example_file = quill_section
688 .get("example_file")
689 .and_then(|v| v.as_str())
690 .map(|s| s.to_string());
691
692 let plate_file = quill_section
693 .get("plate_file")
694 .and_then(|v| v.as_str())
695 .map(|s| s.to_string());
696
697 let mut metadata = HashMap::new();
699 if let toml::Value::Table(table) = quill_section {
700 for (key, value) in table {
701 if key != "name"
703 && key != "backend"
704 && key != "description"
705 && key != "version"
706 && key != "author"
707 && key != "example_file"
708 && key != "plate_file"
709 {
710 match QuillValue::from_toml(value) {
711 Ok(quill_value) => {
712 metadata.insert(key.clone(), quill_value);
713 }
714 Err(e) => {
715 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
716 }
717 }
718 }
719 }
720 }
721
722 let mut typst_config = HashMap::new();
724 if let Some(toml::Value::Table(table)) = quill_toml.get("typst") {
725 for (key, value) in table {
726 match QuillValue::from_toml(value) {
727 Ok(quill_value) => {
728 typst_config.insert(key.clone(), quill_value);
729 }
730 Err(e) => {
731 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
732 }
733 }
734 }
735 }
736
737 let mut fields = HashMap::new();
739 if let Some(toml::Value::Table(fields_table)) = quill_toml.get("fields") {
740 let mut order_counter = 0;
741 for (field_name, field_schema) in fields_table {
742 let order = if let Some(idx) = field_order.iter().position(|k| k == field_name) {
744 idx as i32
745 } else {
746 let o = field_order.len() as i32 + order_counter;
747 order_counter += 1;
748 o
749 };
750
751 match QuillValue::from_toml(field_schema) {
752 Ok(quill_value) => {
753 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
754 Ok(mut schema) => {
755 if schema.ui.is_none() {
757 schema.ui = Some(UiSchema {
758 group: None,
759 order: Some(order),
760 });
761 } else if let Some(ui) = &mut schema.ui {
762 ui.order = Some(order);
763 }
764
765 fields.insert(field_name.clone(), schema);
766 }
767 Err(e) => {
768 eprintln!(
769 "Warning: Failed to parse field schema '{}': {}",
770 field_name, e
771 );
772 }
773 }
774 }
775 Err(e) => {
776 eprintln!(
777 "Warning: Failed to convert field schema '{}': {}",
778 field_name, e
779 );
780 }
781 }
782 }
783 }
784
785 let mut cards: HashMap<String, CardSchema> = HashMap::new();
787 if let Some(toml::Value::Table(cards_table)) = quill_toml.get("cards") {
788 let current_field_count = fields.len() as i32;
789 let mut order_counter = 0;
790
791 for (card_name, card_value) in cards_table {
792 if fields.contains_key(card_name) {
794 return Err(format!(
795 "Card definition '{}' conflicts with an existing field name",
796 card_name
797 )
798 .into());
799 }
800
801 let order = if let Some(idx) = card_order.iter().position(|k| k == card_name) {
803 current_field_count + idx as i32
804 } else {
805 let o = current_field_count + card_order.len() as i32 + order_counter;
806 order_counter += 1;
807 o
808 };
809
810 let card_table = card_value
812 .as_table()
813 .ok_or_else(|| format!("Card definition '{}' must be a table", card_name))?;
814
815 let title = card_table
816 .get("title")
817 .and_then(|v| v.as_str())
818 .map(|s| s.to_string());
819
820 let description = card_table
821 .get("description")
822 .and_then(|v| v.as_str())
823 .unwrap_or("")
824 .to_string();
825
826 let ui = if let Some(ui_value) = card_table.get("ui") {
828 if let Some(ui_table) = ui_value.as_table() {
829 let group = ui_table
830 .get("group")
831 .and_then(|v| v.as_str())
832 .map(|s| s.to_string());
833 Some(UiSchema {
834 group,
835 order: Some(order),
836 })
837 } else {
838 None
839 }
840 } else {
841 Some(UiSchema {
842 group: None,
843 order: Some(order),
844 })
845 };
846
847 let mut card_fields: HashMap<String, FieldSchema> = HashMap::new();
849 if let Some(fields_value) = card_table.get("fields") {
850 if let Some(fields_table) = fields_value.as_table() {
851 for (field_name, field_value) in fields_table {
852 match QuillValue::from_toml(field_value) {
853 Ok(quill_value) => {
854 match FieldSchema::from_quill_value(
855 field_name.clone(),
856 &quill_value,
857 ) {
858 Ok(field_schema) => {
859 card_fields.insert(field_name.clone(), field_schema);
860 }
861 Err(e) => {
862 eprintln!(
863 "Warning: Failed to parse card field '{}.{}': {}",
864 card_name, field_name, e
865 );
866 }
867 }
868 }
869 Err(e) => {
870 eprintln!(
871 "Warning: Failed to convert card field '{}.{}': {}",
872 card_name, field_name, e
873 );
874 }
875 }
876 }
877 }
878 }
879
880 let card_schema = CardSchema {
881 name: card_name.clone(),
882 title,
883 description,
884 ui,
885 fields: card_fields,
886 };
887
888 cards.insert(card_name.clone(), card_schema);
889 }
890 }
891
892 Ok(QuillConfig {
893 name,
894 description,
895 backend,
896 version,
897 author,
898 example_file,
899 plate_file,
900 fields,
901 cards,
902 metadata,
903 typst_config,
904 })
905 }
906}
907
908impl Quill {
909 pub fn from_path<P: AsRef<std::path::Path>>(
911 path: P,
912 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
913 use std::fs;
914
915 let path = path.as_ref();
916 let name = path
917 .file_name()
918 .and_then(|n| n.to_str())
919 .unwrap_or("unnamed")
920 .to_string();
921
922 let quillignore_path = path.join(".quillignore");
924 let ignore = if quillignore_path.exists() {
925 let ignore_content = fs::read_to_string(&quillignore_path)
926 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
927 QuillIgnore::from_content(&ignore_content)
928 } else {
929 QuillIgnore::new(vec![
931 ".git/".to_string(),
932 ".gitignore".to_string(),
933 ".quillignore".to_string(),
934 "target/".to_string(),
935 "node_modules/".to_string(),
936 ])
937 };
938
939 let root = Self::load_directory_as_tree(path, path, &ignore)?;
941
942 Self::from_tree(root, Some(name))
944 }
945
946 pub fn from_tree(
963 root: FileTreeNode,
964 _default_name: Option<String>,
965 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
966 let quill_toml_bytes = root
968 .get_file("Quill.toml")
969 .ok_or("Quill.toml not found in file tree")?;
970
971 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
972 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
973
974 let config = QuillConfig::from_toml(&quill_toml_content)?;
976
977 Self::from_config(config, root)
979 }
980
981 fn from_config(
998 config: QuillConfig,
999 root: FileTreeNode,
1000 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
1001 let mut metadata = config.metadata.clone();
1003
1004 metadata.insert(
1006 "backend".to_string(),
1007 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
1008 );
1009
1010 metadata.insert(
1012 "description".to_string(),
1013 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
1014 );
1015
1016 if let Some(ref author) = config.author {
1018 metadata.insert(
1019 "author".to_string(),
1020 QuillValue::from_json(serde_json::Value::String(author.clone())),
1021 );
1022 }
1023
1024 for (key, value) in &config.typst_config {
1026 metadata.insert(format!("typst_{}", key), value.clone());
1027 }
1028
1029 let schema = crate::schema::build_schema(&config.fields, &config.cards)
1031 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
1032
1033 let plate_content: Option<String> = if let Some(ref plate_file_name) = config.plate_file {
1035 let plate_bytes = root.get_file(plate_file_name).ok_or_else(|| {
1036 format!("Plate file '{}' not found in file tree", plate_file_name)
1037 })?;
1038
1039 let content = String::from_utf8(plate_bytes.to_vec()).map_err(|e| {
1040 format!("Plate file '{}' is not valid UTF-8: {}", plate_file_name, e)
1041 })?;
1042 Some(content)
1043 } else {
1044 None
1046 };
1047
1048 let example_content = if let Some(ref example_file_name) = config.example_file {
1050 root.get_file(example_file_name).and_then(|bytes| {
1051 String::from_utf8(bytes.to_vec())
1052 .map_err(|e| {
1053 eprintln!(
1054 "Warning: Example file '{}' is not valid UTF-8: {}",
1055 example_file_name, e
1056 );
1057 e
1058 })
1059 .ok()
1060 })
1061 } else {
1062 None
1063 };
1064
1065 let defaults = crate::schema::extract_defaults_from_schema(&schema);
1067 let examples = crate::schema::extract_examples_from_schema(&schema);
1068
1069 let quill = Quill {
1070 metadata,
1071 name: config.name,
1072 backend: config.backend,
1073 plate: plate_content,
1074 example: example_content,
1075 schema,
1076 defaults,
1077 examples,
1078 files: root,
1079 };
1080
1081 Ok(quill)
1082 }
1083
1084 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
1091 use serde_json::Value as JsonValue;
1092
1093 let json: JsonValue =
1094 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1095
1096 let obj = json.as_object().ok_or("Root must be an object")?;
1097
1098 let default_name = obj
1100 .get("metadata")
1101 .and_then(|m| m.get("name"))
1102 .and_then(|v| v.as_str())
1103 .map(String::from);
1104
1105 let files_obj = obj
1107 .get("files")
1108 .and_then(|v| v.as_object())
1109 .ok_or("Missing or invalid 'files' key")?;
1110
1111 let mut root_files = HashMap::new();
1113 for (key, value) in files_obj {
1114 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
1115 }
1116
1117 let root = FileTreeNode::Directory { files: root_files };
1118
1119 Self::from_tree(root, default_name)
1121 }
1122
1123 fn load_directory_as_tree(
1125 current_dir: &Path,
1126 base_dir: &Path,
1127 ignore: &QuillIgnore,
1128 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
1129 use std::fs;
1130
1131 if !current_dir.exists() {
1132 return Ok(FileTreeNode::Directory {
1133 files: HashMap::new(),
1134 });
1135 }
1136
1137 let mut files = HashMap::new();
1138
1139 for entry in fs::read_dir(current_dir)? {
1140 let entry = entry?;
1141 let path = entry.path();
1142 let relative_path = path
1143 .strip_prefix(base_dir)
1144 .map_err(|e| format!("Failed to get relative path: {}", e))?
1145 .to_path_buf();
1146
1147 if ignore.is_ignored(&relative_path) {
1149 continue;
1150 }
1151
1152 let filename = path
1154 .file_name()
1155 .and_then(|n| n.to_str())
1156 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
1157 .to_string();
1158
1159 if path.is_file() {
1160 let contents = fs::read(&path)
1161 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
1162
1163 files.insert(filename, FileTreeNode::File { contents });
1164 } else if path.is_dir() {
1165 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
1167 files.insert(filename, subdir_tree);
1168 }
1169 }
1170
1171 Ok(FileTreeNode::Directory { files })
1172 }
1173
1174 pub fn typst_packages(&self) -> Vec<String> {
1176 self.metadata
1177 .get("typst_packages")
1178 .and_then(|v| v.as_array())
1179 .map(|arr| {
1180 arr.iter()
1181 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1182 .collect()
1183 })
1184 .unwrap_or_default()
1185 }
1186
1187 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
1195 &self.defaults
1196 }
1197
1198 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
1203 &self.examples
1204 }
1205
1206 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
1208 self.files.get_file(path)
1209 }
1210
1211 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1213 self.files.file_exists(path)
1214 }
1215
1216 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1218 self.files.dir_exists(path)
1219 }
1220
1221 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1223 self.files.list_files(path)
1224 }
1225
1226 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1228 self.files.list_subdirectories(path)
1229 }
1230
1231 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1233 let dir_path = dir_path.as_ref();
1234 let filenames = self.files.list_files(dir_path);
1235
1236 filenames
1238 .iter()
1239 .map(|name| {
1240 if dir_path == Path::new("") {
1241 PathBuf::from(name)
1242 } else {
1243 dir_path.join(name)
1244 }
1245 })
1246 .collect()
1247 }
1248
1249 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1251 let dir_path = dir_path.as_ref();
1252 let subdirs = self.files.list_subdirectories(dir_path);
1253
1254 subdirs
1256 .iter()
1257 .map(|name| {
1258 if dir_path == Path::new("") {
1259 PathBuf::from(name)
1260 } else {
1261 dir_path.join(name)
1262 }
1263 })
1264 .collect()
1265 }
1266
1267 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1269 let pattern_str = pattern.as_ref().to_string_lossy();
1270 let mut matches = Vec::new();
1271
1272 let glob_pattern = match glob::Pattern::new(&pattern_str) {
1274 Ok(pat) => pat,
1275 Err(_) => return matches, };
1277
1278 Self::find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1280
1281 matches.sort();
1282 matches
1283 }
1284
1285 fn find_files_recursive(
1287 node: &FileTreeNode,
1288 current_path: &Path,
1289 pattern: &glob::Pattern,
1290 matches: &mut Vec<PathBuf>,
1291 ) {
1292 match node {
1293 FileTreeNode::File { .. } => {
1294 let path_str = current_path.to_string_lossy();
1295 if pattern.matches(&path_str) {
1296 matches.push(current_path.to_path_buf());
1297 }
1298 }
1299 FileTreeNode::Directory { files } => {
1300 for (name, child_node) in files {
1301 let child_path = if current_path == Path::new("") {
1302 PathBuf::from(name)
1303 } else {
1304 current_path.join(name)
1305 };
1306 Self::find_files_recursive(child_node, &child_path, pattern, matches);
1307 }
1308 }
1309 }
1310 }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316 use std::fs;
1317 use tempfile::TempDir;
1318
1319 #[test]
1320 fn test_quillignore_parsing() {
1321 let ignore_content = r#"
1322# This is a comment
1323*.tmp
1324target/
1325node_modules/
1326.git/
1327"#;
1328 let ignore = QuillIgnore::from_content(ignore_content);
1329 assert_eq!(ignore.patterns.len(), 4);
1330 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1331 assert!(ignore.patterns.contains(&"target/".to_string()));
1332 }
1333
1334 #[test]
1335 fn test_quillignore_matching() {
1336 let ignore = QuillIgnore::new(vec![
1337 "*.tmp".to_string(),
1338 "target/".to_string(),
1339 "node_modules/".to_string(),
1340 ".git/".to_string(),
1341 ]);
1342
1343 assert!(ignore.is_ignored("test.tmp"));
1345 assert!(ignore.is_ignored("path/to/file.tmp"));
1346 assert!(!ignore.is_ignored("test.txt"));
1347
1348 assert!(ignore.is_ignored("target"));
1350 assert!(ignore.is_ignored("target/debug"));
1351 assert!(ignore.is_ignored("target/debug/deps"));
1352 assert!(!ignore.is_ignored("src/target.rs"));
1353
1354 assert!(ignore.is_ignored("node_modules"));
1355 assert!(ignore.is_ignored("node_modules/package"));
1356 assert!(!ignore.is_ignored("my_node_modules"));
1357 }
1358
1359 #[test]
1360 fn test_in_memory_file_system() {
1361 let temp_dir = TempDir::new().unwrap();
1362 let quill_dir = temp_dir.path();
1363
1364 fs::write(
1366 quill_dir.join("Quill.toml"),
1367 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1368 )
1369 .unwrap();
1370 fs::write(quill_dir.join("plate.typ"), "test plate").unwrap();
1371
1372 let assets_dir = quill_dir.join("assets");
1373 fs::create_dir_all(&assets_dir).unwrap();
1374 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1375
1376 let packages_dir = quill_dir.join("packages");
1377 fs::create_dir_all(&packages_dir).unwrap();
1378 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1379
1380 let quill = Quill::from_path(quill_dir).unwrap();
1382
1383 assert!(quill.file_exists("plate.typ"));
1385 assert!(quill.file_exists("assets/test.txt"));
1386 assert!(quill.file_exists("packages/package.typ"));
1387 assert!(!quill.file_exists("nonexistent.txt"));
1388
1389 let asset_content = quill.get_file("assets/test.txt").unwrap();
1391 assert_eq!(asset_content, b"asset content");
1392
1393 let asset_files = quill.list_directory("assets");
1395 assert_eq!(asset_files.len(), 1);
1396 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1397 }
1398
1399 #[test]
1400 fn test_quillignore_integration() {
1401 let temp_dir = TempDir::new().unwrap();
1402 let quill_dir = temp_dir.path();
1403
1404 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1406
1407 fs::write(
1409 quill_dir.join("Quill.toml"),
1410 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1411 )
1412 .unwrap();
1413 fs::write(quill_dir.join("plate.typ"), "test template").unwrap();
1414 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1415
1416 let target_dir = quill_dir.join("target");
1417 fs::create_dir_all(&target_dir).unwrap();
1418 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1419
1420 let quill = Quill::from_path(quill_dir).unwrap();
1422
1423 assert!(quill.file_exists("plate.typ"));
1425 assert!(!quill.file_exists("should_ignore.tmp"));
1426 assert!(!quill.file_exists("target/debug.txt"));
1427 }
1428
1429 #[test]
1430 fn test_find_files_pattern() {
1431 let temp_dir = TempDir::new().unwrap();
1432 let quill_dir = temp_dir.path();
1433
1434 fs::write(
1436 quill_dir.join("Quill.toml"),
1437 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1438 )
1439 .unwrap();
1440 fs::write(quill_dir.join("plate.typ"), "template").unwrap();
1441
1442 let assets_dir = quill_dir.join("assets");
1443 fs::create_dir_all(&assets_dir).unwrap();
1444 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1445 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1446
1447 let fonts_dir = assets_dir.join("fonts");
1448 fs::create_dir_all(&fonts_dir).unwrap();
1449 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1450
1451 let quill = Quill::from_path(quill_dir).unwrap();
1453
1454 let all_assets = quill.find_files("assets/*");
1456 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1459 assert_eq!(typ_files.len(), 1);
1460 assert!(typ_files.contains(&PathBuf::from("plate.typ")));
1461 }
1462
1463 #[test]
1464 fn test_new_standardized_toml_format() {
1465 let temp_dir = TempDir::new().unwrap();
1466 let quill_dir = temp_dir.path();
1467
1468 let toml_content = r#"[Quill]
1470name = "my-custom-quill"
1471backend = "typst"
1472plate_file = "custom_plate.typ"
1473description = "Test quill with new format"
1474author = "Test Author"
1475"#;
1476 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1477 fs::write(
1478 quill_dir.join("custom_plate.typ"),
1479 "= Custom Template\n\nThis is a custom template.",
1480 )
1481 .unwrap();
1482
1483 let quill = Quill::from_path(quill_dir).unwrap();
1485
1486 assert_eq!(quill.name, "my-custom-quill");
1488
1489 assert!(quill.metadata.contains_key("backend"));
1491 if let Some(backend_val) = quill.metadata.get("backend") {
1492 if let Some(backend_str) = backend_val.as_str() {
1493 assert_eq!(backend_str, "typst");
1494 } else {
1495 panic!("Backend value is not a string");
1496 }
1497 }
1498
1499 assert!(quill.metadata.contains_key("description"));
1501 assert!(quill.metadata.contains_key("author"));
1502 assert!(!quill.metadata.contains_key("version")); assert!(quill.plate.unwrap().contains("Custom Template"));
1506 }
1507
1508 #[test]
1509 fn test_typst_packages_parsing() {
1510 let temp_dir = TempDir::new().unwrap();
1511 let quill_dir = temp_dir.path();
1512
1513 let toml_content = r#"
1514[Quill]
1515name = "test-quill"
1516backend = "typst"
1517plate_file = "plate.typ"
1518description = "Test quill for packages"
1519
1520[typst]
1521packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1522"#;
1523
1524 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1525 fs::write(quill_dir.join("plate.typ"), "test").unwrap();
1526
1527 let quill = Quill::from_path(quill_dir).unwrap();
1528 let packages = quill.typst_packages();
1529
1530 assert_eq!(packages.len(), 2);
1531 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1532 assert_eq!(packages[1], "@preview/example:1.0.0");
1533 }
1534
1535 #[test]
1536 fn test_template_loading() {
1537 let temp_dir = TempDir::new().unwrap();
1538 let quill_dir = temp_dir.path();
1539
1540 let toml_content = r#"[Quill]
1542name = "test-with-template"
1543backend = "typst"
1544plate_file = "plate.typ"
1545example_file = "example.md"
1546description = "Test quill with template"
1547"#;
1548 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1549 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1550 fs::write(
1551 quill_dir.join("example.md"),
1552 "---\ntitle: Test\n---\n\nThis is a test template.",
1553 )
1554 .unwrap();
1555
1556 let quill = Quill::from_path(quill_dir).unwrap();
1558
1559 assert!(quill.example.is_some());
1561 let example = quill.example.unwrap();
1562 assert!(example.contains("title: Test"));
1563 assert!(example.contains("This is a test template"));
1564
1565 assert_eq!(quill.plate.unwrap(), "plate content");
1567 }
1568
1569 #[test]
1570 fn test_template_optional() {
1571 let temp_dir = TempDir::new().unwrap();
1572 let quill_dir = temp_dir.path();
1573
1574 let toml_content = r#"[Quill]
1576name = "test-without-template"
1577backend = "typst"
1578plate_file = "plate.typ"
1579description = "Test quill without template"
1580"#;
1581 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1582 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1583
1584 let quill = Quill::from_path(quill_dir).unwrap();
1586
1587 assert_eq!(quill.example, None);
1589
1590 assert_eq!(quill.plate.unwrap(), "plate content");
1592 }
1593
1594 #[test]
1595 fn test_from_tree() {
1596 let mut root_files = HashMap::new();
1598
1599 let quill_toml = r#"[Quill]
1601name = "test-from-tree"
1602backend = "typst"
1603plate_file = "plate.typ"
1604description = "A test quill from tree"
1605"#;
1606 root_files.insert(
1607 "Quill.toml".to_string(),
1608 FileTreeNode::File {
1609 contents: quill_toml.as_bytes().to_vec(),
1610 },
1611 );
1612
1613 let plate_content = "= Test Template\n\nThis is a test.";
1615 root_files.insert(
1616 "plate.typ".to_string(),
1617 FileTreeNode::File {
1618 contents: plate_content.as_bytes().to_vec(),
1619 },
1620 );
1621
1622 let root = FileTreeNode::Directory { files: root_files };
1623
1624 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1626
1627 assert_eq!(quill.name, "test-from-tree");
1629 assert_eq!(quill.plate.unwrap(), plate_content);
1630 assert!(quill.metadata.contains_key("backend"));
1631 assert!(quill.metadata.contains_key("description"));
1632 }
1633
1634 #[test]
1635 fn test_from_tree_with_template() {
1636 let mut root_files = HashMap::new();
1637
1638 let quill_toml = r#"[Quill]
1640name = "test-tree-template"
1641backend = "typst"
1642plate_file = "plate.typ"
1643example_file = "template.md"
1644description = "Test tree with template"
1645"#;
1646 root_files.insert(
1647 "Quill.toml".to_string(),
1648 FileTreeNode::File {
1649 contents: quill_toml.as_bytes().to_vec(),
1650 },
1651 );
1652
1653 root_files.insert(
1655 "plate.typ".to_string(),
1656 FileTreeNode::File {
1657 contents: b"plate content".to_vec(),
1658 },
1659 );
1660
1661 let template_content = "# {{ title }}\n\n{{ body }}";
1663 root_files.insert(
1664 "template.md".to_string(),
1665 FileTreeNode::File {
1666 contents: template_content.as_bytes().to_vec(),
1667 },
1668 );
1669
1670 let root = FileTreeNode::Directory { files: root_files };
1671
1672 let quill = Quill::from_tree(root, None).unwrap();
1674
1675 assert_eq!(quill.example, Some(template_content.to_string()));
1677 }
1678
1679 #[test]
1680 fn test_from_json() {
1681 let json_str = r#"{
1683 "metadata": {
1684 "name": "test-from-json"
1685 },
1686 "files": {
1687 "Quill.toml": {
1688 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill from JSON\"\n"
1689 },
1690 "plate.typ": {
1691 "contents": "= Test Plate\n\nThis is test content."
1692 }
1693 }
1694 }"#;
1695
1696 let quill = Quill::from_json(json_str).unwrap();
1698
1699 assert_eq!(quill.name, "test-from-json");
1701 assert!(quill.plate.unwrap().contains("Test Plate"));
1702 assert!(quill.metadata.contains_key("backend"));
1703 }
1704
1705 #[test]
1706 fn test_from_json_with_byte_array() {
1707 let json_str = r#"{
1709 "files": {
1710 "Quill.toml": {
1711 "contents": "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1712 },
1713 "plate.typ": {
1714 "contents": "test plate"
1715 }
1716 }
1717 }"#;
1718
1719 let quill = Quill::from_json(json_str).unwrap();
1721
1722 assert_eq!(quill.name, "test");
1724 assert_eq!(quill.plate.unwrap(), "test plate");
1725 }
1726
1727 #[test]
1728 fn test_from_json_missing_files() {
1729 let json_str = r#"{
1731 "metadata": {
1732 "name": "test"
1733 }
1734 }"#;
1735
1736 let result = Quill::from_json(json_str);
1737 assert!(result.is_err());
1738 assert!(result.unwrap_err().to_string().contains("files"));
1740 }
1741
1742 #[test]
1743 fn test_from_json_tree_structure() {
1744 let json_str = r#"{
1746 "files": {
1747 "Quill.toml": {
1748 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test tree JSON\"\n"
1749 },
1750 "plate.typ": {
1751 "contents": "= Test Plate\n\nTree structure content."
1752 }
1753 }
1754 }"#;
1755
1756 let quill = Quill::from_json(json_str).unwrap();
1757
1758 assert_eq!(quill.name, "test-tree-json");
1759 assert!(quill.plate.unwrap().contains("Tree structure content"));
1760 assert!(quill.metadata.contains_key("backend"));
1761 }
1762
1763 #[test]
1764 fn test_from_json_nested_tree_structure() {
1765 let json_str = r#"{
1767 "files": {
1768 "Quill.toml": {
1769 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Nested test\"\n"
1770 },
1771 "plate.typ": {
1772 "contents": "plate"
1773 },
1774 "src": {
1775 "main.rs": {
1776 "contents": "fn main() {}"
1777 },
1778 "lib.rs": {
1779 "contents": "// lib"
1780 }
1781 }
1782 }
1783 }"#;
1784
1785 let quill = Quill::from_json(json_str).unwrap();
1786
1787 assert_eq!(quill.name, "nested-test");
1788 assert!(quill.file_exists("src/main.rs"));
1790 assert!(quill.file_exists("src/lib.rs"));
1791
1792 let main_rs = quill.get_file("src/main.rs").unwrap();
1793 assert_eq!(main_rs, b"fn main() {}");
1794 }
1795
1796 #[test]
1797 fn test_from_tree_structure_direct() {
1798 let mut root_files = HashMap::new();
1800
1801 root_files.insert(
1802 "Quill.toml".to_string(),
1803 FileTreeNode::File {
1804 contents:
1805 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Direct tree test\"\n"
1806 .to_vec(),
1807 },
1808 );
1809
1810 root_files.insert(
1811 "plate.typ".to_string(),
1812 FileTreeNode::File {
1813 contents: b"plate content".to_vec(),
1814 },
1815 );
1816
1817 let mut src_files = HashMap::new();
1819 src_files.insert(
1820 "main.rs".to_string(),
1821 FileTreeNode::File {
1822 contents: b"fn main() {}".to_vec(),
1823 },
1824 );
1825
1826 root_files.insert(
1827 "src".to_string(),
1828 FileTreeNode::Directory { files: src_files },
1829 );
1830
1831 let root = FileTreeNode::Directory { files: root_files };
1832
1833 let quill = Quill::from_tree(root, None).unwrap();
1834
1835 assert_eq!(quill.name, "direct-tree");
1836 assert!(quill.file_exists("src/main.rs"));
1837 assert!(quill.file_exists("plate.typ"));
1838 }
1839
1840 #[test]
1841 fn test_from_json_with_metadata_override() {
1842 let json_str = r#"{
1844 "metadata": {
1845 "name": "override-name"
1846 },
1847 "files": {
1848 "Quill.toml": {
1849 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"TOML name test\"\n"
1850 },
1851 "plate.typ": {
1852 "contents": "= plate"
1853 }
1854 }
1855 }"#;
1856
1857 let quill = Quill::from_json(json_str).unwrap();
1858 assert_eq!(quill.name, "toml-name");
1861 }
1862
1863 #[test]
1864 fn test_from_json_empty_directory() {
1865 let json_str = r#"{
1867 "files": {
1868 "Quill.toml": {
1869 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Empty directory test\"\n"
1870 },
1871 "plate.typ": {
1872 "contents": "plate"
1873 },
1874 "empty_dir": {}
1875 }
1876 }"#;
1877
1878 let quill = Quill::from_json(json_str).unwrap();
1879 assert_eq!(quill.name, "empty-dir-test");
1880 assert!(quill.dir_exists("empty_dir"));
1881 assert!(!quill.file_exists("empty_dir"));
1882 }
1883
1884 #[test]
1885 fn test_dir_exists_and_list_apis() {
1886 let mut root_files = HashMap::new();
1887
1888 root_files.insert(
1890 "Quill.toml".to_string(),
1891 FileTreeNode::File {
1892 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1893 .to_vec(),
1894 },
1895 );
1896
1897 root_files.insert(
1899 "plate.typ".to_string(),
1900 FileTreeNode::File {
1901 contents: b"plate content".to_vec(),
1902 },
1903 );
1904
1905 let mut assets_files = HashMap::new();
1907 assets_files.insert(
1908 "logo.png".to_string(),
1909 FileTreeNode::File {
1910 contents: vec![137, 80, 78, 71],
1911 },
1912 );
1913 assets_files.insert(
1914 "icon.svg".to_string(),
1915 FileTreeNode::File {
1916 contents: b"<svg></svg>".to_vec(),
1917 },
1918 );
1919
1920 let mut fonts_files = HashMap::new();
1922 fonts_files.insert(
1923 "font.ttf".to_string(),
1924 FileTreeNode::File {
1925 contents: b"font data".to_vec(),
1926 },
1927 );
1928 assets_files.insert(
1929 "fonts".to_string(),
1930 FileTreeNode::Directory { files: fonts_files },
1931 );
1932
1933 root_files.insert(
1934 "assets".to_string(),
1935 FileTreeNode::Directory {
1936 files: assets_files,
1937 },
1938 );
1939
1940 root_files.insert(
1942 "empty".to_string(),
1943 FileTreeNode::Directory {
1944 files: HashMap::new(),
1945 },
1946 );
1947
1948 let root = FileTreeNode::Directory { files: root_files };
1949 let quill = Quill::from_tree(root, None).unwrap();
1950
1951 assert!(quill.dir_exists("assets"));
1953 assert!(quill.dir_exists("assets/fonts"));
1954 assert!(quill.dir_exists("empty"));
1955 assert!(!quill.dir_exists("nonexistent"));
1956 assert!(!quill.dir_exists("plate.typ")); assert!(quill.file_exists("plate.typ"));
1960 assert!(quill.file_exists("assets/logo.png"));
1961 assert!(quill.file_exists("assets/fonts/font.ttf"));
1962 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1966 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1968 assert!(root_files_list.contains(&"plate.typ".to_string()));
1969
1970 let assets_files_list = quill.list_files("assets");
1971 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1973 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1974
1975 let root_subdirs = quill.list_subdirectories("");
1977 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1979 assert!(root_subdirs.contains(&"empty".to_string()));
1980
1981 let assets_subdirs = quill.list_subdirectories("assets");
1982 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1984
1985 let empty_subdirs = quill.list_subdirectories("empty");
1986 assert_eq!(empty_subdirs.len(), 0);
1987 }
1988
1989 #[test]
1990 fn test_field_schemas_parsing() {
1991 let mut root_files = HashMap::new();
1992
1993 let quill_toml = r#"[Quill]
1995name = "taro"
1996backend = "typst"
1997plate_file = "plate.typ"
1998example_file = "taro.md"
1999description = "Test template for field schemas"
2000
2001[fields]
2002author = {description = "Author of document" }
2003ice_cream = {description = "favorite ice cream flavor"}
2004title = {description = "title of document" }
2005"#;
2006 root_files.insert(
2007 "Quill.toml".to_string(),
2008 FileTreeNode::File {
2009 contents: quill_toml.as_bytes().to_vec(),
2010 },
2011 );
2012
2013 let plate_content = "= Test Template\n\nThis is a test.";
2015 root_files.insert(
2016 "plate.typ".to_string(),
2017 FileTreeNode::File {
2018 contents: plate_content.as_bytes().to_vec(),
2019 },
2020 );
2021
2022 root_files.insert(
2024 "taro.md".to_string(),
2025 FileTreeNode::File {
2026 contents: b"# Template".to_vec(),
2027 },
2028 );
2029
2030 let root = FileTreeNode::Directory { files: root_files };
2031
2032 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
2034
2035 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
2037 assert!(quill.schema["properties"]
2038 .as_object()
2039 .unwrap()
2040 .contains_key("author"));
2041 assert!(quill.schema["properties"]
2042 .as_object()
2043 .unwrap()
2044 .contains_key("ice_cream"));
2045 assert!(quill.schema["properties"]
2046 .as_object()
2047 .unwrap()
2048 .contains_key("title"));
2049
2050 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
2052 assert_eq!(author_schema["description"], "Author of document");
2053
2054 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
2056 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
2057
2058 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
2060 assert_eq!(title_schema["description"], "title of document");
2061 }
2062
2063 #[test]
2064 fn test_field_schema_struct() {
2065 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
2067 assert_eq!(schema1.description, "Test description");
2068 assert_eq!(schema1.r#type, None);
2069 assert_eq!(schema1.examples, None);
2070 assert_eq!(schema1.default, None);
2071
2072 let yaml_str = r#"
2074description: "Full field schema"
2075type: "string"
2076examples:
2077 - "Example value"
2078default: "Default value"
2079"#;
2080 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
2081 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2082 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
2083 assert_eq!(schema2.name, "test_name");
2084 assert_eq!(schema2.description, "Full field schema");
2085 assert_eq!(schema2.r#type, Some(FieldType::String));
2086 assert_eq!(
2087 schema2
2088 .examples
2089 .as_ref()
2090 .and_then(|v| v.as_array())
2091 .and_then(|arr| arr.first())
2092 .and_then(|v| v.as_str()),
2093 Some("Example value")
2094 );
2095 assert_eq!(
2096 schema2.default.as_ref().and_then(|v| v.as_str()),
2097 Some("Default value")
2098 );
2099 }
2100
2101 #[test]
2102 fn test_quill_without_plate_file() {
2103 let mut root_files = HashMap::new();
2105
2106 let quill_toml = r#"[Quill]
2108name = "test-no-plate"
2109backend = "typst"
2110description = "Test quill without plate file"
2111"#;
2112 root_files.insert(
2113 "Quill.toml".to_string(),
2114 FileTreeNode::File {
2115 contents: quill_toml.as_bytes().to_vec(),
2116 },
2117 );
2118
2119 let root = FileTreeNode::Directory { files: root_files };
2120
2121 let quill = Quill::from_tree(root, None).unwrap();
2123
2124 assert!(quill.plate.clone().is_none());
2126 assert_eq!(quill.name, "test-no-plate");
2127 }
2128
2129 #[test]
2130 fn test_quill_config_from_toml() {
2131 let toml_content = r#"[Quill]
2133name = "test-config"
2134backend = "typst"
2135description = "Test configuration parsing"
2136version = "1.0.0"
2137author = "Test Author"
2138plate_file = "plate.typ"
2139example_file = "example.md"
2140
2141[typst]
2142packages = ["@preview/bubble:0.2.2"]
2143
2144[fields]
2145title = {description = "Document title", type = "string"}
2146author = {description = "Document author"}
2147"#;
2148
2149 let config = QuillConfig::from_toml(toml_content).unwrap();
2150
2151 assert_eq!(config.name, "test-config");
2153 assert_eq!(config.backend, "typst");
2154 assert_eq!(config.description, "Test configuration parsing");
2155
2156 assert_eq!(config.version, Some("1.0.0".to_string()));
2158 assert_eq!(config.author, Some("Test Author".to_string()));
2159 assert_eq!(config.plate_file, Some("plate.typ".to_string()));
2160 assert_eq!(config.example_file, Some("example.md".to_string()));
2161
2162 assert!(config.typst_config.contains_key("packages"));
2164
2165 assert_eq!(config.fields.len(), 2);
2167 assert!(config.fields.contains_key("title"));
2168 assert!(config.fields.contains_key("author"));
2169
2170 let title_field = &config.fields["title"];
2171 assert_eq!(title_field.description, "Document title");
2172 assert_eq!(title_field.r#type, Some(FieldType::String));
2173 }
2174
2175 #[test]
2176 fn test_quill_config_missing_required_fields() {
2177 let toml_missing_name = r#"[Quill]
2179backend = "typst"
2180description = "Missing name"
2181"#;
2182 let result = QuillConfig::from_toml(toml_missing_name);
2183 assert!(result.is_err());
2184 assert!(result
2185 .unwrap_err()
2186 .to_string()
2187 .contains("Missing required 'name'"));
2188
2189 let toml_missing_backend = r#"[Quill]
2190name = "test"
2191description = "Missing backend"
2192"#;
2193 let result = QuillConfig::from_toml(toml_missing_backend);
2194 assert!(result.is_err());
2195 assert!(result
2196 .unwrap_err()
2197 .to_string()
2198 .contains("Missing required 'backend'"));
2199
2200 let toml_missing_description = r#"[Quill]
2201name = "test"
2202backend = "typst"
2203"#;
2204 let result = QuillConfig::from_toml(toml_missing_description);
2205 assert!(result.is_err());
2206 assert!(result
2207 .unwrap_err()
2208 .to_string()
2209 .contains("Missing required 'description'"));
2210 }
2211
2212 #[test]
2213 fn test_quill_config_empty_description() {
2214 let toml_empty_description = r#"[Quill]
2216name = "test"
2217backend = "typst"
2218description = " "
2219"#;
2220 let result = QuillConfig::from_toml(toml_empty_description);
2221 assert!(result.is_err());
2222 assert!(result
2223 .unwrap_err()
2224 .to_string()
2225 .contains("description' field in [Quill] section cannot be empty"));
2226 }
2227
2228 #[test]
2229 fn test_quill_config_missing_quill_section() {
2230 let toml_no_section = r#"[fields]
2232title = {description = "Title"}
2233"#;
2234 let result = QuillConfig::from_toml(toml_no_section);
2235 assert!(result.is_err());
2236 assert!(result
2237 .unwrap_err()
2238 .to_string()
2239 .contains("Missing required [Quill] section"));
2240 }
2241
2242 #[test]
2243 fn test_quill_from_config_metadata() {
2244 let mut root_files = HashMap::new();
2246
2247 let quill_toml = r#"[Quill]
2248name = "metadata-test"
2249backend = "typst"
2250description = "Test metadata flow"
2251author = "Test Author"
2252custom_field = "custom_value"
2253
2254[typst]
2255packages = ["@preview/bubble:0.2.2"]
2256"#;
2257 root_files.insert(
2258 "Quill.toml".to_string(),
2259 FileTreeNode::File {
2260 contents: quill_toml.as_bytes().to_vec(),
2261 },
2262 );
2263
2264 let root = FileTreeNode::Directory { files: root_files };
2265 let quill = Quill::from_tree(root, None).unwrap();
2266
2267 assert!(quill.metadata.contains_key("backend"));
2269 assert!(quill.metadata.contains_key("description"));
2270 assert!(quill.metadata.contains_key("author"));
2271
2272 assert!(quill.metadata.contains_key("custom_field"));
2274 assert_eq!(
2275 quill.metadata.get("custom_field").unwrap().as_str(),
2276 Some("custom_value")
2277 );
2278
2279 assert!(quill.metadata.contains_key("typst_packages"));
2281 }
2282
2283 #[test]
2284 fn test_extract_defaults_method() {
2285 let mut root_files = HashMap::new();
2287
2288 let quill_toml = r#"[Quill]
2289name = "defaults-test"
2290backend = "typst"
2291description = "Test defaults extraction"
2292
2293[fields]
2294title = {description = "Title"}
2295author = {description = "Author", default = "Anonymous"}
2296status = {description = "Status", default = "draft"}
2297"#;
2298
2299 root_files.insert(
2300 "Quill.toml".to_string(),
2301 FileTreeNode::File {
2302 contents: quill_toml.as_bytes().to_vec(),
2303 },
2304 );
2305
2306 let root = FileTreeNode::Directory { files: root_files };
2307 let quill = Quill::from_tree(root, None).unwrap();
2308
2309 let defaults = quill.extract_defaults();
2311
2312 assert_eq!(defaults.len(), 2);
2314 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2316 assert!(defaults.contains_key("status"));
2317
2318 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2320 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2321 }
2322
2323 #[test]
2324 fn test_field_order_preservation() {
2325 let toml_content = r#"[Quill]
2326name = "order-test"
2327backend = "typst"
2328description = "Test field order"
2329
2330[fields]
2331first = {description = "First field"}
2332second = {description = "Second field"}
2333third = {description = "Third field", ui = {group = "Test Group"}}
2334fourth = {description = "Fourth field"}
2335"#;
2336
2337 let config = QuillConfig::from_toml(toml_content).unwrap();
2338
2339 let first = config.fields.get("first").unwrap();
2343 assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2344
2345 let second = config.fields.get("second").unwrap();
2346 assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2347
2348 let third = config.fields.get("third").unwrap();
2349 assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2350 assert_eq!(
2351 third.ui.as_ref().unwrap().group,
2352 Some("Test Group".to_string())
2353 );
2354
2355 let fourth = config.fields.get("fourth").unwrap();
2356 assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2357 }
2358
2359 #[test]
2360 fn test_quill_with_all_ui_properties() {
2361 let toml_content = r#"[Quill]
2362name = "full-ui-test"
2363backend = "typst"
2364description = "Test all UI properties"
2365
2366[fields.author]
2367description = "The full name of the document author"
2368type = "str"
2369
2370[fields.author.ui]
2371group = "Author Info"
2372"#;
2373
2374 let config = QuillConfig::from_toml(toml_content).unwrap();
2375
2376 let author_field = &config.fields["author"];
2377 let ui = author_field.ui.as_ref().unwrap();
2378 assert_eq!(ui.group, Some("Author Info".to_string()));
2379 assert_eq!(ui.order, Some(0)); }
2381 #[test]
2382 fn test_field_schema_with_title_and_description() {
2383 let yaml = r#"
2385title: "Field Title"
2386description: "Detailed field description"
2387type: "string"
2388examples:
2389 - "Example value"
2390ui:
2391 group: "Test Group"
2392"#;
2393 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2394 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2395 let schema = FieldSchema::from_quill_value("test_field".to_string(), &quill_value).unwrap();
2396
2397 assert_eq!(schema.title, Some("Field Title".to_string()));
2398 assert_eq!(schema.description, "Detailed field description");
2399
2400 assert_eq!(
2401 schema
2402 .examples
2403 .as_ref()
2404 .and_then(|v| v.as_array())
2405 .and_then(|arr| arr.first())
2406 .and_then(|v| v.as_str()),
2407 Some("Example value")
2408 );
2409
2410 let ui = schema.ui.as_ref().unwrap();
2411 assert_eq!(ui.group, Some("Test Group".to_string()));
2412 }
2413
2414 #[test]
2415 fn test_parse_card_field_type() {
2416 let yaml = r#"
2418type: "string"
2419title: "Simple Field"
2420description: "A simple string field"
2421"#;
2422 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2423 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2424 let schema =
2425 FieldSchema::from_quill_value("simple_field".to_string(), &quill_value).unwrap();
2426
2427 assert_eq!(schema.name, "simple_field");
2428 assert_eq!(schema.r#type, Some(FieldType::String));
2429 assert_eq!(schema.title, Some("Simple Field".to_string()));
2430 assert_eq!(schema.description, "A simple string field");
2431 }
2432
2433 #[test]
2434 fn test_parse_card_with_fields_in_toml() {
2435 let toml_content = r#"[Quill]
2437name = "cards-fields-test"
2438backend = "typst"
2439description = "Test [cards.X.fields.Y] syntax"
2440
2441[cards.endorsements]
2442title = "Endorsements"
2443description = "Chain of endorsements"
2444
2445[cards.endorsements.fields.name]
2446type = "string"
2447title = "Endorser Name"
2448description = "Name of the endorsing official"
2449required = true
2450
2451[cards.endorsements.fields.org]
2452type = "string"
2453title = "Organization"
2454description = "Endorser's organization"
2455default = "Unknown"
2456"#;
2457
2458 let config = QuillConfig::from_toml(toml_content).unwrap();
2459
2460 assert!(config.cards.contains_key("endorsements"));
2462 let card = config.cards.get("endorsements").unwrap();
2463
2464 assert_eq!(card.name, "endorsements");
2465 assert_eq!(card.title, Some("Endorsements".to_string()));
2466 assert_eq!(card.description, "Chain of endorsements");
2467
2468 assert_eq!(card.fields.len(), 2);
2470
2471 let name_field = card.fields.get("name").unwrap();
2472 assert_eq!(name_field.r#type, Some(FieldType::String));
2473 assert_eq!(name_field.title, Some("Endorser Name".to_string()));
2474 assert!(name_field.required);
2475
2476 let org_field = card.fields.get("org").unwrap();
2477 assert_eq!(org_field.r#type, Some(FieldType::String));
2478 assert!(org_field.default.is_some());
2479 assert_eq!(
2480 org_field.default.as_ref().unwrap().as_str(),
2481 Some("Unknown")
2482 );
2483 }
2484
2485 #[test]
2486 fn test_field_schema_ignores_unknown_keys() {
2487 let yaml = r#"
2489type: "string"
2490description: "A string field"
2491items:
2492 sub_field:
2493 type: "string"
2494 description: "Nested field"
2495"#;
2496 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2497 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
2498 let result = FieldSchema::from_quill_value("author".to_string(), &quill_value);
2500
2501 assert!(result.is_ok());
2503 let schema = result.unwrap();
2504 assert_eq!(schema.r#type, Some(FieldType::String));
2505 }
2506
2507 #[test]
2508 fn test_quill_config_with_cards_section() {
2509 let toml_content = r#"[Quill]
2510name = "cards-test"
2511backend = "typst"
2512description = "Test [cards] section"
2513
2514[fields.regular]
2515description = "Regular field"
2516type = "string"
2517
2518[cards.indorsements]
2519title = "Routing Indorsements"
2520description = "Chain of endorsements"
2521
2522[cards.indorsements.fields.name]
2523title = "Name"
2524type = "string"
2525description = "Name field"
2526"#;
2527
2528 let config = QuillConfig::from_toml(toml_content).unwrap();
2529
2530 assert!(config.fields.contains_key("regular"));
2532 let regular = config.fields.get("regular").unwrap();
2533 assert_eq!(regular.r#type, Some(FieldType::String));
2534
2535 assert!(config.cards.contains_key("indorsements"));
2537 let card = config.cards.get("indorsements").unwrap();
2538 assert_eq!(card.title, Some("Routing Indorsements".to_string()));
2539 assert_eq!(card.description, "Chain of endorsements");
2540 assert!(card.fields.contains_key("name"));
2541 }
2542
2543 #[test]
2544 fn test_quill_config_cards_empty_fields() {
2545 let toml_content = r#"[Quill]
2547name = "cards-empty-fields-test"
2548backend = "typst"
2549description = "Test cards without fields"
2550
2551[cards.myscope]
2552description = "My scope"
2553"#;
2554
2555 let config = QuillConfig::from_toml(toml_content).unwrap();
2556 let card = config.cards.get("myscope").unwrap();
2557 assert_eq!(card.name, "myscope");
2558 assert_eq!(card.description, "My scope");
2559 assert!(card.fields.is_empty());
2560 }
2561
2562 #[test]
2563 fn test_quill_config_card_collision() {
2564 let toml_content = r#"[Quill]
2566name = "collision-test"
2567backend = "typst"
2568description = "Test collision"
2569
2570[fields.conflict]
2571description = "Field"
2572type = "string"
2573
2574[cards.conflict]
2575description = "Card"
2576items = {}
2577"#;
2578
2579 let result = QuillConfig::from_toml(toml_content);
2580 assert!(result.is_err());
2581 assert!(result
2582 .unwrap_err()
2583 .to_string()
2584 .contains("conflicts with an existing field name"));
2585 }
2586
2587 #[test]
2588 fn test_quill_config_ordering_with_cards() {
2589 let toml_content = r#"[Quill]
2591name = "ordering-test"
2592backend = "typst"
2593description = "Test ordering"
2594
2595[fields.first]
2596description = "First"
2597
2598[cards.second]
2599description = "Second"
2600
2601[fields.zero]
2602description = "Zero"
2603"#;
2604
2605 let config = QuillConfig::from_toml(toml_content).unwrap();
2606
2607 let first = config.fields.get("first").unwrap();
2608 let zero = config.fields.get("zero").unwrap();
2609 let second = config.cards.get("second").unwrap(); let ord_first = first.ui.as_ref().unwrap().order.unwrap();
2613 let ord_zero = zero.ui.as_ref().unwrap().order.unwrap();
2614 let ord_second = second.ui.as_ref().unwrap().order.unwrap();
2615
2616 assert!(ord_first < ord_second);
2621 assert!(ord_zero < ord_second);
2622
2623 assert!(ord_first < ord_zero);
2625
2626 assert_eq!(ord_first, 0);
2627 assert_eq!(ord_zero, 1);
2628 assert_eq!(ord_second, 2);
2629 }
2630}