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