1use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::value::QuillValue;
10
11pub mod field_key {
15 pub const NAME: &str = "name";
17 pub const TITLE: &str = "title";
19 pub const TYPE: &str = "type";
21 pub const DESCRIPTION: &str = "description";
23 pub const DEFAULT: &str = "default";
25 pub const EXAMPLES: &str = "examples";
27 pub const UI: &str = "ui";
29 pub const REQUIRED: &str = "required";
31 pub const ENUM: &str = "enum";
33 pub const FORMAT: &str = "format";
35}
36
37pub mod ui_key {
39 pub const GROUP: &str = "group";
41 pub const ORDER: &str = "order";
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct UiSchema {
49 pub group: Option<String>,
51 pub order: Option<i32>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
57pub struct CardSchema {
58 pub name: String,
60 pub title: Option<String>,
62 pub description: String,
64 pub fields: HashMap<String, FieldSchema>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum FieldType {
72 String,
74 #[serde(alias = "str")]
76 Str,
77 Number,
79 Boolean,
81 Array,
83 Dict,
85 Date,
87 DateTime,
89}
90
91impl FieldType {
92 pub fn from_str(s: &str) -> Option<Self> {
94 match s {
95 "string" => Some(FieldType::String),
96 "str" => Some(FieldType::Str),
97 "number" => Some(FieldType::Number),
98 "boolean" => Some(FieldType::Boolean),
99 "array" => Some(FieldType::Array),
100 "dict" => Some(FieldType::Dict),
101 "date" => Some(FieldType::Date),
102 "datetime" => Some(FieldType::DateTime),
103 _ => None,
104 }
105 }
106
107 pub fn as_str(&self) -> &'static str {
109 match self {
110 FieldType::String => "string",
111 FieldType::Str => "str",
112 FieldType::Number => "number",
113 FieldType::Boolean => "boolean",
114 FieldType::Array => "array",
115 FieldType::Dict => "dict",
116 FieldType::Date => "date",
117 FieldType::DateTime => "datetime",
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq)]
124pub struct FieldSchema {
125 pub name: String,
126 pub title: Option<String>,
128 pub r#type: Option<FieldType>,
130 pub description: String,
132 pub default: Option<QuillValue>,
134 pub examples: Option<QuillValue>,
136 pub ui: Option<UiSchema>,
138 pub required: bool,
140 pub enum_values: Option<Vec<String>>,
142}
143
144#[derive(Debug, Deserialize)]
145#[serde(deny_unknown_fields)]
146struct FieldSchemaDef {
147 pub title: Option<String>,
148 pub r#type: Option<FieldType>,
149 #[serde(default)]
150 pub description: String,
151 pub default: Option<QuillValue>,
152 pub examples: Option<QuillValue>,
153 pub ui: Option<UiSchema>,
154 #[serde(default)]
155 pub required: bool,
156 #[serde(rename = "enum")]
157 pub enum_values: Option<Vec<String>>,
158}
159
160impl FieldSchema {
161 pub fn new(name: String, description: String) -> Self {
163 Self {
164 name,
165 title: None,
166 r#type: None,
167 description,
168 default: None,
169 examples: None,
170 ui: None,
171 required: false,
172 enum_values: None,
173 }
174 }
175
176 pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
178 let def: FieldSchemaDef = serde_json::from_value(value.clone().into_json())
179 .map_err(|e| format!("Failed to parse field schema: {}", e))?;
180
181 Ok(Self {
182 name: key,
183 title: def.title,
184 r#type: def.r#type,
185 description: def.description,
186 default: def.default,
187 examples: def.examples,
188 ui: def.ui,
189 required: def.required,
190 enum_values: def.enum_values,
191 })
192 }
193}
194
195#[derive(Debug, Clone)]
197pub enum FileTreeNode {
198 File {
200 contents: Vec<u8>,
202 },
203 Directory {
205 files: HashMap<String, FileTreeNode>,
207 },
208}
209
210impl FileTreeNode {
211 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
213 let path = path.as_ref();
214
215 if path == Path::new("") {
217 return Some(self);
218 }
219
220 let components: Vec<_> = path
222 .components()
223 .filter_map(|c| {
224 if let std::path::Component::Normal(s) = c {
225 s.to_str()
226 } else {
227 None
228 }
229 })
230 .collect();
231
232 if components.is_empty() {
233 return Some(self);
234 }
235
236 let mut current_node = self;
238 for component in components {
239 match current_node {
240 FileTreeNode::Directory { files } => {
241 current_node = files.get(component)?;
242 }
243 FileTreeNode::File { .. } => {
244 return None; }
246 }
247 }
248
249 Some(current_node)
250 }
251
252 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
254 match self.get_node(path)? {
255 FileTreeNode::File { contents } => Some(contents.as_slice()),
256 FileTreeNode::Directory { .. } => None,
257 }
258 }
259
260 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
262 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
263 }
264
265 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
267 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
268 }
269
270 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
272 match self.get_node(dir_path) {
273 Some(FileTreeNode::Directory { files }) => files
274 .iter()
275 .filter_map(|(name, node)| {
276 if matches!(node, FileTreeNode::File { .. }) {
277 Some(name.clone())
278 } else {
279 None
280 }
281 })
282 .collect(),
283 _ => Vec::new(),
284 }
285 }
286
287 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
289 match self.get_node(dir_path) {
290 Some(FileTreeNode::Directory { files }) => files
291 .iter()
292 .filter_map(|(name, node)| {
293 if matches!(node, FileTreeNode::Directory { .. }) {
294 Some(name.clone())
295 } else {
296 None
297 }
298 })
299 .collect(),
300 _ => Vec::new(),
301 }
302 }
303
304 pub fn insert<P: AsRef<Path>>(
306 &mut self,
307 path: P,
308 node: FileTreeNode,
309 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
310 let path = path.as_ref();
311
312 let components: Vec<_> = path
314 .components()
315 .filter_map(|c| {
316 if let std::path::Component::Normal(s) = c {
317 s.to_str().map(|s| s.to_string())
318 } else {
319 None
320 }
321 })
322 .collect();
323
324 if components.is_empty() {
325 return Err("Cannot insert at root path".into());
326 }
327
328 let mut current_node = self;
330 for component in &components[..components.len() - 1] {
331 match current_node {
332 FileTreeNode::Directory { files } => {
333 current_node =
334 files
335 .entry(component.clone())
336 .or_insert_with(|| FileTreeNode::Directory {
337 files: HashMap::new(),
338 });
339 }
340 FileTreeNode::File { .. } => {
341 return Err("Cannot traverse into a file".into());
342 }
343 }
344 }
345
346 let filename = &components[components.len() - 1];
348 match current_node {
349 FileTreeNode::Directory { files } => {
350 files.insert(filename.clone(), node);
351 Ok(())
352 }
353 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
354 }
355 }
356
357 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
359 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
360 Ok(FileTreeNode::File {
362 contents: contents_str.as_bytes().to_vec(),
363 })
364 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
365 let contents: Vec<u8> = bytes_array
367 .iter()
368 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
369 .collect();
370 Ok(FileTreeNode::File { contents })
371 } else if let Some(obj) = value.as_object() {
372 let mut files = HashMap::new();
374 for (name, child_value) in obj {
375 files.insert(name.clone(), Self::from_json_value(child_value)?);
376 }
377 Ok(FileTreeNode::Directory { files })
379 } else {
380 Err(format!("Invalid file tree node: {:?}", value).into())
381 }
382 }
383
384 pub fn print_tree(&self) -> String {
385 self.__print_tree("", "", true)
386 }
387
388 pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
389 let mut result = String::new();
390
391 let connector = if is_last { "└── " } else { "├── " };
393 let extension = if is_last { " " } else { "│ " };
394
395 match self {
396 FileTreeNode::File { .. } => {
397 result.push_str(&format!("{}{}{}\n", prefix, connector, name));
398 }
399 FileTreeNode::Directory { files } => {
400 result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
402
403 let child_prefix = format!("{}{}", prefix, extension);
404 let count = files.len();
405
406 for (i, (child_name, node)) in files.iter().enumerate() {
407 let is_last_child = i == count - 1;
408 result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
409 }
410 }
411 }
412
413 result
414 }
415}
416
417#[derive(Debug, Clone)]
419pub struct QuillIgnore {
420 patterns: Vec<String>,
421}
422
423impl QuillIgnore {
424 pub fn new(patterns: Vec<String>) -> Self {
426 Self { patterns }
427 }
428
429 pub fn from_content(content: &str) -> Self {
431 let patterns = content
432 .lines()
433 .map(|line| line.trim())
434 .filter(|line| !line.is_empty() && !line.starts_with('#'))
435 .map(|line| line.to_string())
436 .collect();
437 Self::new(patterns)
438 }
439
440 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
442 let path = path.as_ref();
443 let path_str = path.to_string_lossy();
444
445 for pattern in &self.patterns {
446 if self.matches_pattern(pattern, &path_str) {
447 return true;
448 }
449 }
450 false
451 }
452
453 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
455 if let Some(pattern_prefix) = pattern.strip_suffix('/') {
457 return path.starts_with(pattern_prefix)
458 && (path.len() == pattern_prefix.len()
459 || path.chars().nth(pattern_prefix.len()) == Some('/'));
460 }
461
462 if !pattern.contains('*') {
464 return path == pattern || path.ends_with(&format!("/{}", pattern));
465 }
466
467 if pattern == "*" {
469 return true;
470 }
471
472 let pattern_parts: Vec<&str> = pattern.split('*').collect();
474 if pattern_parts.len() == 2 {
475 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
476 if prefix.is_empty() {
477 return path.ends_with(suffix);
478 } else if suffix.is_empty() {
479 return path.starts_with(prefix);
480 } else {
481 return path.starts_with(prefix) && path.ends_with(suffix);
482 }
483 }
484
485 false
486 }
487}
488
489#[derive(Debug, Clone)]
491pub struct Quill {
492 pub metadata: HashMap<String, QuillValue>,
494 pub name: String,
496 pub backend: String,
498 pub plate: Option<String>,
500 pub example: Option<String>,
502 pub schema: QuillValue,
504 pub defaults: HashMap<String, QuillValue>,
506 pub examples: HashMap<String, Vec<QuillValue>>,
508 pub files: FileTreeNode,
510}
511
512#[derive(Debug, Clone)]
514pub struct QuillConfig {
515 pub name: String,
517 pub description: String,
519 pub backend: String,
521 pub version: Option<String>,
523 pub author: Option<String>,
525 pub example_file: Option<String>,
527 pub plate_file: Option<String>,
529 pub fields: HashMap<String, FieldSchema>,
531 pub cards: HashMap<String, CardSchema>,
533 pub metadata: HashMap<String, QuillValue>,
535 pub typst_config: HashMap<String, QuillValue>,
537}
538
539#[derive(Debug, Deserialize)]
540#[serde(deny_unknown_fields)]
541struct CardSchemaDef {
542 pub title: Option<String>,
543 #[serde(default)]
544 pub description: String,
545 pub fields: Option<toml::value::Table>,
546}
547
548impl QuillConfig {
549 fn parse_fields_with_order(
559 fields_table: &toml::value::Table,
560 key_order: &[String],
561 context: &str,
562 ) -> HashMap<String, FieldSchema> {
563 let mut fields = HashMap::new();
564 let mut fallback_counter = 0;
565
566 for (field_name, field_value) in fields_table {
567 let order = if let Some(idx) = key_order.iter().position(|k| k == field_name) {
569 idx as i32
570 } else {
571 let o = key_order.len() as i32 + fallback_counter;
572 fallback_counter += 1;
573 o
574 };
575
576 match QuillValue::from_toml(field_value) {
577 Ok(quill_value) => {
578 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
579 Ok(mut schema) => {
580 if schema.ui.is_none() {
582 schema.ui = Some(UiSchema {
583 group: None,
584 order: Some(order),
585 });
586 } else if let Some(ui) = &mut schema.ui {
587 ui.order = Some(order);
588 }
589
590 fields.insert(field_name.clone(), schema);
591 }
592 Err(e) => {
593 eprintln!(
594 "Warning: Failed to parse {} '{}': {}",
595 context, field_name, e
596 );
597 }
598 }
599 }
600 Err(e) => {
601 eprintln!(
602 "Warning: Failed to convert {} '{}': {}",
603 context, field_name, e
604 );
605 }
606 }
607 }
608
609 fields
610 }
611
612 fn get_key_order(doc: Option<&toml_edit::DocumentMut>, path: &[&str]) -> Vec<String> {
616 doc.and_then(|d| {
617 let mut current: &dyn toml_edit::TableLike = d.as_table();
618 for segment in path {
619 current = current.get(*segment)?.as_table()?;
620 }
621 Some(current.iter().map(|(k, _)| k.to_string()).collect())
622 })
623 .unwrap_or_default()
624 }
625
626 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
628 let quill_toml: toml::Value = toml::from_str(toml_content)
629 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
630
631 let toml_edit_doc = toml_content.parse::<toml_edit::DocumentMut>().ok();
633
634 let quill_section = quill_toml
636 .get("Quill")
637 .ok_or("Missing required [Quill] section in Quill.toml")?;
638
639 let name = quill_section
641 .get("name")
642 .and_then(|v| v.as_str())
643 .ok_or("Missing required 'name' field in [Quill] section")?
644 .to_string();
645
646 let backend = quill_section
647 .get("backend")
648 .and_then(|v| v.as_str())
649 .ok_or("Missing required 'backend' field in [Quill] section")?
650 .to_string();
651
652 let description = quill_section
653 .get("description")
654 .and_then(|v| v.as_str())
655 .ok_or("Missing required 'description' field in [Quill] section")?;
656
657 if description.trim().is_empty() {
658 return Err("'description' field in [Quill] section cannot be empty".into());
659 }
660 let description = description.to_string();
661
662 let version = quill_section
664 .get("version")
665 .and_then(|v| v.as_str())
666 .map(|s| s.to_string());
667
668 let author = quill_section
669 .get("author")
670 .and_then(|v| v.as_str())
671 .map(|s| s.to_string());
672
673 let example_file = quill_section
674 .get("example_file")
675 .and_then(|v| v.as_str())
676 .map(|s| s.to_string());
677
678 let plate_file = quill_section
679 .get("plate_file")
680 .and_then(|v| v.as_str())
681 .map(|s| s.to_string());
682
683 let mut metadata = HashMap::new();
685 if let toml::Value::Table(table) = quill_section {
686 for (key, value) in table {
687 if key != "name"
689 && key != "backend"
690 && key != "description"
691 && key != "version"
692 && key != "author"
693 && key != "example_file"
694 && key != "plate_file"
695 {
696 match QuillValue::from_toml(value) {
697 Ok(quill_value) => {
698 metadata.insert(key.clone(), quill_value);
699 }
700 Err(e) => {
701 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
702 }
703 }
704 }
705 }
706 }
707
708 let mut typst_config = HashMap::new();
710 if let Some(toml::Value::Table(table)) = quill_toml.get("typst") {
711 for (key, value) in table {
712 match QuillValue::from_toml(value) {
713 Ok(quill_value) => {
714 typst_config.insert(key.clone(), quill_value);
715 }
716 Err(e) => {
717 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
718 }
719 }
720 }
721 }
722
723 let fields = if let Some(toml::Value::Table(fields_table)) = quill_toml.get("fields") {
725 let field_order = Self::get_key_order(toml_edit_doc.as_ref(), &["fields"]);
726 Self::parse_fields_with_order(fields_table, &field_order, "field schema")
727 } else {
728 HashMap::new()
729 };
730
731 let mut cards: HashMap<String, CardSchema> = HashMap::new();
734 if let Some(cards_value) = quill_toml.get("cards") {
735 let cards_table = cards_value
739 .as_table()
740 .ok_or("'cards' section must be a table")?;
741
742 for (card_name, card_value) in cards_table {
743 let card_def: CardSchemaDef = card_value
745 .clone()
746 .try_into()
747 .map_err(|e| format!("Failed to parse card '{}': {}", card_name, e))?;
748
749 let card_fields = if let Some(card_fields_table) = &card_def.fields {
751 let card_field_order = Self::get_key_order(
752 toml_edit_doc.as_ref(),
753 &["cards", card_name, "fields"],
754 );
755
756 Self::parse_fields_with_order(
757 card_fields_table,
758 &card_field_order,
759 &format!("card '{}' field", card_name),
760 )
761 } else {
762 HashMap::new()
763 };
764
765 let card_schema = CardSchema {
766 name: card_name.clone(),
767 title: card_def.title,
768 description: card_def.description,
769 fields: card_fields,
770 };
771
772 cards.insert(card_name.clone(), card_schema);
773 }
774 }
775
776 Ok(QuillConfig {
777 name,
778 description,
779 backend,
780 version,
781 author,
782 example_file,
783 plate_file,
784 fields,
785 cards,
786 metadata,
787 typst_config,
788 })
789 }
790}
791
792impl Quill {
793 pub fn from_path<P: AsRef<std::path::Path>>(
795 path: P,
796 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
797 use std::fs;
798
799 let path = path.as_ref();
800 let name = path
801 .file_name()
802 .and_then(|n| n.to_str())
803 .unwrap_or("unnamed")
804 .to_string();
805
806 let quillignore_path = path.join(".quillignore");
808 let ignore = if quillignore_path.exists() {
809 let ignore_content = fs::read_to_string(&quillignore_path)
810 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
811 QuillIgnore::from_content(&ignore_content)
812 } else {
813 QuillIgnore::new(vec![
815 ".git/".to_string(),
816 ".gitignore".to_string(),
817 ".quillignore".to_string(),
818 "target/".to_string(),
819 "node_modules/".to_string(),
820 ])
821 };
822
823 let root = Self::load_directory_as_tree(path, path, &ignore)?;
825
826 Self::from_tree(root, Some(name))
828 }
829
830 pub fn from_tree(
847 root: FileTreeNode,
848 _default_name: Option<String>,
849 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
850 let quill_toml_bytes = root
852 .get_file("Quill.toml")
853 .ok_or("Quill.toml not found in file tree")?;
854
855 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
856 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
857
858 let config = QuillConfig::from_toml(&quill_toml_content)?;
860
861 Self::from_config(config, root)
863 }
864
865 fn from_config(
882 config: QuillConfig,
883 root: FileTreeNode,
884 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
885 let mut metadata = config.metadata.clone();
887
888 metadata.insert(
890 "backend".to_string(),
891 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
892 );
893
894 metadata.insert(
896 "description".to_string(),
897 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
898 );
899
900 if let Some(ref author) = config.author {
902 metadata.insert(
903 "author".to_string(),
904 QuillValue::from_json(serde_json::Value::String(author.clone())),
905 );
906 }
907
908 for (key, value) in &config.typst_config {
910 metadata.insert(format!("typst_{}", key), value.clone());
911 }
912
913 let schema = crate::schema::build_schema(&config.fields, &config.cards)
915 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?;
916
917 let plate_content: Option<String> = if let Some(ref plate_file_name) = config.plate_file {
919 let plate_bytes = root.get_file(plate_file_name).ok_or_else(|| {
920 format!("Plate file '{}' not found in file tree", plate_file_name)
921 })?;
922
923 let content = String::from_utf8(plate_bytes.to_vec()).map_err(|e| {
924 format!("Plate file '{}' is not valid UTF-8: {}", plate_file_name, e)
925 })?;
926 Some(content)
927 } else {
928 None
930 };
931
932 let example_content = if let Some(ref example_file_name) = config.example_file {
934 root.get_file(example_file_name).and_then(|bytes| {
935 String::from_utf8(bytes.to_vec())
936 .map_err(|e| {
937 eprintln!(
938 "Warning: Example file '{}' is not valid UTF-8: {}",
939 example_file_name, e
940 );
941 e
942 })
943 .ok()
944 })
945 } else {
946 None
947 };
948
949 let defaults = crate::schema::extract_defaults_from_schema(&schema);
951 let examples = crate::schema::extract_examples_from_schema(&schema);
952
953 let quill = Quill {
954 metadata,
955 name: config.name,
956 backend: config.backend,
957 plate: plate_content,
958 example: example_content,
959 schema,
960 defaults,
961 examples,
962 files: root,
963 };
964
965 Ok(quill)
966 }
967
968 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
975 use serde_json::Value as JsonValue;
976
977 let json: JsonValue =
978 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
979
980 let obj = json.as_object().ok_or("Root must be an object")?;
981
982 let default_name = obj
984 .get("metadata")
985 .and_then(|m| m.get("name"))
986 .and_then(|v| v.as_str())
987 .map(String::from);
988
989 let files_obj = obj
991 .get("files")
992 .and_then(|v| v.as_object())
993 .ok_or("Missing or invalid 'files' key")?;
994
995 let mut root_files = HashMap::new();
997 for (key, value) in files_obj {
998 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
999 }
1000
1001 let root = FileTreeNode::Directory { files: root_files };
1002
1003 Self::from_tree(root, default_name)
1005 }
1006
1007 fn load_directory_as_tree(
1009 current_dir: &Path,
1010 base_dir: &Path,
1011 ignore: &QuillIgnore,
1012 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
1013 use std::fs;
1014
1015 if !current_dir.exists() {
1016 return Ok(FileTreeNode::Directory {
1017 files: HashMap::new(),
1018 });
1019 }
1020
1021 let mut files = HashMap::new();
1022
1023 for entry in fs::read_dir(current_dir)? {
1024 let entry = entry?;
1025 let path = entry.path();
1026 let relative_path = path
1027 .strip_prefix(base_dir)
1028 .map_err(|e| format!("Failed to get relative path: {}", e))?
1029 .to_path_buf();
1030
1031 if ignore.is_ignored(&relative_path) {
1033 continue;
1034 }
1035
1036 let filename = path
1038 .file_name()
1039 .and_then(|n| n.to_str())
1040 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
1041 .to_string();
1042
1043 if path.is_file() {
1044 let contents = fs::read(&path)
1045 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
1046
1047 files.insert(filename, FileTreeNode::File { contents });
1048 } else if path.is_dir() {
1049 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
1051 files.insert(filename, subdir_tree);
1052 }
1053 }
1054
1055 Ok(FileTreeNode::Directory { files })
1056 }
1057
1058 pub fn typst_packages(&self) -> Vec<String> {
1060 self.metadata
1061 .get("typst_packages")
1062 .and_then(|v| v.as_array())
1063 .map(|arr| {
1064 arr.iter()
1065 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1066 .collect()
1067 })
1068 .unwrap_or_default()
1069 }
1070
1071 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
1079 &self.defaults
1080 }
1081
1082 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
1087 &self.examples
1088 }
1089
1090 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
1092 self.files.get_file(path)
1093 }
1094
1095 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1097 self.files.file_exists(path)
1098 }
1099
1100 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1102 self.files.dir_exists(path)
1103 }
1104
1105 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1107 self.files.list_files(path)
1108 }
1109
1110 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1112 self.files.list_subdirectories(path)
1113 }
1114
1115 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1117 let dir_path = dir_path.as_ref();
1118 let filenames = self.files.list_files(dir_path);
1119
1120 filenames
1122 .iter()
1123 .map(|name| {
1124 if dir_path == Path::new("") {
1125 PathBuf::from(name)
1126 } else {
1127 dir_path.join(name)
1128 }
1129 })
1130 .collect()
1131 }
1132
1133 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1135 let dir_path = dir_path.as_ref();
1136 let subdirs = self.files.list_subdirectories(dir_path);
1137
1138 subdirs
1140 .iter()
1141 .map(|name| {
1142 if dir_path == Path::new("") {
1143 PathBuf::from(name)
1144 } else {
1145 dir_path.join(name)
1146 }
1147 })
1148 .collect()
1149 }
1150
1151 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1153 let pattern_str = pattern.as_ref().to_string_lossy();
1154 let mut matches = Vec::new();
1155
1156 let glob_pattern = match glob::Pattern::new(&pattern_str) {
1158 Ok(pat) => pat,
1159 Err(_) => return matches, };
1161
1162 Self::find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1164
1165 matches.sort();
1166 matches
1167 }
1168
1169 fn find_files_recursive(
1171 node: &FileTreeNode,
1172 current_path: &Path,
1173 pattern: &glob::Pattern,
1174 matches: &mut Vec<PathBuf>,
1175 ) {
1176 match node {
1177 FileTreeNode::File { .. } => {
1178 let path_str = current_path.to_string_lossy();
1179 if pattern.matches(&path_str) {
1180 matches.push(current_path.to_path_buf());
1181 }
1182 }
1183 FileTreeNode::Directory { files } => {
1184 for (name, child_node) in files {
1185 let child_path = if current_path == Path::new("") {
1186 PathBuf::from(name)
1187 } else {
1188 current_path.join(name)
1189 };
1190 Self::find_files_recursive(child_node, &child_path, pattern, matches);
1191 }
1192 }
1193 }
1194 }
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200 use std::fs;
1201 use tempfile::TempDir;
1202
1203 #[test]
1204 fn test_quillignore_parsing() {
1205 let ignore_content = r#"
1206# This is a comment
1207*.tmp
1208target/
1209node_modules/
1210.git/
1211"#;
1212 let ignore = QuillIgnore::from_content(ignore_content);
1213 assert_eq!(ignore.patterns.len(), 4);
1214 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1215 assert!(ignore.patterns.contains(&"target/".to_string()));
1216 }
1217
1218 #[test]
1219 fn test_quillignore_matching() {
1220 let ignore = QuillIgnore::new(vec![
1221 "*.tmp".to_string(),
1222 "target/".to_string(),
1223 "node_modules/".to_string(),
1224 ".git/".to_string(),
1225 ]);
1226
1227 assert!(ignore.is_ignored("test.tmp"));
1229 assert!(ignore.is_ignored("path/to/file.tmp"));
1230 assert!(!ignore.is_ignored("test.txt"));
1231
1232 assert!(ignore.is_ignored("target"));
1234 assert!(ignore.is_ignored("target/debug"));
1235 assert!(ignore.is_ignored("target/debug/deps"));
1236 assert!(!ignore.is_ignored("src/target.rs"));
1237
1238 assert!(ignore.is_ignored("node_modules"));
1239 assert!(ignore.is_ignored("node_modules/package"));
1240 assert!(!ignore.is_ignored("my_node_modules"));
1241 }
1242
1243 #[test]
1244 fn test_in_memory_file_system() {
1245 let temp_dir = TempDir::new().unwrap();
1246 let quill_dir = temp_dir.path();
1247
1248 fs::write(
1250 quill_dir.join("Quill.toml"),
1251 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1252 )
1253 .unwrap();
1254 fs::write(quill_dir.join("plate.typ"), "test plate").unwrap();
1255
1256 let assets_dir = quill_dir.join("assets");
1257 fs::create_dir_all(&assets_dir).unwrap();
1258 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1259
1260 let packages_dir = quill_dir.join("packages");
1261 fs::create_dir_all(&packages_dir).unwrap();
1262 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1263
1264 let quill = Quill::from_path(quill_dir).unwrap();
1266
1267 assert!(quill.file_exists("plate.typ"));
1269 assert!(quill.file_exists("assets/test.txt"));
1270 assert!(quill.file_exists("packages/package.typ"));
1271 assert!(!quill.file_exists("nonexistent.txt"));
1272
1273 let asset_content = quill.get_file("assets/test.txt").unwrap();
1275 assert_eq!(asset_content, b"asset content");
1276
1277 let asset_files = quill.list_directory("assets");
1279 assert_eq!(asset_files.len(), 1);
1280 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1281 }
1282
1283 #[test]
1284 fn test_quillignore_integration() {
1285 let temp_dir = TempDir::new().unwrap();
1286 let quill_dir = temp_dir.path();
1287
1288 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1290
1291 fs::write(
1293 quill_dir.join("Quill.toml"),
1294 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1295 )
1296 .unwrap();
1297 fs::write(quill_dir.join("plate.typ"), "test template").unwrap();
1298 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1299
1300 let target_dir = quill_dir.join("target");
1301 fs::create_dir_all(&target_dir).unwrap();
1302 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1303
1304 let quill = Quill::from_path(quill_dir).unwrap();
1306
1307 assert!(quill.file_exists("plate.typ"));
1309 assert!(!quill.file_exists("should_ignore.tmp"));
1310 assert!(!quill.file_exists("target/debug.txt"));
1311 }
1312
1313 #[test]
1314 fn test_find_files_pattern() {
1315 let temp_dir = TempDir::new().unwrap();
1316 let quill_dir = temp_dir.path();
1317
1318 fs::write(
1320 quill_dir.join("Quill.toml"),
1321 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"",
1322 )
1323 .unwrap();
1324 fs::write(quill_dir.join("plate.typ"), "template").unwrap();
1325
1326 let assets_dir = quill_dir.join("assets");
1327 fs::create_dir_all(&assets_dir).unwrap();
1328 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1329 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1330
1331 let fonts_dir = assets_dir.join("fonts");
1332 fs::create_dir_all(&fonts_dir).unwrap();
1333 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1334
1335 let quill = Quill::from_path(quill_dir).unwrap();
1337
1338 let all_assets = quill.find_files("assets/*");
1340 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1343 assert_eq!(typ_files.len(), 1);
1344 assert!(typ_files.contains(&PathBuf::from("plate.typ")));
1345 }
1346
1347 #[test]
1348 fn test_new_standardized_toml_format() {
1349 let temp_dir = TempDir::new().unwrap();
1350 let quill_dir = temp_dir.path();
1351
1352 let toml_content = r#"[Quill]
1354name = "my-custom-quill"
1355backend = "typst"
1356plate_file = "custom_plate.typ"
1357description = "Test quill with new format"
1358author = "Test Author"
1359"#;
1360 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1361 fs::write(
1362 quill_dir.join("custom_plate.typ"),
1363 "= Custom Template\n\nThis is a custom template.",
1364 )
1365 .unwrap();
1366
1367 let quill = Quill::from_path(quill_dir).unwrap();
1369
1370 assert_eq!(quill.name, "my-custom-quill");
1372
1373 assert!(quill.metadata.contains_key("backend"));
1375 if let Some(backend_val) = quill.metadata.get("backend") {
1376 if let Some(backend_str) = backend_val.as_str() {
1377 assert_eq!(backend_str, "typst");
1378 } else {
1379 panic!("Backend value is not a string");
1380 }
1381 }
1382
1383 assert!(quill.metadata.contains_key("description"));
1385 assert!(quill.metadata.contains_key("author"));
1386 assert!(!quill.metadata.contains_key("version")); assert!(quill.plate.unwrap().contains("Custom Template"));
1390 }
1391
1392 #[test]
1393 fn test_typst_packages_parsing() {
1394 let temp_dir = TempDir::new().unwrap();
1395 let quill_dir = temp_dir.path();
1396
1397 let toml_content = r#"
1398[Quill]
1399name = "test-quill"
1400backend = "typst"
1401plate_file = "plate.typ"
1402description = "Test quill for packages"
1403
1404[typst]
1405packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1406"#;
1407
1408 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1409 fs::write(quill_dir.join("plate.typ"), "test").unwrap();
1410
1411 let quill = Quill::from_path(quill_dir).unwrap();
1412 let packages = quill.typst_packages();
1413
1414 assert_eq!(packages.len(), 2);
1415 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1416 assert_eq!(packages[1], "@preview/example:1.0.0");
1417 }
1418
1419 #[test]
1420 fn test_template_loading() {
1421 let temp_dir = TempDir::new().unwrap();
1422 let quill_dir = temp_dir.path();
1423
1424 let toml_content = r#"[Quill]
1426name = "test-with-template"
1427backend = "typst"
1428plate_file = "plate.typ"
1429example_file = "example.md"
1430description = "Test quill with template"
1431"#;
1432 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1433 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1434 fs::write(
1435 quill_dir.join("example.md"),
1436 "---\ntitle: Test\n---\n\nThis is a test template.",
1437 )
1438 .unwrap();
1439
1440 let quill = Quill::from_path(quill_dir).unwrap();
1442
1443 assert!(quill.example.is_some());
1445 let example = quill.example.unwrap();
1446 assert!(example.contains("title: Test"));
1447 assert!(example.contains("This is a test template"));
1448
1449 assert_eq!(quill.plate.unwrap(), "plate content");
1451 }
1452
1453 #[test]
1454 fn test_template_optional() {
1455 let temp_dir = TempDir::new().unwrap();
1456 let quill_dir = temp_dir.path();
1457
1458 let toml_content = r#"[Quill]
1460name = "test-without-template"
1461backend = "typst"
1462plate_file = "plate.typ"
1463description = "Test quill without template"
1464"#;
1465 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1466 fs::write(quill_dir.join("plate.typ"), "plate content").unwrap();
1467
1468 let quill = Quill::from_path(quill_dir).unwrap();
1470
1471 assert_eq!(quill.example, None);
1473
1474 assert_eq!(quill.plate.unwrap(), "plate content");
1476 }
1477
1478 #[test]
1479 fn test_from_tree() {
1480 let mut root_files = HashMap::new();
1482
1483 let quill_toml = r#"[Quill]
1485name = "test-from-tree"
1486backend = "typst"
1487plate_file = "plate.typ"
1488description = "A test quill from tree"
1489"#;
1490 root_files.insert(
1491 "Quill.toml".to_string(),
1492 FileTreeNode::File {
1493 contents: quill_toml.as_bytes().to_vec(),
1494 },
1495 );
1496
1497 let plate_content = "= Test Template\n\nThis is a test.";
1499 root_files.insert(
1500 "plate.typ".to_string(),
1501 FileTreeNode::File {
1502 contents: plate_content.as_bytes().to_vec(),
1503 },
1504 );
1505
1506 let root = FileTreeNode::Directory { files: root_files };
1507
1508 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1510
1511 assert_eq!(quill.name, "test-from-tree");
1513 assert_eq!(quill.plate.unwrap(), plate_content);
1514 assert!(quill.metadata.contains_key("backend"));
1515 assert!(quill.metadata.contains_key("description"));
1516 }
1517
1518 #[test]
1519 fn test_from_tree_with_template() {
1520 let mut root_files = HashMap::new();
1521
1522 let quill_toml = r#"[Quill]
1524name = "test-tree-template"
1525backend = "typst"
1526plate_file = "plate.typ"
1527example_file = "template.md"
1528description = "Test tree with template"
1529"#;
1530 root_files.insert(
1531 "Quill.toml".to_string(),
1532 FileTreeNode::File {
1533 contents: quill_toml.as_bytes().to_vec(),
1534 },
1535 );
1536
1537 root_files.insert(
1539 "plate.typ".to_string(),
1540 FileTreeNode::File {
1541 contents: b"plate content".to_vec(),
1542 },
1543 );
1544
1545 let template_content = "# {{ title }}\n\n{{ body }}";
1547 root_files.insert(
1548 "template.md".to_string(),
1549 FileTreeNode::File {
1550 contents: template_content.as_bytes().to_vec(),
1551 },
1552 );
1553
1554 let root = FileTreeNode::Directory { files: root_files };
1555
1556 let quill = Quill::from_tree(root, None).unwrap();
1558
1559 assert_eq!(quill.example, Some(template_content.to_string()));
1561 }
1562
1563 #[test]
1564 fn test_from_json() {
1565 let json_str = r#"{
1567 "metadata": {
1568 "name": "test-from-json"
1569 },
1570 "files": {
1571 "Quill.toml": {
1572 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill from JSON\"\n"
1573 },
1574 "plate.typ": {
1575 "contents": "= Test Plate\n\nThis is test content."
1576 }
1577 }
1578 }"#;
1579
1580 let quill = Quill::from_json(json_str).unwrap();
1582
1583 assert_eq!(quill.name, "test-from-json");
1585 assert!(quill.plate.unwrap().contains("Test Plate"));
1586 assert!(quill.metadata.contains_key("backend"));
1587 }
1588
1589 #[test]
1590 fn test_from_json_with_byte_array() {
1591 let json_str = r#"{
1593 "files": {
1594 "Quill.toml": {
1595 "contents": "[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1596 },
1597 "plate.typ": {
1598 "contents": "test plate"
1599 }
1600 }
1601 }"#;
1602
1603 let quill = Quill::from_json(json_str).unwrap();
1605
1606 assert_eq!(quill.name, "test");
1608 assert_eq!(quill.plate.unwrap(), "test plate");
1609 }
1610
1611 #[test]
1612 fn test_from_json_missing_files() {
1613 let json_str = r#"{
1615 "metadata": {
1616 "name": "test"
1617 }
1618 }"#;
1619
1620 let result = Quill::from_json(json_str);
1621 assert!(result.is_err());
1622 assert!(result.unwrap_err().to_string().contains("files"));
1624 }
1625
1626 #[test]
1627 fn test_from_json_tree_structure() {
1628 let json_str = r#"{
1630 "files": {
1631 "Quill.toml": {
1632 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test tree JSON\"\n"
1633 },
1634 "plate.typ": {
1635 "contents": "= Test Plate\n\nTree structure content."
1636 }
1637 }
1638 }"#;
1639
1640 let quill = Quill::from_json(json_str).unwrap();
1641
1642 assert_eq!(quill.name, "test-tree-json");
1643 assert!(quill.plate.unwrap().contains("Tree structure content"));
1644 assert!(quill.metadata.contains_key("backend"));
1645 }
1646
1647 #[test]
1648 fn test_from_json_nested_tree_structure() {
1649 let json_str = r#"{
1651 "files": {
1652 "Quill.toml": {
1653 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Nested test\"\n"
1654 },
1655 "plate.typ": {
1656 "contents": "plate"
1657 },
1658 "src": {
1659 "main.rs": {
1660 "contents": "fn main() {}"
1661 },
1662 "lib.rs": {
1663 "contents": "// lib"
1664 }
1665 }
1666 }
1667 }"#;
1668
1669 let quill = Quill::from_json(json_str).unwrap();
1670
1671 assert_eq!(quill.name, "nested-test");
1672 assert!(quill.file_exists("src/main.rs"));
1674 assert!(quill.file_exists("src/lib.rs"));
1675
1676 let main_rs = quill.get_file("src/main.rs").unwrap();
1677 assert_eq!(main_rs, b"fn main() {}");
1678 }
1679
1680 #[test]
1681 fn test_from_tree_structure_direct() {
1682 let mut root_files = HashMap::new();
1684
1685 root_files.insert(
1686 "Quill.toml".to_string(),
1687 FileTreeNode::File {
1688 contents:
1689 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Direct tree test\"\n"
1690 .to_vec(),
1691 },
1692 );
1693
1694 root_files.insert(
1695 "plate.typ".to_string(),
1696 FileTreeNode::File {
1697 contents: b"plate content".to_vec(),
1698 },
1699 );
1700
1701 let mut src_files = HashMap::new();
1703 src_files.insert(
1704 "main.rs".to_string(),
1705 FileTreeNode::File {
1706 contents: b"fn main() {}".to_vec(),
1707 },
1708 );
1709
1710 root_files.insert(
1711 "src".to_string(),
1712 FileTreeNode::Directory { files: src_files },
1713 );
1714
1715 let root = FileTreeNode::Directory { files: root_files };
1716
1717 let quill = Quill::from_tree(root, None).unwrap();
1718
1719 assert_eq!(quill.name, "direct-tree");
1720 assert!(quill.file_exists("src/main.rs"));
1721 assert!(quill.file_exists("plate.typ"));
1722 }
1723
1724 #[test]
1725 fn test_from_json_with_metadata_override() {
1726 let json_str = r#"{
1728 "metadata": {
1729 "name": "override-name"
1730 },
1731 "files": {
1732 "Quill.toml": {
1733 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"TOML name test\"\n"
1734 },
1735 "plate.typ": {
1736 "contents": "= plate"
1737 }
1738 }
1739 }"#;
1740
1741 let quill = Quill::from_json(json_str).unwrap();
1742 assert_eq!(quill.name, "toml-name");
1745 }
1746
1747 #[test]
1748 fn test_from_json_empty_directory() {
1749 let json_str = r#"{
1751 "files": {
1752 "Quill.toml": {
1753 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Empty directory test\"\n"
1754 },
1755 "plate.typ": {
1756 "contents": "plate"
1757 },
1758 "empty_dir": {}
1759 }
1760 }"#;
1761
1762 let quill = Quill::from_json(json_str).unwrap();
1763 assert_eq!(quill.name, "empty-dir-test");
1764 assert!(quill.dir_exists("empty_dir"));
1765 assert!(!quill.file_exists("empty_dir"));
1766 }
1767
1768 #[test]
1769 fn test_dir_exists_and_list_apis() {
1770 let mut root_files = HashMap::new();
1771
1772 root_files.insert(
1774 "Quill.toml".to_string(),
1775 FileTreeNode::File {
1776 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nplate_file = \"plate.typ\"\ndescription = \"Test quill\"\n"
1777 .to_vec(),
1778 },
1779 );
1780
1781 root_files.insert(
1783 "plate.typ".to_string(),
1784 FileTreeNode::File {
1785 contents: b"plate content".to_vec(),
1786 },
1787 );
1788
1789 let mut assets_files = HashMap::new();
1791 assets_files.insert(
1792 "logo.png".to_string(),
1793 FileTreeNode::File {
1794 contents: vec![137, 80, 78, 71],
1795 },
1796 );
1797 assets_files.insert(
1798 "icon.svg".to_string(),
1799 FileTreeNode::File {
1800 contents: b"<svg></svg>".to_vec(),
1801 },
1802 );
1803
1804 let mut fonts_files = HashMap::new();
1806 fonts_files.insert(
1807 "font.ttf".to_string(),
1808 FileTreeNode::File {
1809 contents: b"font data".to_vec(),
1810 },
1811 );
1812 assets_files.insert(
1813 "fonts".to_string(),
1814 FileTreeNode::Directory { files: fonts_files },
1815 );
1816
1817 root_files.insert(
1818 "assets".to_string(),
1819 FileTreeNode::Directory {
1820 files: assets_files,
1821 },
1822 );
1823
1824 root_files.insert(
1826 "empty".to_string(),
1827 FileTreeNode::Directory {
1828 files: HashMap::new(),
1829 },
1830 );
1831
1832 let root = FileTreeNode::Directory { files: root_files };
1833 let quill = Quill::from_tree(root, None).unwrap();
1834
1835 assert!(quill.dir_exists("assets"));
1837 assert!(quill.dir_exists("assets/fonts"));
1838 assert!(quill.dir_exists("empty"));
1839 assert!(!quill.dir_exists("nonexistent"));
1840 assert!(!quill.dir_exists("plate.typ")); assert!(quill.file_exists("plate.typ"));
1844 assert!(quill.file_exists("assets/logo.png"));
1845 assert!(quill.file_exists("assets/fonts/font.ttf"));
1846 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1850 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1852 assert!(root_files_list.contains(&"plate.typ".to_string()));
1853
1854 let assets_files_list = quill.list_files("assets");
1855 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1857 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1858
1859 let root_subdirs = quill.list_subdirectories("");
1861 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1863 assert!(root_subdirs.contains(&"empty".to_string()));
1864
1865 let assets_subdirs = quill.list_subdirectories("assets");
1866 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1868
1869 let empty_subdirs = quill.list_subdirectories("empty");
1870 assert_eq!(empty_subdirs.len(), 0);
1871 }
1872
1873 #[test]
1874 fn test_field_schemas_parsing() {
1875 let mut root_files = HashMap::new();
1876
1877 let quill_toml = r#"[Quill]
1879name = "taro"
1880backend = "typst"
1881plate_file = "plate.typ"
1882example_file = "taro.md"
1883description = "Test template for field schemas"
1884
1885[fields]
1886author = {description = "Author of document" }
1887ice_cream = {description = "favorite ice cream flavor"}
1888title = {description = "title of document" }
1889"#;
1890 root_files.insert(
1891 "Quill.toml".to_string(),
1892 FileTreeNode::File {
1893 contents: quill_toml.as_bytes().to_vec(),
1894 },
1895 );
1896
1897 let plate_content = "= Test Template\n\nThis is a test.";
1899 root_files.insert(
1900 "plate.typ".to_string(),
1901 FileTreeNode::File {
1902 contents: plate_content.as_bytes().to_vec(),
1903 },
1904 );
1905
1906 root_files.insert(
1908 "taro.md".to_string(),
1909 FileTreeNode::File {
1910 contents: b"# Template".to_vec(),
1911 },
1912 );
1913
1914 let root = FileTreeNode::Directory { files: root_files };
1915
1916 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1918
1919 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1921 assert!(quill.schema["properties"]
1922 .as_object()
1923 .unwrap()
1924 .contains_key("author"));
1925 assert!(quill.schema["properties"]
1926 .as_object()
1927 .unwrap()
1928 .contains_key("ice_cream"));
1929 assert!(quill.schema["properties"]
1930 .as_object()
1931 .unwrap()
1932 .contains_key("title"));
1933
1934 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1936 assert_eq!(author_schema["description"], "Author of document");
1937
1938 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1940 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1941
1942 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1944 assert_eq!(title_schema["description"], "title of document");
1945 }
1946
1947 #[test]
1948 fn test_field_schema_struct() {
1949 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1951 assert_eq!(schema1.description, "Test description");
1952 assert_eq!(schema1.r#type, None);
1953 assert_eq!(schema1.examples, None);
1954 assert_eq!(schema1.default, None);
1955
1956 let yaml_str = r#"
1958description: "Full field schema"
1959type: "string"
1960examples:
1961 - "Example value"
1962default: "Default value"
1963"#;
1964 let quill_value = QuillValue::from_yaml_str(yaml_str).unwrap();
1965 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1966 assert_eq!(schema2.name, "test_name");
1967 assert_eq!(schema2.description, "Full field schema");
1968 assert_eq!(schema2.r#type, Some(FieldType::String));
1969 assert_eq!(
1970 schema2
1971 .examples
1972 .as_ref()
1973 .and_then(|v| v.as_array())
1974 .and_then(|arr| arr.first())
1975 .and_then(|v| v.as_str()),
1976 Some("Example value")
1977 );
1978 assert_eq!(
1979 schema2.default.as_ref().and_then(|v| v.as_str()),
1980 Some("Default value")
1981 );
1982 }
1983
1984 #[test]
1985 fn test_quill_without_plate_file() {
1986 let mut root_files = HashMap::new();
1988
1989 let quill_toml = r#"[Quill]
1991name = "test-no-plate"
1992backend = "typst"
1993description = "Test quill without plate file"
1994"#;
1995 root_files.insert(
1996 "Quill.toml".to_string(),
1997 FileTreeNode::File {
1998 contents: quill_toml.as_bytes().to_vec(),
1999 },
2000 );
2001
2002 let root = FileTreeNode::Directory { files: root_files };
2003
2004 let quill = Quill::from_tree(root, None).unwrap();
2006
2007 assert!(quill.plate.clone().is_none());
2009 assert_eq!(quill.name, "test-no-plate");
2010 }
2011
2012 #[test]
2013 fn test_quill_config_from_toml() {
2014 let toml_content = r#"[Quill]
2016name = "test-config"
2017backend = "typst"
2018description = "Test configuration parsing"
2019version = "1.0.0"
2020author = "Test Author"
2021plate_file = "plate.typ"
2022example_file = "example.md"
2023
2024[typst]
2025packages = ["@preview/bubble:0.2.2"]
2026
2027[fields]
2028title = {description = "Document title", type = "string"}
2029author = {description = "Document author"}
2030"#;
2031
2032 let config = QuillConfig::from_toml(toml_content).unwrap();
2033
2034 assert_eq!(config.name, "test-config");
2036 assert_eq!(config.backend, "typst");
2037 assert_eq!(config.description, "Test configuration parsing");
2038
2039 assert_eq!(config.version, Some("1.0.0".to_string()));
2041 assert_eq!(config.author, Some("Test Author".to_string()));
2042 assert_eq!(config.plate_file, Some("plate.typ".to_string()));
2043 assert_eq!(config.example_file, Some("example.md".to_string()));
2044
2045 assert!(config.typst_config.contains_key("packages"));
2047
2048 assert_eq!(config.fields.len(), 2);
2050 assert!(config.fields.contains_key("title"));
2051 assert!(config.fields.contains_key("author"));
2052
2053 let title_field = &config.fields["title"];
2054 assert_eq!(title_field.description, "Document title");
2055 assert_eq!(title_field.r#type, Some(FieldType::String));
2056 }
2057
2058 #[test]
2059 fn test_quill_config_missing_required_fields() {
2060 let toml_missing_name = r#"[Quill]
2062backend = "typst"
2063description = "Missing name"
2064"#;
2065 let result = QuillConfig::from_toml(toml_missing_name);
2066 assert!(result.is_err());
2067 assert!(result
2068 .unwrap_err()
2069 .to_string()
2070 .contains("Missing required 'name'"));
2071
2072 let toml_missing_backend = r#"[Quill]
2073name = "test"
2074description = "Missing backend"
2075"#;
2076 let result = QuillConfig::from_toml(toml_missing_backend);
2077 assert!(result.is_err());
2078 assert!(result
2079 .unwrap_err()
2080 .to_string()
2081 .contains("Missing required 'backend'"));
2082
2083 let toml_missing_description = r#"[Quill]
2084name = "test"
2085backend = "typst"
2086"#;
2087 let result = QuillConfig::from_toml(toml_missing_description);
2088 assert!(result.is_err());
2089 assert!(result
2090 .unwrap_err()
2091 .to_string()
2092 .contains("Missing required 'description'"));
2093 }
2094
2095 #[test]
2096 fn test_quill_config_empty_description() {
2097 let toml_empty_description = r#"[Quill]
2099name = "test"
2100backend = "typst"
2101description = " "
2102"#;
2103 let result = QuillConfig::from_toml(toml_empty_description);
2104 assert!(result.is_err());
2105 assert!(result
2106 .unwrap_err()
2107 .to_string()
2108 .contains("description' field in [Quill] section cannot be empty"));
2109 }
2110
2111 #[test]
2112 fn test_quill_config_missing_quill_section() {
2113 let toml_no_section = r#"[fields]
2115title = {description = "Title"}
2116"#;
2117 let result = QuillConfig::from_toml(toml_no_section);
2118 assert!(result.is_err());
2119 assert!(result
2120 .unwrap_err()
2121 .to_string()
2122 .contains("Missing required [Quill] section"));
2123 }
2124
2125 #[test]
2126 fn test_quill_from_config_metadata() {
2127 let mut root_files = HashMap::new();
2129
2130 let quill_toml = r#"[Quill]
2131name = "metadata-test"
2132backend = "typst"
2133description = "Test metadata flow"
2134author = "Test Author"
2135custom_field = "custom_value"
2136
2137[typst]
2138packages = ["@preview/bubble:0.2.2"]
2139"#;
2140 root_files.insert(
2141 "Quill.toml".to_string(),
2142 FileTreeNode::File {
2143 contents: quill_toml.as_bytes().to_vec(),
2144 },
2145 );
2146
2147 let root = FileTreeNode::Directory { files: root_files };
2148 let quill = Quill::from_tree(root, None).unwrap();
2149
2150 assert!(quill.metadata.contains_key("backend"));
2152 assert!(quill.metadata.contains_key("description"));
2153 assert!(quill.metadata.contains_key("author"));
2154
2155 assert!(quill.metadata.contains_key("custom_field"));
2157 assert_eq!(
2158 quill.metadata.get("custom_field").unwrap().as_str(),
2159 Some("custom_value")
2160 );
2161
2162 assert!(quill.metadata.contains_key("typst_packages"));
2164 }
2165
2166 #[test]
2167 fn test_extract_defaults_method() {
2168 let mut root_files = HashMap::new();
2170
2171 let quill_toml = r#"[Quill]
2172name = "defaults-test"
2173backend = "typst"
2174description = "Test defaults extraction"
2175
2176[fields]
2177title = {description = "Title"}
2178author = {description = "Author", default = "Anonymous"}
2179status = {description = "Status", default = "draft"}
2180"#;
2181
2182 root_files.insert(
2183 "Quill.toml".to_string(),
2184 FileTreeNode::File {
2185 contents: quill_toml.as_bytes().to_vec(),
2186 },
2187 );
2188
2189 let root = FileTreeNode::Directory { files: root_files };
2190 let quill = Quill::from_tree(root, None).unwrap();
2191
2192 let defaults = quill.extract_defaults();
2194
2195 assert_eq!(defaults.len(), 2);
2197 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2199 assert!(defaults.contains_key("status"));
2200
2201 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2203 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2204 }
2205
2206 #[test]
2207 fn test_field_order_preservation() {
2208 let toml_content = r#"[Quill]
2209name = "order-test"
2210backend = "typst"
2211description = "Test field order"
2212
2213[fields]
2214first = {description = "First field"}
2215second = {description = "Second field"}
2216third = {description = "Third field", ui = {group = "Test Group"}}
2217fourth = {description = "Fourth field"}
2218"#;
2219
2220 let config = QuillConfig::from_toml(toml_content).unwrap();
2221
2222 let first = config.fields.get("first").unwrap();
2226 assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2227
2228 let second = config.fields.get("second").unwrap();
2229 assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2230
2231 let third = config.fields.get("third").unwrap();
2232 assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2233 assert_eq!(
2234 third.ui.as_ref().unwrap().group,
2235 Some("Test Group".to_string())
2236 );
2237
2238 let fourth = config.fields.get("fourth").unwrap();
2239 assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2240 }
2241
2242 #[test]
2243 fn test_quill_with_all_ui_properties() {
2244 let toml_content = r#"[Quill]
2245name = "full-ui-test"
2246backend = "typst"
2247description = "Test all UI properties"
2248
2249[fields.author]
2250description = "The full name of the document author"
2251type = "str"
2252
2253[fields.author.ui]
2254group = "Author Info"
2255"#;
2256
2257 let config = QuillConfig::from_toml(toml_content).unwrap();
2258
2259 let author_field = &config.fields["author"];
2260 let ui = author_field.ui.as_ref().unwrap();
2261 assert_eq!(ui.group, Some("Author Info".to_string()));
2262 assert_eq!(ui.order, Some(0)); }
2264 #[test]
2265 fn test_field_schema_with_title_and_description() {
2266 let yaml = r#"
2268title: "Field Title"
2269description: "Detailed field description"
2270type: "string"
2271examples:
2272 - "Example value"
2273ui:
2274 group: "Test Group"
2275"#;
2276 let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2277 let schema = FieldSchema::from_quill_value("test_field".to_string(), &quill_value).unwrap();
2278
2279 assert_eq!(schema.title, Some("Field Title".to_string()));
2280 assert_eq!(schema.description, "Detailed field description");
2281
2282 assert_eq!(
2283 schema
2284 .examples
2285 .as_ref()
2286 .and_then(|v| v.as_array())
2287 .and_then(|arr| arr.first())
2288 .and_then(|v| v.as_str()),
2289 Some("Example value")
2290 );
2291
2292 let ui = schema.ui.as_ref().unwrap();
2293 assert_eq!(ui.group, Some("Test Group".to_string()));
2294 }
2295
2296 #[test]
2297 fn test_parse_card_field_type() {
2298 let yaml = r#"
2300type: "string"
2301title: "Simple Field"
2302description: "A simple string field"
2303"#;
2304 let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2305 let schema =
2306 FieldSchema::from_quill_value("simple_field".to_string(), &quill_value).unwrap();
2307
2308 assert_eq!(schema.name, "simple_field");
2309 assert_eq!(schema.r#type, Some(FieldType::String));
2310 assert_eq!(schema.title, Some("Simple Field".to_string()));
2311 assert_eq!(schema.description, "A simple string field");
2312 }
2313
2314 #[test]
2315 fn test_parse_card_with_fields_in_toml() {
2316 let toml_content = r#"[Quill]
2318name = "cards-fields-test"
2319backend = "typst"
2320description = "Test [cards.X.fields.Y] syntax"
2321
2322[cards.endorsements]
2323title = "Endorsements"
2324description = "Chain of endorsements"
2325
2326[cards.endorsements.fields.name]
2327type = "string"
2328title = "Endorser Name"
2329description = "Name of the endorsing official"
2330required = true
2331
2332[cards.endorsements.fields.org]
2333type = "string"
2334title = "Organization"
2335description = "Endorser's organization"
2336default = "Unknown"
2337"#;
2338
2339 let config = QuillConfig::from_toml(toml_content).unwrap();
2340
2341 assert!(config.cards.contains_key("endorsements"));
2343 let card = config.cards.get("endorsements").unwrap();
2344
2345 assert_eq!(card.name, "endorsements");
2346 assert_eq!(card.title, Some("Endorsements".to_string()));
2347 assert_eq!(card.description, "Chain of endorsements");
2348
2349 assert_eq!(card.fields.len(), 2);
2351
2352 let name_field = card.fields.get("name").unwrap();
2353 assert_eq!(name_field.r#type, Some(FieldType::String));
2354 assert_eq!(name_field.title, Some("Endorser Name".to_string()));
2355 assert!(name_field.required);
2356
2357 let org_field = card.fields.get("org").unwrap();
2358 assert_eq!(org_field.r#type, Some(FieldType::String));
2359 assert!(org_field.default.is_some());
2360 assert_eq!(
2361 org_field.default.as_ref().unwrap().as_str(),
2362 Some("Unknown")
2363 );
2364 }
2365
2366 #[test]
2367 fn test_field_schema_rejects_unknown_keys() {
2368 let yaml = r#"
2370type: "string"
2371description: "A string field"
2372items:
2373 sub_field:
2374 type: "string"
2375 description: "Nested field"
2376"#;
2377 let quill_value = QuillValue::from_yaml_str(yaml).unwrap();
2378
2379 let result = FieldSchema::from_quill_value("author".to_string(), &quill_value);
2380
2381 assert!(result.is_err());
2383 let err = result.unwrap_err();
2384 assert!(err.contains("unknown field `items`"), "Error was: {}", err);
2385 }
2386
2387 #[test]
2388 fn test_quill_config_with_cards_section() {
2389 let toml_content = r#"[Quill]
2390name = "cards-test"
2391backend = "typst"
2392description = "Test [cards] section"
2393
2394[fields.regular]
2395description = "Regular field"
2396type = "string"
2397
2398[cards.indorsements]
2399title = "Routing Indorsements"
2400description = "Chain of endorsements"
2401
2402[cards.indorsements.fields.name]
2403title = "Name"
2404type = "string"
2405description = "Name field"
2406"#;
2407
2408 let config = QuillConfig::from_toml(toml_content).unwrap();
2409
2410 assert!(config.fields.contains_key("regular"));
2412 let regular = config.fields.get("regular").unwrap();
2413 assert_eq!(regular.r#type, Some(FieldType::String));
2414
2415 assert!(config.cards.contains_key("indorsements"));
2417 let card = config.cards.get("indorsements").unwrap();
2418 assert_eq!(card.title, Some("Routing Indorsements".to_string()));
2419 assert_eq!(card.description, "Chain of endorsements");
2420 assert!(card.fields.contains_key("name"));
2421 }
2422
2423 #[test]
2424 fn test_quill_config_cards_empty_fields() {
2425 let toml_content = r#"[Quill]
2427name = "cards-empty-fields-test"
2428backend = "typst"
2429description = "Test cards without fields"
2430
2431[cards.myscope]
2432description = "My scope"
2433"#;
2434
2435 let config = QuillConfig::from_toml(toml_content).unwrap();
2436 let card = config.cards.get("myscope").unwrap();
2437 assert_eq!(card.name, "myscope");
2438 assert_eq!(card.description, "My scope");
2439 assert!(card.fields.is_empty());
2440 }
2441
2442 #[test]
2443 fn test_quill_config_allows_card_collision() {
2444 let toml_content = r#"[Quill]
2446name = "collision-test"
2447backend = "typst"
2448description = "Test collision"
2449
2450[fields.conflict]
2451description = "Field"
2452type = "string"
2453
2454[cards.conflict]
2455description = "Card"
2456"#;
2457
2458 let result = QuillConfig::from_toml(toml_content);
2459 if let Err(e) = &result {
2460 panic!(
2461 "Card name collision should be allowed, but got error: {}",
2462 e
2463 );
2464 }
2465 assert!(result.is_ok());
2466
2467 let config = result.unwrap();
2468 assert!(config.fields.contains_key("conflict"));
2469 assert!(config.cards.contains_key("conflict"));
2470 }
2471
2472 #[test]
2473 fn test_quill_config_ordering_with_cards() {
2474 let toml_content = r#"[Quill]
2476name = "ordering-test"
2477backend = "typst"
2478description = "Test ordering"
2479
2480[fields.first]
2481description = "First"
2482
2483[cards.second]
2484description = "Second"
2485
2486[cards.second.fields.card_field]
2487description = "A card field"
2488
2489[fields.zero]
2490description = "Zero"
2491"#;
2492
2493 let config = QuillConfig::from_toml(toml_content).unwrap();
2494
2495 let first = config.fields.get("first").unwrap();
2496 let zero = config.fields.get("zero").unwrap();
2497 let second = config.cards.get("second").unwrap();
2498
2499 let ord_first = first.ui.as_ref().unwrap().order.unwrap();
2501 let ord_zero = zero.ui.as_ref().unwrap().order.unwrap();
2502
2503 assert!(ord_first < ord_zero);
2505 assert_eq!(ord_first, 0);
2506 assert_eq!(ord_zero, 1);
2507
2508 let card_field = second.fields.get("card_field").unwrap();
2510 let ord_card_field = card_field.ui.as_ref().unwrap().order.unwrap();
2511 assert_eq!(ord_card_field, 0); }
2513 #[test]
2514 fn test_card_field_order_preservation() {
2515 let toml_content = r#"[Quill]
2519name = "card-order-test"
2520backend = "typst"
2521description = "Test card field order"
2522
2523[cards.mycard]
2524description = "Test card"
2525
2526[cards.mycard.fields.z_first]
2527type = "string"
2528description = "Defined first"
2529
2530[cards.mycard.fields.a_second]
2531type = "string"
2532description = "Defined second"
2533"#;
2534
2535 let config = QuillConfig::from_toml(toml_content).unwrap();
2536 let card = config.cards.get("mycard").unwrap();
2537
2538 let z_first = card.fields.get("z_first").unwrap();
2539 let a_second = card.fields.get("a_second").unwrap();
2540
2541 let z_order = z_first.ui.as_ref().unwrap().order.unwrap();
2543 let a_order = a_second.ui.as_ref().unwrap().order.unwrap();
2544
2545 assert_eq!(z_order, 0, "z_first should be 0 (defined first)");
2548 assert_eq!(a_order, 1, "a_second should be 1 (defined second)");
2549 }
2550}