1use std::collections::HashMap;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use crate::schema::build_schema_from_fields;
8use crate::value::QuillValue;
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct FieldSchema {
13 pub name: String,
14 pub r#type: Option<String>,
16 pub description: String,
18 pub default: Option<QuillValue>,
20 pub example: Option<QuillValue>,
22 pub examples: Option<QuillValue>,
24}
25
26impl FieldSchema {
27 pub fn new(name: String, description: String) -> Self {
29 Self {
30 name,
31 r#type: None,
32 description,
33 default: None,
34 example: None,
35 examples: None,
36 }
37 }
38
39 pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
41 let obj = value
42 .as_object()
43 .ok_or_else(|| "Field schema must be an object".to_string())?;
44
45 for key in obj.keys() {
47 match key.as_str() {
48 "name" | "type" | "description" | "example" | "default" => {}
49 _ => {
50 return Err(format!("Unknown key '{}' in field schema", key));
51 }
52 }
53 }
54
55 let name = key.clone();
56
57 let description = obj
58 .get("description")
59 .and_then(|v| v.as_str())
60 .unwrap_or("")
61 .to_string();
62
63 let field_type = obj
64 .get("type")
65 .and_then(|v| v.as_str())
66 .map(|s| s.to_string());
67
68 let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
69
70 let example = obj.get("example").map(|v| QuillValue::from_json(v.clone()));
71
72 let examples = obj
73 .get("examples")
74 .map(|v| QuillValue::from_json(v.clone()));
75
76 Ok(Self {
77 name: name,
78 r#type: field_type,
79 description: description,
80 default: default,
81 example: example,
82 examples: examples,
83 })
84 }
85}
86
87#[derive(Debug, Clone)]
89pub enum FileTreeNode {
90 File {
92 contents: Vec<u8>,
94 },
95 Directory {
97 files: HashMap<String, FileTreeNode>,
99 },
100}
101
102impl FileTreeNode {
103 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
105 let path = path.as_ref();
106
107 if path == Path::new("") {
109 return Some(self);
110 }
111
112 let components: Vec<_> = path
114 .components()
115 .filter_map(|c| {
116 if let std::path::Component::Normal(s) = c {
117 s.to_str()
118 } else {
119 None
120 }
121 })
122 .collect();
123
124 if components.is_empty() {
125 return Some(self);
126 }
127
128 let mut current_node = self;
130 for component in components {
131 match current_node {
132 FileTreeNode::Directory { files } => {
133 current_node = files.get(component)?;
134 }
135 FileTreeNode::File { .. } => {
136 return None; }
138 }
139 }
140
141 Some(current_node)
142 }
143
144 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
146 match self.get_node(path)? {
147 FileTreeNode::File { contents } => Some(contents.as_slice()),
148 FileTreeNode::Directory { .. } => None,
149 }
150 }
151
152 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
154 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
155 }
156
157 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
159 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
160 }
161
162 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
164 match self.get_node(dir_path) {
165 Some(FileTreeNode::Directory { files }) => files
166 .iter()
167 .filter_map(|(name, node)| {
168 if matches!(node, FileTreeNode::File { .. }) {
169 Some(name.clone())
170 } else {
171 None
172 }
173 })
174 .collect(),
175 _ => Vec::new(),
176 }
177 }
178
179 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
181 match self.get_node(dir_path) {
182 Some(FileTreeNode::Directory { files }) => files
183 .iter()
184 .filter_map(|(name, node)| {
185 if matches!(node, FileTreeNode::Directory { .. }) {
186 Some(name.clone())
187 } else {
188 None
189 }
190 })
191 .collect(),
192 _ => Vec::new(),
193 }
194 }
195
196 pub fn insert<P: AsRef<Path>>(
198 &mut self,
199 path: P,
200 node: FileTreeNode,
201 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
202 let path = path.as_ref();
203
204 let components: Vec<_> = path
206 .components()
207 .filter_map(|c| {
208 if let std::path::Component::Normal(s) = c {
209 s.to_str().map(|s| s.to_string())
210 } else {
211 None
212 }
213 })
214 .collect();
215
216 if components.is_empty() {
217 return Err("Cannot insert at root path".into());
218 }
219
220 let mut current_node = self;
222 for component in &components[..components.len() - 1] {
223 match current_node {
224 FileTreeNode::Directory { files } => {
225 current_node =
226 files
227 .entry(component.clone())
228 .or_insert_with(|| FileTreeNode::Directory {
229 files: HashMap::new(),
230 });
231 }
232 FileTreeNode::File { .. } => {
233 return Err("Cannot traverse into a file".into());
234 }
235 }
236 }
237
238 let filename = &components[components.len() - 1];
240 match current_node {
241 FileTreeNode::Directory { files } => {
242 files.insert(filename.clone(), node);
243 Ok(())
244 }
245 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
246 }
247 }
248
249 fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
251 if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
252 Ok(FileTreeNode::File {
254 contents: contents_str.as_bytes().to_vec(),
255 })
256 } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
257 let contents: Vec<u8> = bytes_array
259 .iter()
260 .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
261 .collect();
262 Ok(FileTreeNode::File { contents })
263 } else if let Some(obj) = value.as_object() {
264 let mut files = HashMap::new();
266 for (name, child_value) in obj {
267 files.insert(name.clone(), Self::from_json_value(child_value)?);
268 }
269 Ok(FileTreeNode::Directory { files })
271 } else {
272 Err(format!("Invalid file tree node: {:?}", value).into())
273 }
274 }
275
276 pub fn print_tree(&self) -> String {
277 self.__print_tree("", "", true)
278 }
279
280 pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
281 let mut result = String::new();
282
283 let connector = if is_last { "└── " } else { "├── " };
285 let extension = if is_last { " " } else { "│ " };
286
287 match self {
288 FileTreeNode::File { .. } => {
289 result.push_str(&format!("{}{}{}\n", prefix, connector, name));
290 }
291 FileTreeNode::Directory { files } => {
292 result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
294
295 let child_prefix = format!("{}{}", prefix, extension);
296 let count = files.len();
297
298 for (i, (child_name, node)) in files.iter().enumerate() {
299 let is_last_child = i == count - 1;
300 result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
301 }
302 }
303 }
304
305 result
306 }
307}
308
309#[derive(Debug, Clone)]
311pub struct QuillIgnore {
312 patterns: Vec<String>,
313}
314
315impl QuillIgnore {
316 pub fn new(patterns: Vec<String>) -> Self {
318 Self { patterns }
319 }
320
321 pub fn from_content(content: &str) -> Self {
323 let patterns = content
324 .lines()
325 .map(|line| line.trim())
326 .filter(|line| !line.is_empty() && !line.starts_with('#'))
327 .map(|line| line.to_string())
328 .collect();
329 Self::new(patterns)
330 }
331
332 pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
334 let path = path.as_ref();
335 let path_str = path.to_string_lossy();
336
337 for pattern in &self.patterns {
338 if self.matches_pattern(pattern, &path_str) {
339 return true;
340 }
341 }
342 false
343 }
344
345 fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
347 if pattern.ends_with('/') {
349 let pattern_prefix = &pattern[..pattern.len() - 1];
350 return path.starts_with(pattern_prefix)
351 && (path.len() == pattern_prefix.len()
352 || path.chars().nth(pattern_prefix.len()) == Some('/'));
353 }
354
355 if !pattern.contains('*') {
357 return path == pattern || path.ends_with(&format!("/{}", pattern));
358 }
359
360 if pattern == "*" {
362 return true;
363 }
364
365 let pattern_parts: Vec<&str> = pattern.split('*').collect();
367 if pattern_parts.len() == 2 {
368 let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
369 if prefix.is_empty() {
370 return path.ends_with(suffix);
371 } else if suffix.is_empty() {
372 return path.starts_with(prefix);
373 } else {
374 return path.starts_with(prefix) && path.ends_with(suffix);
375 }
376 }
377
378 false
379 }
380}
381
382#[derive(Debug, Clone)]
384pub struct Quill {
385 pub metadata: HashMap<String, QuillValue>,
387 pub name: String,
389 pub backend: String,
391 pub glue: Option<String>,
393 pub example: Option<String>,
395 pub schema: QuillValue,
397 pub defaults: HashMap<String, QuillValue>,
399 pub examples: HashMap<String, Vec<QuillValue>>,
401 pub files: FileTreeNode,
403}
404
405#[derive(Debug, Clone)]
407pub struct QuillConfig {
408 pub name: String,
410 pub description: String,
412 pub backend: String,
414 pub version: Option<String>,
416 pub author: Option<String>,
418 pub example_file: Option<String>,
420 pub glue_file: Option<String>,
422 pub json_schema_file: Option<String>,
424 pub fields: HashMap<String, FieldSchema>,
426 pub metadata: HashMap<String, QuillValue>,
428 pub typst_config: HashMap<String, QuillValue>,
430}
431
432impl QuillConfig {
433 pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
435 let quill_toml: toml::Value = toml::from_str(toml_content)
436 .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
437
438 let quill_section = quill_toml
440 .get("Quill")
441 .ok_or("Missing required [Quill] section in Quill.toml")?;
442
443 let name = quill_section
445 .get("name")
446 .and_then(|v| v.as_str())
447 .ok_or("Missing required 'name' field in [Quill] section")?
448 .to_string();
449
450 let backend = quill_section
451 .get("backend")
452 .and_then(|v| v.as_str())
453 .ok_or("Missing required 'backend' field in [Quill] section")?
454 .to_string();
455
456 let description = quill_section
457 .get("description")
458 .and_then(|v| v.as_str())
459 .ok_or("Missing required 'description' field in [Quill] section")?;
460
461 if description.trim().is_empty() {
462 return Err("'description' field in [Quill] section cannot be empty".into());
463 }
464 let description = description.to_string();
465
466 let version = quill_section
468 .get("version")
469 .and_then(|v| v.as_str())
470 .map(|s| s.to_string());
471
472 let author = quill_section
473 .get("author")
474 .and_then(|v| v.as_str())
475 .map(|s| s.to_string());
476
477 let example_file = quill_section
478 .get("example_file")
479 .and_then(|v| v.as_str())
480 .map(|s| s.to_string());
481
482 let glue_file = quill_section
483 .get("glue_file")
484 .and_then(|v| v.as_str())
485 .map(|s| s.to_string());
486
487 let json_schema_file = quill_section
488 .get("json_schema_file")
489 .and_then(|v| v.as_str())
490 .map(|s| s.to_string());
491
492 let mut metadata = HashMap::new();
494 if let toml::Value::Table(table) = quill_section {
495 for (key, value) in table {
496 if key != "name"
498 && key != "backend"
499 && key != "description"
500 && key != "version"
501 && key != "author"
502 && key != "example_file"
503 && key != "glue_file"
504 && key != "json_schema_file"
505 {
506 match QuillValue::from_toml(value) {
507 Ok(quill_value) => {
508 metadata.insert(key.clone(), quill_value);
509 }
510 Err(e) => {
511 eprintln!("Warning: Failed to convert field '{}': {}", key, e);
512 }
513 }
514 }
515 }
516 }
517
518 let mut typst_config = HashMap::new();
520 if let Some(typst_section) = quill_toml.get("typst") {
521 if let toml::Value::Table(table) = typst_section {
522 for (key, value) in table {
523 match QuillValue::from_toml(value) {
524 Ok(quill_value) => {
525 typst_config.insert(key.clone(), quill_value);
526 }
527 Err(e) => {
528 eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
529 }
530 }
531 }
532 }
533 }
534
535 let mut fields = HashMap::new();
537 if let Some(fields_section) = quill_toml.get("fields") {
538 if let toml::Value::Table(fields_table) = fields_section {
539 for (field_name, field_schema) in fields_table {
540 match QuillValue::from_toml(field_schema) {
541 Ok(quill_value) => {
542 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
543 Ok(schema) => {
544 fields.insert(field_name.clone(), schema);
545 }
546 Err(e) => {
547 eprintln!(
548 "Warning: Failed to parse field schema '{}': {}",
549 field_name, e
550 );
551 }
552 }
553 }
554 Err(e) => {
555 eprintln!(
556 "Warning: Failed to convert field schema '{}': {}",
557 field_name, e
558 );
559 }
560 }
561 }
562 }
563 }
564
565 Ok(QuillConfig {
566 name,
567 description,
568 backend,
569 version,
570 author,
571 example_file,
572 glue_file,
573 json_schema_file,
574 fields,
575 metadata,
576 typst_config,
577 })
578 }
579}
580
581impl Quill {
582 pub fn from_path<P: AsRef<std::path::Path>>(
584 path: P,
585 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
586 use std::fs;
587
588 let path = path.as_ref();
589 let name = path
590 .file_name()
591 .and_then(|n| n.to_str())
592 .unwrap_or("unnamed")
593 .to_string();
594
595 let quillignore_path = path.join(".quillignore");
597 let ignore = if quillignore_path.exists() {
598 let ignore_content = fs::read_to_string(&quillignore_path)
599 .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
600 QuillIgnore::from_content(&ignore_content)
601 } else {
602 QuillIgnore::new(vec![
604 ".git/".to_string(),
605 ".gitignore".to_string(),
606 ".quillignore".to_string(),
607 "target/".to_string(),
608 "node_modules/".to_string(),
609 ])
610 };
611
612 let root = Self::load_directory_as_tree(path, path, &ignore)?;
614
615 Self::from_tree(root, Some(name))
617 }
618
619 pub fn from_tree(
636 root: FileTreeNode,
637 _default_name: Option<String>,
638 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
639 let quill_toml_bytes = root
641 .get_file("Quill.toml")
642 .ok_or("Quill.toml not found in file tree")?;
643
644 let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
645 .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
646
647 let config = QuillConfig::from_toml(&quill_toml_content)?;
649
650 Self::from_config(config, root)
652 }
653
654 fn from_config(
672 config: QuillConfig,
673 root: FileTreeNode,
674 ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
675 let mut metadata = config.metadata.clone();
677
678 metadata.insert(
680 "backend".to_string(),
681 QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
682 );
683
684 metadata.insert(
686 "description".to_string(),
687 QuillValue::from_json(serde_json::Value::String(config.description.clone())),
688 );
689
690 if let Some(ref author) = config.author {
692 metadata.insert(
693 "author".to_string(),
694 QuillValue::from_json(serde_json::Value::String(author.clone())),
695 );
696 }
697
698 for (key, value) in &config.typst_config {
700 metadata.insert(format!("typst_{}", key), value.clone());
701 }
702
703 let schema = if let Some(ref json_schema_path) = config.json_schema_file {
705 let schema_bytes = root.get_file(json_schema_path).ok_or_else(|| {
707 format!(
708 "json_schema_file '{}' not found in file tree",
709 json_schema_path
710 )
711 })?;
712
713 let schema_json =
715 serde_json::from_slice::<serde_json::Value>(schema_bytes).map_err(|e| {
716 format!(
717 "json_schema_file '{}' is not valid JSON: {}",
718 json_schema_path, e
719 )
720 })?;
721
722 if !config.fields.is_empty() {
724 eprintln!("Warning: [fields] section is overridden by json_schema_file");
725 }
726
727 QuillValue::from_json(schema_json)
728 } else {
729 build_schema_from_fields(&config.fields)
731 .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?
732 };
733
734 let glue_content: Option<String> = if let Some(ref glue_file_name) = config.glue_file {
736 let glue_bytes = root
737 .get_file(glue_file_name)
738 .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
739
740 let content = String::from_utf8(glue_bytes.to_vec())
741 .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?;
742 Some(content)
743 } else {
744 None
746 };
747
748 let example_content = if let Some(ref example_file_name) = config.example_file {
750 root.get_file(example_file_name).and_then(|bytes| {
751 String::from_utf8(bytes.to_vec())
752 .map_err(|e| {
753 eprintln!(
754 "Warning: Example file '{}' is not valid UTF-8: {}",
755 example_file_name, e
756 );
757 e
758 })
759 .ok()
760 })
761 } else {
762 None
763 };
764
765 let defaults = crate::schema::extract_defaults_from_schema(&schema);
767 let examples = crate::schema::extract_examples_from_schema(&schema);
768
769 let quill = Quill {
770 metadata,
771 name: config.name,
772 backend: config.backend,
773 glue: glue_content,
774 example: example_content,
775 schema,
776 defaults,
777 examples,
778 files: root,
779 };
780
781 Ok(quill)
782 }
783
784 pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
791 use serde_json::Value as JsonValue;
792
793 let json: JsonValue =
794 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
795
796 let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
797
798 let default_name = obj
800 .get("metadata")
801 .and_then(|m| m.get("name"))
802 .and_then(|v| v.as_str())
803 .map(String::from);
804
805 let files_obj = obj
807 .get("files")
808 .and_then(|v| v.as_object())
809 .ok_or_else(|| "Missing or invalid 'files' key")?;
810
811 let mut root_files = HashMap::new();
813 for (key, value) in files_obj {
814 root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
815 }
816
817 let root = FileTreeNode::Directory { files: root_files };
818
819 Self::from_tree(root, default_name)
821 }
822
823 fn load_directory_as_tree(
825 current_dir: &Path,
826 base_dir: &Path,
827 ignore: &QuillIgnore,
828 ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
829 use std::fs;
830
831 if !current_dir.exists() {
832 return Ok(FileTreeNode::Directory {
833 files: HashMap::new(),
834 });
835 }
836
837 let mut files = HashMap::new();
838
839 for entry in fs::read_dir(current_dir)? {
840 let entry = entry?;
841 let path = entry.path();
842 let relative_path = path
843 .strip_prefix(base_dir)
844 .map_err(|e| format!("Failed to get relative path: {}", e))?
845 .to_path_buf();
846
847 if ignore.is_ignored(&relative_path) {
849 continue;
850 }
851
852 let filename = path
854 .file_name()
855 .and_then(|n| n.to_str())
856 .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
857 .to_string();
858
859 if path.is_file() {
860 let contents = fs::read(&path)
861 .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
862
863 files.insert(filename, FileTreeNode::File { contents });
864 } else if path.is_dir() {
865 let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
867 files.insert(filename, subdir_tree);
868 }
869 }
870
871 Ok(FileTreeNode::Directory { files })
872 }
873
874 pub fn typst_packages(&self) -> Vec<String> {
876 self.metadata
877 .get("typst_packages")
878 .and_then(|v| v.as_array())
879 .map(|arr| {
880 arr.iter()
881 .filter_map(|v| v.as_str().map(|s| s.to_string()))
882 .collect()
883 })
884 .unwrap_or_default()
885 }
886
887 pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
895 &self.defaults
896 }
897
898 pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
903 &self.examples
904 }
905
906 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
908 self.files.get_file(path)
909 }
910
911 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
913 self.files.file_exists(path)
914 }
915
916 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
918 self.files.dir_exists(path)
919 }
920
921 pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
923 self.files.list_files(path)
924 }
925
926 pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
928 self.files.list_subdirectories(path)
929 }
930
931 pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
933 let dir_path = dir_path.as_ref();
934 let filenames = self.files.list_files(dir_path);
935
936 filenames
938 .iter()
939 .map(|name| {
940 if dir_path == Path::new("") {
941 PathBuf::from(name)
942 } else {
943 dir_path.join(name)
944 }
945 })
946 .collect()
947 }
948
949 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
951 let dir_path = dir_path.as_ref();
952 let subdirs = self.files.list_subdirectories(dir_path);
953
954 subdirs
956 .iter()
957 .map(|name| {
958 if dir_path == Path::new("") {
959 PathBuf::from(name)
960 } else {
961 dir_path.join(name)
962 }
963 })
964 .collect()
965 }
966
967 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
969 let pattern_str = pattern.as_ref().to_string_lossy();
970 let mut matches = Vec::new();
971
972 let glob_pattern = match glob::Pattern::new(&pattern_str) {
974 Ok(pat) => pat,
975 Err(_) => return matches, };
977
978 self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
980
981 matches.sort();
982 matches
983 }
984
985 fn find_files_recursive(
987 &self,
988 node: &FileTreeNode,
989 current_path: &Path,
990 pattern: &glob::Pattern,
991 matches: &mut Vec<PathBuf>,
992 ) {
993 match node {
994 FileTreeNode::File { .. } => {
995 let path_str = current_path.to_string_lossy();
996 if pattern.matches(&path_str) {
997 matches.push(current_path.to_path_buf());
998 }
999 }
1000 FileTreeNode::Directory { files } => {
1001 for (name, child_node) in files {
1002 let child_path = if current_path == Path::new("") {
1003 PathBuf::from(name)
1004 } else {
1005 current_path.join(name)
1006 };
1007 self.find_files_recursive(child_node, &child_path, pattern, matches);
1008 }
1009 }
1010 }
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use std::fs;
1018 use tempfile::TempDir;
1019
1020 #[test]
1021 fn test_quillignore_parsing() {
1022 let ignore_content = r#"
1023# This is a comment
1024*.tmp
1025target/
1026node_modules/
1027.git/
1028"#;
1029 let ignore = QuillIgnore::from_content(ignore_content);
1030 assert_eq!(ignore.patterns.len(), 4);
1031 assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1032 assert!(ignore.patterns.contains(&"target/".to_string()));
1033 }
1034
1035 #[test]
1036 fn test_quillignore_matching() {
1037 let ignore = QuillIgnore::new(vec![
1038 "*.tmp".to_string(),
1039 "target/".to_string(),
1040 "node_modules/".to_string(),
1041 ".git/".to_string(),
1042 ]);
1043
1044 assert!(ignore.is_ignored("test.tmp"));
1046 assert!(ignore.is_ignored("path/to/file.tmp"));
1047 assert!(!ignore.is_ignored("test.txt"));
1048
1049 assert!(ignore.is_ignored("target"));
1051 assert!(ignore.is_ignored("target/debug"));
1052 assert!(ignore.is_ignored("target/debug/deps"));
1053 assert!(!ignore.is_ignored("src/target.rs"));
1054
1055 assert!(ignore.is_ignored("node_modules"));
1056 assert!(ignore.is_ignored("node_modules/package"));
1057 assert!(!ignore.is_ignored("my_node_modules"));
1058 }
1059
1060 #[test]
1061 fn test_in_memory_file_system() {
1062 let temp_dir = TempDir::new().unwrap();
1063 let quill_dir = temp_dir.path();
1064
1065 fs::write(
1067 quill_dir.join("Quill.toml"),
1068 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1069 )
1070 .unwrap();
1071 fs::write(quill_dir.join("glue.typ"), "test glue").unwrap();
1072
1073 let assets_dir = quill_dir.join("assets");
1074 fs::create_dir_all(&assets_dir).unwrap();
1075 fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1076
1077 let packages_dir = quill_dir.join("packages");
1078 fs::create_dir_all(&packages_dir).unwrap();
1079 fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1080
1081 let quill = Quill::from_path(quill_dir).unwrap();
1083
1084 assert!(quill.file_exists("glue.typ"));
1086 assert!(quill.file_exists("assets/test.txt"));
1087 assert!(quill.file_exists("packages/package.typ"));
1088 assert!(!quill.file_exists("nonexistent.txt"));
1089
1090 let asset_content = quill.get_file("assets/test.txt").unwrap();
1092 assert_eq!(asset_content, b"asset content");
1093
1094 let asset_files = quill.list_directory("assets");
1096 assert_eq!(asset_files.len(), 1);
1097 assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1098 }
1099
1100 #[test]
1101 fn test_quillignore_integration() {
1102 let temp_dir = TempDir::new().unwrap();
1103 let quill_dir = temp_dir.path();
1104
1105 fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1107
1108 fs::write(
1110 quill_dir.join("Quill.toml"),
1111 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1112 )
1113 .unwrap();
1114 fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
1115 fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1116
1117 let target_dir = quill_dir.join("target");
1118 fs::create_dir_all(&target_dir).unwrap();
1119 fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1120
1121 let quill = Quill::from_path(quill_dir).unwrap();
1123
1124 assert!(quill.file_exists("glue.typ"));
1126 assert!(!quill.file_exists("should_ignore.tmp"));
1127 assert!(!quill.file_exists("target/debug.txt"));
1128 }
1129
1130 #[test]
1131 fn test_find_files_pattern() {
1132 let temp_dir = TempDir::new().unwrap();
1133 let quill_dir = temp_dir.path();
1134
1135 fs::write(
1137 quill_dir.join("Quill.toml"),
1138 "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1139 )
1140 .unwrap();
1141 fs::write(quill_dir.join("glue.typ"), "template").unwrap();
1142
1143 let assets_dir = quill_dir.join("assets");
1144 fs::create_dir_all(&assets_dir).unwrap();
1145 fs::write(assets_dir.join("image.png"), "png data").unwrap();
1146 fs::write(assets_dir.join("data.json"), "json data").unwrap();
1147
1148 let fonts_dir = assets_dir.join("fonts");
1149 fs::create_dir_all(&fonts_dir).unwrap();
1150 fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1151
1152 let quill = Quill::from_path(quill_dir).unwrap();
1154
1155 let all_assets = quill.find_files("assets/*");
1157 assert!(all_assets.len() >= 3); let typ_files = quill.find_files("*.typ");
1160 assert_eq!(typ_files.len(), 1);
1161 assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1162 }
1163
1164 #[test]
1165 fn test_new_standardized_toml_format() {
1166 let temp_dir = TempDir::new().unwrap();
1167 let quill_dir = temp_dir.path();
1168
1169 let toml_content = r#"[Quill]
1171name = "my-custom-quill"
1172backend = "typst"
1173glue_file = "custom_glue.typ"
1174description = "Test quill with new format"
1175author = "Test Author"
1176"#;
1177 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1178 fs::write(
1179 quill_dir.join("custom_glue.typ"),
1180 "= Custom Template\n\nThis is a custom template.",
1181 )
1182 .unwrap();
1183
1184 let quill = Quill::from_path(quill_dir).unwrap();
1186
1187 assert_eq!(quill.name, "my-custom-quill");
1189
1190 assert!(quill.metadata.contains_key("backend"));
1192 if let Some(backend_val) = quill.metadata.get("backend") {
1193 if let Some(backend_str) = backend_val.as_str() {
1194 assert_eq!(backend_str, "typst");
1195 } else {
1196 panic!("Backend value is not a string");
1197 }
1198 }
1199
1200 assert!(quill.metadata.contains_key("description"));
1202 assert!(quill.metadata.contains_key("author"));
1203 assert!(!quill.metadata.contains_key("version")); assert!(quill.glue.unwrap().contains("Custom Template"));
1207 }
1208
1209 #[test]
1210 fn test_typst_packages_parsing() {
1211 let temp_dir = TempDir::new().unwrap();
1212 let quill_dir = temp_dir.path();
1213
1214 let toml_content = r#"
1215[Quill]
1216name = "test-quill"
1217backend = "typst"
1218glue_file = "glue.typ"
1219description = "Test quill for packages"
1220
1221[typst]
1222packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1223"#;
1224
1225 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1226 fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1227
1228 let quill = Quill::from_path(quill_dir).unwrap();
1229 let packages = quill.typst_packages();
1230
1231 assert_eq!(packages.len(), 2);
1232 assert_eq!(packages[0], "@preview/bubble:0.2.2");
1233 assert_eq!(packages[1], "@preview/example:1.0.0");
1234 }
1235
1236 #[test]
1237 fn test_template_loading() {
1238 let temp_dir = TempDir::new().unwrap();
1239 let quill_dir = temp_dir.path();
1240
1241 let toml_content = r#"[Quill]
1243name = "test-with-template"
1244backend = "typst"
1245glue_file = "glue.typ"
1246example_file = "example.md"
1247description = "Test quill with template"
1248"#;
1249 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1250 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1251 fs::write(
1252 quill_dir.join("example.md"),
1253 "---\ntitle: Test\n---\n\nThis is a test template.",
1254 )
1255 .unwrap();
1256
1257 let quill = Quill::from_path(quill_dir).unwrap();
1259
1260 assert!(quill.example.is_some());
1262 let example = quill.example.unwrap();
1263 assert!(example.contains("title: Test"));
1264 assert!(example.contains("This is a test template"));
1265
1266 assert_eq!(quill.glue.unwrap(), "glue content");
1268 }
1269
1270 #[test]
1271 fn test_template_optional() {
1272 let temp_dir = TempDir::new().unwrap();
1273 let quill_dir = temp_dir.path();
1274
1275 let toml_content = r#"[Quill]
1277name = "test-without-template"
1278backend = "typst"
1279glue_file = "glue.typ"
1280description = "Test quill without template"
1281"#;
1282 fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1283 fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1284
1285 let quill = Quill::from_path(quill_dir).unwrap();
1287
1288 assert_eq!(quill.example, None);
1290
1291 assert_eq!(quill.glue.unwrap(), "glue content");
1293 }
1294
1295 #[test]
1296 fn test_from_tree() {
1297 let mut root_files = HashMap::new();
1299
1300 let quill_toml = r#"[Quill]
1302name = "test-from-tree"
1303backend = "typst"
1304glue_file = "glue.typ"
1305description = "A test quill from tree"
1306"#;
1307 root_files.insert(
1308 "Quill.toml".to_string(),
1309 FileTreeNode::File {
1310 contents: quill_toml.as_bytes().to_vec(),
1311 },
1312 );
1313
1314 let glue_content = "= Test Template\n\nThis is a test.";
1316 root_files.insert(
1317 "glue.typ".to_string(),
1318 FileTreeNode::File {
1319 contents: glue_content.as_bytes().to_vec(),
1320 },
1321 );
1322
1323 let root = FileTreeNode::Directory { files: root_files };
1324
1325 let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1327
1328 assert_eq!(quill.name, "test-from-tree");
1330 assert_eq!(quill.glue.unwrap(), glue_content);
1331 assert!(quill.metadata.contains_key("backend"));
1332 assert!(quill.metadata.contains_key("description"));
1333 }
1334
1335 #[test]
1336 fn test_from_tree_with_template() {
1337 let mut root_files = HashMap::new();
1338
1339 let quill_toml = r#"[Quill]
1341name = "test-tree-template"
1342backend = "typst"
1343glue_file = "glue.typ"
1344example_file = "template.md"
1345description = "Test tree with template"
1346"#;
1347 root_files.insert(
1348 "Quill.toml".to_string(),
1349 FileTreeNode::File {
1350 contents: quill_toml.as_bytes().to_vec(),
1351 },
1352 );
1353
1354 root_files.insert(
1356 "glue.typ".to_string(),
1357 FileTreeNode::File {
1358 contents: b"glue content".to_vec(),
1359 },
1360 );
1361
1362 let template_content = "# {{ title }}\n\n{{ body }}";
1364 root_files.insert(
1365 "template.md".to_string(),
1366 FileTreeNode::File {
1367 contents: template_content.as_bytes().to_vec(),
1368 },
1369 );
1370
1371 let root = FileTreeNode::Directory { files: root_files };
1372
1373 let quill = Quill::from_tree(root, None).unwrap();
1375
1376 assert_eq!(quill.example, Some(template_content.to_string()));
1378 }
1379
1380 #[test]
1381 fn test_from_json() {
1382 let json_str = r#"{
1384 "metadata": {
1385 "name": "test-from-json"
1386 },
1387 "files": {
1388 "Quill.toml": {
1389 "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1390 },
1391 "glue.typ": {
1392 "contents": "= Test Glue\n\nThis is test content."
1393 }
1394 }
1395 }"#;
1396
1397 let quill = Quill::from_json(json_str).unwrap();
1399
1400 assert_eq!(quill.name, "test-from-json");
1402 assert!(quill.glue.unwrap().contains("Test Glue"));
1403 assert!(quill.metadata.contains_key("backend"));
1404 }
1405
1406 #[test]
1407 fn test_from_json_with_byte_array() {
1408 let json_str = r#"{
1410 "files": {
1411 "Quill.toml": {
1412 "contents": [91, 81, 117, 105, 108, 108, 93, 10, 110, 97, 109, 101, 32, 61, 32, 34, 116, 101, 115, 116, 34, 10, 98, 97, 99, 107, 101, 110, 100, 32, 61, 32, 34, 116, 121, 112, 115, 116, 34, 10, 103, 108, 117, 101, 95, 102, 105, 108, 101, 32, 61, 32, 34, 103, 108, 117, 101, 46, 116, 121, 112, 34, 10, 100, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 32, 61, 32, 34, 84, 101, 115, 116, 32, 113, 117, 105, 108, 108, 34, 10]
1413 },
1414 "glue.typ": {
1415 "contents": "test glue"
1416 }
1417 }
1418 }"#;
1419
1420 let quill = Quill::from_json(json_str).unwrap();
1422
1423 assert_eq!(quill.name, "test");
1425 assert_eq!(quill.glue.unwrap(), "test glue");
1426 }
1427
1428 #[test]
1429 fn test_from_json_missing_files() {
1430 let json_str = r#"{
1432 "metadata": {
1433 "name": "test"
1434 }
1435 }"#;
1436
1437 let result = Quill::from_json(json_str);
1438 assert!(result.is_err());
1439 assert!(result.unwrap_err().to_string().contains("files"));
1441 }
1442
1443 #[test]
1444 fn test_from_json_tree_structure() {
1445 let json_str = r#"{
1447 "files": {
1448 "Quill.toml": {
1449 "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1450 },
1451 "glue.typ": {
1452 "contents": "= Test Glue\n\nTree structure content."
1453 }
1454 }
1455 }"#;
1456
1457 let quill = Quill::from_json(json_str).unwrap();
1458
1459 assert_eq!(quill.name, "test-tree-json");
1460 assert!(quill.glue.unwrap().contains("Tree structure content"));
1461 assert!(quill.metadata.contains_key("backend"));
1462 }
1463
1464 #[test]
1465 fn test_from_json_nested_tree_structure() {
1466 let json_str = r#"{
1468 "files": {
1469 "Quill.toml": {
1470 "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Nested test\"\n"
1471 },
1472 "glue.typ": {
1473 "contents": "glue"
1474 },
1475 "src": {
1476 "main.rs": {
1477 "contents": "fn main() {}"
1478 },
1479 "lib.rs": {
1480 "contents": "// lib"
1481 }
1482 }
1483 }
1484 }"#;
1485
1486 let quill = Quill::from_json(json_str).unwrap();
1487
1488 assert_eq!(quill.name, "nested-test");
1489 assert!(quill.file_exists("src/main.rs"));
1491 assert!(quill.file_exists("src/lib.rs"));
1492
1493 let main_rs = quill.get_file("src/main.rs").unwrap();
1494 assert_eq!(main_rs, b"fn main() {}");
1495 }
1496
1497 #[test]
1498 fn test_from_tree_structure_direct() {
1499 let mut root_files = HashMap::new();
1501
1502 root_files.insert(
1503 "Quill.toml".to_string(),
1504 FileTreeNode::File {
1505 contents:
1506 b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1507 .to_vec(),
1508 },
1509 );
1510
1511 root_files.insert(
1512 "glue.typ".to_string(),
1513 FileTreeNode::File {
1514 contents: b"glue content".to_vec(),
1515 },
1516 );
1517
1518 let mut src_files = HashMap::new();
1520 src_files.insert(
1521 "main.rs".to_string(),
1522 FileTreeNode::File {
1523 contents: b"fn main() {}".to_vec(),
1524 },
1525 );
1526
1527 root_files.insert(
1528 "src".to_string(),
1529 FileTreeNode::Directory { files: src_files },
1530 );
1531
1532 let root = FileTreeNode::Directory { files: root_files };
1533
1534 let quill = Quill::from_tree(root, None).unwrap();
1535
1536 assert_eq!(quill.name, "direct-tree");
1537 assert!(quill.file_exists("src/main.rs"));
1538 assert!(quill.file_exists("glue.typ"));
1539 }
1540
1541 #[test]
1542 fn test_from_json_with_metadata_override() {
1543 let json_str = r#"{
1545 "metadata": {
1546 "name": "override-name"
1547 },
1548 "files": {
1549 "Quill.toml": {
1550 "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1551 },
1552 "glue.typ": {
1553 "contents": "= glue"
1554 }
1555 }
1556 }"#;
1557
1558 let quill = Quill::from_json(json_str).unwrap();
1559 assert_eq!(quill.name, "toml-name");
1562 }
1563
1564 #[test]
1565 fn test_from_json_empty_directory() {
1566 let json_str = r#"{
1568 "files": {
1569 "Quill.toml": {
1570 "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1571 },
1572 "glue.typ": {
1573 "contents": "glue"
1574 },
1575 "empty_dir": {}
1576 }
1577 }"#;
1578
1579 let quill = Quill::from_json(json_str).unwrap();
1580 assert_eq!(quill.name, "empty-dir-test");
1581 assert!(quill.dir_exists("empty_dir"));
1582 assert!(!quill.file_exists("empty_dir"));
1583 }
1584
1585 #[test]
1586 fn test_dir_exists_and_list_apis() {
1587 let mut root_files = HashMap::new();
1588
1589 root_files.insert(
1591 "Quill.toml".to_string(),
1592 FileTreeNode::File {
1593 contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"\n"
1594 .to_vec(),
1595 },
1596 );
1597
1598 root_files.insert(
1600 "glue.typ".to_string(),
1601 FileTreeNode::File {
1602 contents: b"glue content".to_vec(),
1603 },
1604 );
1605
1606 let mut assets_files = HashMap::new();
1608 assets_files.insert(
1609 "logo.png".to_string(),
1610 FileTreeNode::File {
1611 contents: vec![137, 80, 78, 71],
1612 },
1613 );
1614 assets_files.insert(
1615 "icon.svg".to_string(),
1616 FileTreeNode::File {
1617 contents: b"<svg></svg>".to_vec(),
1618 },
1619 );
1620
1621 let mut fonts_files = HashMap::new();
1623 fonts_files.insert(
1624 "font.ttf".to_string(),
1625 FileTreeNode::File {
1626 contents: b"font data".to_vec(),
1627 },
1628 );
1629 assets_files.insert(
1630 "fonts".to_string(),
1631 FileTreeNode::Directory { files: fonts_files },
1632 );
1633
1634 root_files.insert(
1635 "assets".to_string(),
1636 FileTreeNode::Directory {
1637 files: assets_files,
1638 },
1639 );
1640
1641 root_files.insert(
1643 "empty".to_string(),
1644 FileTreeNode::Directory {
1645 files: HashMap::new(),
1646 },
1647 );
1648
1649 let root = FileTreeNode::Directory { files: root_files };
1650 let quill = Quill::from_tree(root, None).unwrap();
1651
1652 assert!(quill.dir_exists("assets"));
1654 assert!(quill.dir_exists("assets/fonts"));
1655 assert!(quill.dir_exists("empty"));
1656 assert!(!quill.dir_exists("nonexistent"));
1657 assert!(!quill.dir_exists("glue.typ")); assert!(quill.file_exists("glue.typ"));
1661 assert!(quill.file_exists("assets/logo.png"));
1662 assert!(quill.file_exists("assets/fonts/font.ttf"));
1663 assert!(!quill.file_exists("assets")); let root_files_list = quill.list_files("");
1667 assert_eq!(root_files_list.len(), 2); assert!(root_files_list.contains(&"Quill.toml".to_string()));
1669 assert!(root_files_list.contains(&"glue.typ".to_string()));
1670
1671 let assets_files_list = quill.list_files("assets");
1672 assert_eq!(assets_files_list.len(), 2); assert!(assets_files_list.contains(&"logo.png".to_string()));
1674 assert!(assets_files_list.contains(&"icon.svg".to_string()));
1675
1676 let root_subdirs = quill.list_subdirectories("");
1678 assert_eq!(root_subdirs.len(), 2); assert!(root_subdirs.contains(&"assets".to_string()));
1680 assert!(root_subdirs.contains(&"empty".to_string()));
1681
1682 let assets_subdirs = quill.list_subdirectories("assets");
1683 assert_eq!(assets_subdirs.len(), 1); assert!(assets_subdirs.contains(&"fonts".to_string()));
1685
1686 let empty_subdirs = quill.list_subdirectories("empty");
1687 assert_eq!(empty_subdirs.len(), 0);
1688 }
1689
1690 #[test]
1691 fn test_field_schemas_parsing() {
1692 let mut root_files = HashMap::new();
1693
1694 let quill_toml = r#"[Quill]
1696name = "taro"
1697backend = "typst"
1698glue_file = "glue.typ"
1699example_file = "taro.md"
1700description = "Test template for field schemas"
1701
1702[fields]
1703author = {description = "Author of document" }
1704ice_cream = {description = "favorite ice cream flavor"}
1705title = {description = "title of document" }
1706"#;
1707 root_files.insert(
1708 "Quill.toml".to_string(),
1709 FileTreeNode::File {
1710 contents: quill_toml.as_bytes().to_vec(),
1711 },
1712 );
1713
1714 let glue_content = "= Test Template\n\nThis is a test.";
1716 root_files.insert(
1717 "glue.typ".to_string(),
1718 FileTreeNode::File {
1719 contents: glue_content.as_bytes().to_vec(),
1720 },
1721 );
1722
1723 root_files.insert(
1725 "taro.md".to_string(),
1726 FileTreeNode::File {
1727 contents: b"# Template".to_vec(),
1728 },
1729 );
1730
1731 let root = FileTreeNode::Directory { files: root_files };
1732
1733 let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1735
1736 assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1738 assert!(quill.schema["properties"]
1739 .as_object()
1740 .unwrap()
1741 .contains_key("author"));
1742 assert!(quill.schema["properties"]
1743 .as_object()
1744 .unwrap()
1745 .contains_key("ice_cream"));
1746 assert!(quill.schema["properties"]
1747 .as_object()
1748 .unwrap()
1749 .contains_key("title"));
1750
1751 let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1753 assert_eq!(author_schema["description"], "Author of document");
1754
1755 let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1757 assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1758
1759 let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1761 assert_eq!(title_schema["description"], "title of document");
1762 }
1763
1764 #[test]
1765 fn test_field_schema_struct() {
1766 let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1768 assert_eq!(schema1.description, "Test description");
1769 assert_eq!(schema1.r#type, None);
1770 assert_eq!(schema1.example, None);
1771 assert_eq!(schema1.default, None);
1772
1773 let yaml_str = r#"
1775description: "Full field schema"
1776type: "string"
1777example: "Example value"
1778default: "Default value"
1779"#;
1780 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1781 let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1782 let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1783 assert_eq!(schema2.name, "test_name");
1784 assert_eq!(schema2.description, "Full field schema");
1785 assert_eq!(schema2.r#type, Some("string".to_string()));
1786 assert_eq!(
1787 schema2.example.as_ref().and_then(|v| v.as_str()),
1788 Some("Example value")
1789 );
1790 assert_eq!(
1791 schema2.default.as_ref().and_then(|v| v.as_str()),
1792 Some("Default value")
1793 );
1794 }
1795
1796 #[test]
1797 fn test_quill_without_glue_file() {
1798 let mut root_files = HashMap::new();
1800
1801 let quill_toml = r#"[Quill]
1803name = "test-no-glue"
1804backend = "typst"
1805description = "Test quill without glue file"
1806"#;
1807 root_files.insert(
1808 "Quill.toml".to_string(),
1809 FileTreeNode::File {
1810 contents: quill_toml.as_bytes().to_vec(),
1811 },
1812 );
1813
1814 let root = FileTreeNode::Directory { files: root_files };
1815
1816 let quill = Quill::from_tree(root, None).unwrap();
1818
1819 assert!(quill.glue.clone().is_none());
1821 assert_eq!(quill.name, "test-no-glue");
1822 }
1823
1824 #[test]
1825 fn test_quill_config_from_toml() {
1826 let toml_content = r#"[Quill]
1828name = "test-config"
1829backend = "typst"
1830description = "Test configuration parsing"
1831version = "1.0.0"
1832author = "Test Author"
1833glue_file = "glue.typ"
1834example_file = "example.md"
1835
1836[typst]
1837packages = ["@preview/bubble:0.2.2"]
1838
1839[fields]
1840title = {description = "Document title", type = "string"}
1841author = {description = "Document author"}
1842"#;
1843
1844 let config = QuillConfig::from_toml(toml_content).unwrap();
1845
1846 assert_eq!(config.name, "test-config");
1848 assert_eq!(config.backend, "typst");
1849 assert_eq!(config.description, "Test configuration parsing");
1850
1851 assert_eq!(config.version, Some("1.0.0".to_string()));
1853 assert_eq!(config.author, Some("Test Author".to_string()));
1854 assert_eq!(config.glue_file, Some("glue.typ".to_string()));
1855 assert_eq!(config.example_file, Some("example.md".to_string()));
1856
1857 assert!(config.typst_config.contains_key("packages"));
1859
1860 assert_eq!(config.fields.len(), 2);
1862 assert!(config.fields.contains_key("title"));
1863 assert!(config.fields.contains_key("author"));
1864
1865 let title_field = &config.fields["title"];
1866 assert_eq!(title_field.description, "Document title");
1867 assert_eq!(title_field.r#type, Some("string".to_string()));
1868 }
1869
1870 #[test]
1871 fn test_quill_config_missing_required_fields() {
1872 let toml_missing_name = r#"[Quill]
1874backend = "typst"
1875description = "Missing name"
1876"#;
1877 let result = QuillConfig::from_toml(toml_missing_name);
1878 assert!(result.is_err());
1879 assert!(result
1880 .unwrap_err()
1881 .to_string()
1882 .contains("Missing required 'name'"));
1883
1884 let toml_missing_backend = r#"[Quill]
1885name = "test"
1886description = "Missing backend"
1887"#;
1888 let result = QuillConfig::from_toml(toml_missing_backend);
1889 assert!(result.is_err());
1890 assert!(result
1891 .unwrap_err()
1892 .to_string()
1893 .contains("Missing required 'backend'"));
1894
1895 let toml_missing_description = r#"[Quill]
1896name = "test"
1897backend = "typst"
1898"#;
1899 let result = QuillConfig::from_toml(toml_missing_description);
1900 assert!(result.is_err());
1901 assert!(result
1902 .unwrap_err()
1903 .to_string()
1904 .contains("Missing required 'description'"));
1905 }
1906
1907 #[test]
1908 fn test_quill_config_empty_description() {
1909 let toml_empty_description = r#"[Quill]
1911name = "test"
1912backend = "typst"
1913description = " "
1914"#;
1915 let result = QuillConfig::from_toml(toml_empty_description);
1916 assert!(result.is_err());
1917 assert!(result
1918 .unwrap_err()
1919 .to_string()
1920 .contains("description' field in [Quill] section cannot be empty"));
1921 }
1922
1923 #[test]
1924 fn test_quill_config_missing_quill_section() {
1925 let toml_no_section = r#"[fields]
1927title = {description = "Title"}
1928"#;
1929 let result = QuillConfig::from_toml(toml_no_section);
1930 assert!(result.is_err());
1931 assert!(result
1932 .unwrap_err()
1933 .to_string()
1934 .contains("Missing required [Quill] section"));
1935 }
1936
1937 #[test]
1938 fn test_quill_from_config_metadata() {
1939 let mut root_files = HashMap::new();
1941
1942 let quill_toml = r#"[Quill]
1943name = "metadata-test"
1944backend = "typst"
1945description = "Test metadata flow"
1946author = "Test Author"
1947custom_field = "custom_value"
1948
1949[typst]
1950packages = ["@preview/bubble:0.2.2"]
1951"#;
1952 root_files.insert(
1953 "Quill.toml".to_string(),
1954 FileTreeNode::File {
1955 contents: quill_toml.as_bytes().to_vec(),
1956 },
1957 );
1958
1959 let root = FileTreeNode::Directory { files: root_files };
1960 let quill = Quill::from_tree(root, None).unwrap();
1961
1962 assert!(quill.metadata.contains_key("backend"));
1964 assert!(quill.metadata.contains_key("description"));
1965 assert!(quill.metadata.contains_key("author"));
1966
1967 assert!(quill.metadata.contains_key("custom_field"));
1969 assert_eq!(
1970 quill.metadata.get("custom_field").unwrap().as_str(),
1971 Some("custom_value")
1972 );
1973
1974 assert!(quill.metadata.contains_key("typst_packages"));
1976 }
1977
1978 #[test]
1979 fn test_json_schema_file_override() {
1980 let mut root_files = HashMap::new();
1982
1983 let custom_schema = r#"{
1985 "$schema": "https://json-schema.org/draft/2019-09/schema",
1986 "type": "object",
1987 "properties": {
1988 "title": {
1989 "type": "string",
1990 "description": "Document title"
1991 },
1992 "author": {
1993 "type": "string",
1994 "description": "Document author",
1995 "default": "Schema Author"
1996 },
1997 "version": {
1998 "type": "number",
1999 "description": "Version number",
2000 "default": 2
2001 }
2002 },
2003 "required": ["title"]
2004 }"#;
2005
2006 root_files.insert(
2007 "schema.json".to_string(),
2008 FileTreeNode::File {
2009 contents: custom_schema.as_bytes().to_vec(),
2010 },
2011 );
2012
2013 let quill_toml = r#"[Quill]
2014name = "schema-file-test"
2015backend = "typst"
2016description = "Test json_schema_file"
2017json_schema_file = "schema.json"
2018
2019[fields]
2020author = {description = "This should be ignored", default = "Fields Author"}
2021status = {description = "This should also be ignored"}
2022"#;
2023
2024 root_files.insert(
2025 "Quill.toml".to_string(),
2026 FileTreeNode::File {
2027 contents: quill_toml.as_bytes().to_vec(),
2028 },
2029 );
2030
2031 let root = FileTreeNode::Directory { files: root_files };
2032 let quill = Quill::from_tree(root, None).unwrap();
2033
2034 let properties = quill.schema["properties"].as_object().unwrap();
2036 assert_eq!(properties.len(), 3); assert!(properties.contains_key("title"));
2038 assert!(properties.contains_key("author"));
2039 assert!(properties.contains_key("version"));
2040 assert!(!properties.contains_key("status")); let defaults = quill.extract_defaults();
2044 assert_eq!(defaults.len(), 2); assert_eq!(
2046 defaults.get("author").unwrap().as_str(),
2047 Some("Schema Author")
2048 );
2049 assert_eq!(defaults.get("version").unwrap().as_json().as_i64(), Some(2));
2050
2051 let required = quill.schema["required"].as_array().unwrap();
2053 assert_eq!(required.len(), 1);
2054 assert!(required.contains(&serde_json::json!("title")));
2055 }
2056
2057 #[test]
2058 fn test_extract_defaults_method() {
2059 let mut root_files = HashMap::new();
2061
2062 let quill_toml = r#"[Quill]
2063name = "defaults-test"
2064backend = "typst"
2065description = "Test defaults extraction"
2066
2067[fields]
2068title = {description = "Title"}
2069author = {description = "Author", default = "Anonymous"}
2070status = {description = "Status", default = "draft"}
2071"#;
2072
2073 root_files.insert(
2074 "Quill.toml".to_string(),
2075 FileTreeNode::File {
2076 contents: quill_toml.as_bytes().to_vec(),
2077 },
2078 );
2079
2080 let root = FileTreeNode::Directory { files: root_files };
2081 let quill = Quill::from_tree(root, None).unwrap();
2082
2083 let defaults = quill.extract_defaults();
2085
2086 assert_eq!(defaults.len(), 2);
2088 assert!(!defaults.contains_key("title")); assert!(defaults.contains_key("author"));
2090 assert!(defaults.contains_key("status"));
2091
2092 assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2094 assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2095 }
2096}