quillmark_core/
quill.rs

1//! Quill template bundle types and implementations.
2
3use 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/// UI-specific metadata for field rendering
11#[derive(Debug, Clone, PartialEq)]
12pub struct UiSchema {
13    /// Group name for organizing fields (e.g., "Personal Info", "Preferences")
14    pub group: Option<String>,
15    /// Short tooltip text for the field (concise hint, unlike verbose description)
16    pub tooltip: Option<String>,
17    /// Order of the field in the UI
18    pub order: Option<i32>,
19    /// Additional UI-specific properties
20    pub extra: HashMap<String, QuillValue>,
21}
22
23/// Schema definition for a template field
24#[derive(Debug, Clone, PartialEq)]
25pub struct FieldSchema {
26    pub name: String,
27    /// Field type hint (e.g., "string", "number", "boolean", "object", "array")
28    pub r#type: Option<String>,
29    /// Description of the field
30    pub description: String,
31    /// Default value for the field
32    pub default: Option<QuillValue>,
33    /// Example value for the field
34    pub example: Option<QuillValue>,
35    /// Example values for the field
36    pub examples: Option<QuillValue>,
37    /// UI-specific metadata for rendering
38    pub ui: Option<UiSchema>,
39}
40
41impl FieldSchema {
42    /// Create a new FieldSchema with default values
43    pub fn new(name: String, description: String) -> Self {
44        Self {
45            name,
46            r#type: None,
47            description,
48            default: None,
49            example: None,
50            examples: None,
51            ui: None,
52        }
53    }
54
55    /// Parse a FieldSchema from a QuillValue
56    pub fn from_quill_value(key: String, value: &QuillValue) -> Result<Self, String> {
57        let obj = value
58            .as_object()
59            .ok_or_else(|| "Field schema must be an object".to_string())?;
60
61        //Ensure only known keys are present
62        for key in obj.keys() {
63            match key.as_str() {
64                "name" | "type" | "description" | "example" | "default" | "ui" => {}
65                _ => {
66                    return Err(format!("Unknown key '{}' in field schema", key));
67                }
68            }
69        }
70
71        let name = key.clone();
72
73        let description = obj
74            .get("description")
75            .and_then(|v| v.as_str())
76            .unwrap_or("")
77            .to_string();
78
79        let field_type = obj
80            .get("type")
81            .and_then(|v| v.as_str())
82            .map(|s| s.to_string());
83
84        let default = obj.get("default").map(|v| QuillValue::from_json(v.clone()));
85
86        let example = obj.get("example").map(|v| QuillValue::from_json(v.clone()));
87
88        let examples = obj
89            .get("examples")
90            .map(|v| QuillValue::from_json(v.clone()));
91
92        // Parse UI metadata if present
93        let ui = if let Some(ui_value) = obj.get("ui") {
94            if let Some(ui_obj) = ui_value.as_object() {
95                let group = ui_obj
96                    .get("group")
97                    .and_then(|v| v.as_str())
98                    .map(|s| s.to_string());
99
100                let tooltip = ui_obj
101                    .get("tooltip")
102                    .and_then(|v| v.as_str())
103                    .map(|s| s.to_string());
104
105                // Collect any extra UI properties
106                let mut extra = HashMap::new();
107                for (ui_key, ui_val) in ui_obj {
108                    match ui_key.as_str() {
109                        "group" | "tooltip" => {}
110                        _ => {
111                            extra.insert(ui_key.clone(), QuillValue::from_json(ui_val.clone()));
112                        }
113                    }
114                }
115
116                Some(UiSchema {
117                    group,
118                    tooltip,
119                    order: None, // Order is determined by position in Quill.toml
120                    extra,
121                })
122            } else {
123                return Err("UI field must be an object".to_string());
124            }
125        } else {
126            None
127        };
128
129        Ok(Self {
130            name,
131            r#type: field_type,
132            description,
133            default,
134            example,
135            examples,
136            ui,
137        })
138    }
139}
140
141/// A node in the file tree structure
142#[derive(Debug, Clone)]
143pub enum FileTreeNode {
144    /// A file with its contents
145    File {
146        /// The file contents as bytes or UTF-8 string
147        contents: Vec<u8>,
148    },
149    /// A directory containing other files and directories
150    Directory {
151        /// The files and subdirectories in this directory
152        files: HashMap<String, FileTreeNode>,
153    },
154}
155
156impl FileTreeNode {
157    /// Get a file or directory node by path
158    pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
159        let path = path.as_ref();
160
161        // Handle root path
162        if path == Path::new("") {
163            return Some(self);
164        }
165
166        // Split path into components
167        let components: Vec<_> = path
168            .components()
169            .filter_map(|c| {
170                if let std::path::Component::Normal(s) = c {
171                    s.to_str()
172                } else {
173                    None
174                }
175            })
176            .collect();
177
178        if components.is_empty() {
179            return Some(self);
180        }
181
182        // Navigate through the tree
183        let mut current_node = self;
184        for component in components {
185            match current_node {
186                FileTreeNode::Directory { files } => {
187                    current_node = files.get(component)?;
188                }
189                FileTreeNode::File { .. } => {
190                    return None; // Can't traverse into a file
191                }
192            }
193        }
194
195        Some(current_node)
196    }
197
198    /// Get file contents by path
199    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
200        match self.get_node(path)? {
201            FileTreeNode::File { contents } => Some(contents.as_slice()),
202            FileTreeNode::Directory { .. } => None,
203        }
204    }
205
206    /// Check if a file exists at the given path
207    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
208        matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
209    }
210
211    /// Check if a directory exists at the given path
212    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
213        matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
214    }
215
216    /// List all files in a directory (non-recursive)
217    pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
218        match self.get_node(dir_path) {
219            Some(FileTreeNode::Directory { files }) => files
220                .iter()
221                .filter_map(|(name, node)| {
222                    if matches!(node, FileTreeNode::File { .. }) {
223                        Some(name.clone())
224                    } else {
225                        None
226                    }
227                })
228                .collect(),
229            _ => Vec::new(),
230        }
231    }
232
233    /// List all subdirectories in a directory (non-recursive)
234    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
235        match self.get_node(dir_path) {
236            Some(FileTreeNode::Directory { files }) => files
237                .iter()
238                .filter_map(|(name, node)| {
239                    if matches!(node, FileTreeNode::Directory { .. }) {
240                        Some(name.clone())
241                    } else {
242                        None
243                    }
244                })
245                .collect(),
246            _ => Vec::new(),
247        }
248    }
249
250    /// Insert a file or directory at the given path
251    pub fn insert<P: AsRef<Path>>(
252        &mut self,
253        path: P,
254        node: FileTreeNode,
255    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
256        let path = path.as_ref();
257
258        // Split path into components
259        let components: Vec<_> = path
260            .components()
261            .filter_map(|c| {
262                if let std::path::Component::Normal(s) = c {
263                    s.to_str().map(|s| s.to_string())
264                } else {
265                    None
266                }
267            })
268            .collect();
269
270        if components.is_empty() {
271            return Err("Cannot insert at root path".into());
272        }
273
274        // Navigate to parent directory, creating directories as needed
275        let mut current_node = self;
276        for component in &components[..components.len() - 1] {
277            match current_node {
278                FileTreeNode::Directory { files } => {
279                    current_node =
280                        files
281                            .entry(component.clone())
282                            .or_insert_with(|| FileTreeNode::Directory {
283                                files: HashMap::new(),
284                            });
285                }
286                FileTreeNode::File { .. } => {
287                    return Err("Cannot traverse into a file".into());
288                }
289            }
290        }
291
292        // Insert the new node
293        let filename = &components[components.len() - 1];
294        match current_node {
295            FileTreeNode::Directory { files } => {
296                files.insert(filename.clone(), node);
297                Ok(())
298            }
299            FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
300        }
301    }
302
303    /// Parse a tree structure from JSON value
304    fn from_json_value(value: &serde_json::Value) -> Result<Self, Box<dyn StdError + Send + Sync>> {
305        if let Some(contents_str) = value.get("contents").and_then(|v| v.as_str()) {
306            // It's a file with string contents
307            Ok(FileTreeNode::File {
308                contents: contents_str.as_bytes().to_vec(),
309            })
310        } else if let Some(bytes_array) = value.get("contents").and_then(|v| v.as_array()) {
311            // It's a file with byte array contents
312            let contents: Vec<u8> = bytes_array
313                .iter()
314                .filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
315                .collect();
316            Ok(FileTreeNode::File { contents })
317        } else if let Some(obj) = value.as_object() {
318            // It's a directory (either empty or with nested files)
319            let mut files = HashMap::new();
320            for (name, child_value) in obj {
321                files.insert(name.clone(), Self::from_json_value(child_value)?);
322            }
323            // Empty directories are valid
324            Ok(FileTreeNode::Directory { files })
325        } else {
326            Err(format!("Invalid file tree node: {:?}", value).into())
327        }
328    }
329
330    pub fn print_tree(&self) -> String {
331        self.__print_tree("", "", true)
332    }
333
334    pub fn __print_tree(&self, name: &str, prefix: &str, is_last: bool) -> String {
335        let mut result = String::new();
336
337        // Choose the appropriate tree characters
338        let connector = if is_last { "└── " } else { "├── " };
339        let extension = if is_last { "    " } else { "│   " };
340
341        match self {
342            FileTreeNode::File { .. } => {
343                result.push_str(&format!("{}{}{}\n", prefix, connector, name));
344            }
345            FileTreeNode::Directory { files } => {
346                // Add trailing slash for directories like `tree` does
347                result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
348
349                let child_prefix = format!("{}{}", prefix, extension);
350                let count = files.len();
351
352                for (i, (child_name, node)) in files.iter().enumerate() {
353                    let is_last_child = i == count - 1;
354                    result.push_str(&node.__print_tree(child_name, &child_prefix, is_last_child));
355                }
356            }
357        }
358
359        result
360    }
361}
362
363/// Simple gitignore-style pattern matcher for .quillignore
364#[derive(Debug, Clone)]
365pub struct QuillIgnore {
366    patterns: Vec<String>,
367}
368
369impl QuillIgnore {
370    /// Create a new QuillIgnore from pattern strings
371    pub fn new(patterns: Vec<String>) -> Self {
372        Self { patterns }
373    }
374
375    /// Parse .quillignore content into patterns
376    pub fn from_content(content: &str) -> Self {
377        let patterns = content
378            .lines()
379            .map(|line| line.trim())
380            .filter(|line| !line.is_empty() && !line.starts_with('#'))
381            .map(|line| line.to_string())
382            .collect();
383        Self::new(patterns)
384    }
385
386    /// Check if a path should be ignored
387    pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
388        let path = path.as_ref();
389        let path_str = path.to_string_lossy();
390
391        for pattern in &self.patterns {
392            if self.matches_pattern(pattern, &path_str) {
393                return true;
394            }
395        }
396        false
397    }
398
399    /// Simple pattern matching (supports * wildcard and directory patterns)
400    fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
401        // Handle directory patterns
402        if pattern.ends_with('/') {
403            let pattern_prefix = &pattern[..pattern.len() - 1];
404            return path.starts_with(pattern_prefix)
405                && (path.len() == pattern_prefix.len()
406                    || path.chars().nth(pattern_prefix.len()) == Some('/'));
407        }
408
409        // Handle exact matches
410        if !pattern.contains('*') {
411            return path == pattern || path.ends_with(&format!("/{}", pattern));
412        }
413
414        // Simple wildcard matching
415        if pattern == "*" {
416            return true;
417        }
418
419        // Handle patterns with wildcards
420        let pattern_parts: Vec<&str> = pattern.split('*').collect();
421        if pattern_parts.len() == 2 {
422            let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
423            if prefix.is_empty() {
424                return path.ends_with(suffix);
425            } else if suffix.is_empty() {
426                return path.starts_with(prefix);
427            } else {
428                return path.starts_with(prefix) && path.ends_with(suffix);
429            }
430        }
431
432        false
433    }
434}
435
436/// A quill template bundle.
437#[derive(Debug, Clone)]
438pub struct Quill {
439    /// Quill-specific metadata
440    pub metadata: HashMap<String, QuillValue>,
441    /// Name of the quill
442    pub name: String,
443    /// Backend identifier (e.g., "typst")
444    pub backend: String,
445    /// Glue template content (optional)
446    pub glue: Option<String>,
447    /// Markdown template content (optional)
448    pub example: Option<String>,
449    /// Field JSON schema (single source of truth for schema and defaults)
450    pub schema: QuillValue,
451    /// Cached default values extracted from schema (for performance)
452    pub defaults: HashMap<String, QuillValue>,
453    /// Cached example values extracted from schema (for performance)
454    pub examples: HashMap<String, Vec<QuillValue>>,
455    /// In-memory file system (tree structure)
456    pub files: FileTreeNode,
457}
458
459/// Quill configuration extracted from Quill.toml
460#[derive(Debug, Clone)]
461pub struct QuillConfig {
462    /// Human-readable name
463    pub name: String,
464    /// Description of the quill
465    pub description: String,
466    /// Backend identifier (e.g., "typst")
467    pub backend: String,
468    /// Semantic version of the quill
469    pub version: Option<String>,
470    /// Author of the quill
471    pub author: Option<String>,
472    /// Example markdown file
473    pub example_file: Option<String>,
474    /// Glue file
475    pub glue_file: Option<String>,
476    /// JSON schema file
477    pub json_schema_file: Option<String>,
478    /// Field schemas
479    pub fields: HashMap<String, FieldSchema>,
480    /// Additional metadata from [Quill] section (excluding standard fields)
481    pub metadata: HashMap<String, QuillValue>,
482    /// Typst-specific configuration from `[typst]` section
483    pub typst_config: HashMap<String, QuillValue>,
484}
485
486impl QuillConfig {
487    /// Parse QuillConfig from TOML content
488    pub fn from_toml(toml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
489        let quill_toml: toml::Value = toml::from_str(toml_content)
490            .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
491
492        // Parse with toml_edit to get field order
493        let field_order: Vec<String> = toml_content
494            .parse::<toml_edit::DocumentMut>()
495            .ok()
496            .and_then(|doc| {
497                doc.get("fields")
498                    .and_then(|item| item.as_table())
499                    .map(|table| table.iter().map(|(k, _)| k.to_string()).collect())
500            })
501            .unwrap_or_default();
502
503        // Extract [Quill] section (required)
504        let quill_section = quill_toml
505            .get("Quill")
506            .ok_or("Missing required [Quill] section in Quill.toml")?;
507
508        // Extract required fields
509        let name = quill_section
510            .get("name")
511            .and_then(|v| v.as_str())
512            .ok_or("Missing required 'name' field in [Quill] section")?
513            .to_string();
514
515        let backend = quill_section
516            .get("backend")
517            .and_then(|v| v.as_str())
518            .ok_or("Missing required 'backend' field in [Quill] section")?
519            .to_string();
520
521        let description = quill_section
522            .get("description")
523            .and_then(|v| v.as_str())
524            .ok_or("Missing required 'description' field in [Quill] section")?;
525
526        if description.trim().is_empty() {
527            return Err("'description' field in [Quill] section cannot be empty".into());
528        }
529        let description = description.to_string();
530
531        // Extract optional fields
532        let version = quill_section
533            .get("version")
534            .and_then(|v| v.as_str())
535            .map(|s| s.to_string());
536
537        let author = quill_section
538            .get("author")
539            .and_then(|v| v.as_str())
540            .map(|s| s.to_string());
541
542        let example_file = quill_section
543            .get("example_file")
544            .and_then(|v| v.as_str())
545            .map(|s| s.to_string());
546
547        let glue_file = quill_section
548            .get("glue_file")
549            .and_then(|v| v.as_str())
550            .map(|s| s.to_string());
551
552        let json_schema_file = quill_section
553            .get("json_schema_file")
554            .and_then(|v| v.as_str())
555            .map(|s| s.to_string());
556
557        // Extract additional metadata from [Quill] section (excluding standard fields)
558        let mut metadata = HashMap::new();
559        if let toml::Value::Table(table) = quill_section {
560            for (key, value) in table {
561                // Skip standard fields that are stored in dedicated struct fields
562                if key != "name"
563                    && key != "backend"
564                    && key != "description"
565                    && key != "version"
566                    && key != "author"
567                    && key != "example_file"
568                    && key != "glue_file"
569                    && key != "json_schema_file"
570                {
571                    match QuillValue::from_toml(value) {
572                        Ok(quill_value) => {
573                            metadata.insert(key.clone(), quill_value);
574                        }
575                        Err(e) => {
576                            eprintln!("Warning: Failed to convert field '{}': {}", key, e);
577                        }
578                    }
579                }
580            }
581        }
582
583        // Extract [typst] section (optional)
584        let mut typst_config = HashMap::new();
585        if let Some(typst_section) = quill_toml.get("typst") {
586            if let toml::Value::Table(table) = typst_section {
587                for (key, value) in table {
588                    match QuillValue::from_toml(value) {
589                        Ok(quill_value) => {
590                            typst_config.insert(key.clone(), quill_value);
591                        }
592                        Err(e) => {
593                            eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
594                        }
595                    }
596                }
597            }
598        }
599
600        // Extract [fields] section (optional)
601        let mut fields = HashMap::new();
602        if let Some(fields_section) = quill_toml.get("fields") {
603            if let toml::Value::Table(fields_table) = fields_section {
604                let mut order_counter = 0;
605                for (field_name, field_schema) in fields_table {
606                    // Determine order
607                    let order = if let Some(idx) = field_order.iter().position(|k| k == field_name)
608                    {
609                        idx as i32
610                    } else {
611                        let o = field_order.len() as i32 + order_counter;
612                        order_counter += 1;
613                        o
614                    };
615
616                    match QuillValue::from_toml(field_schema) {
617                        Ok(quill_value) => {
618                            match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
619                                Ok(mut schema) => {
620                                    // Always set ui.order based on position in Quill.toml
621                                    if schema.ui.is_none() {
622                                        schema.ui = Some(UiSchema {
623                                            group: None,
624                                            tooltip: None,
625                                            order: Some(order),
626                                            extra: HashMap::new(),
627                                        });
628                                    } else if let Some(ui) = &mut schema.ui {
629                                        ui.order = Some(order);
630                                    }
631
632                                    fields.insert(field_name.clone(), schema);
633                                }
634                                Err(e) => {
635                                    eprintln!(
636                                        "Warning: Failed to parse field schema '{}': {}",
637                                        field_name, e
638                                    );
639                                }
640                            }
641                        }
642                        Err(e) => {
643                            eprintln!(
644                                "Warning: Failed to convert field schema '{}': {}",
645                                field_name, e
646                            );
647                        }
648                    }
649                }
650            }
651        }
652
653        Ok(QuillConfig {
654            name,
655            description,
656            backend,
657            version,
658            author,
659            example_file,
660            glue_file,
661            json_schema_file,
662            fields,
663            metadata,
664            typst_config,
665        })
666    }
667}
668
669impl Quill {
670    /// Create a Quill from a directory path
671    pub fn from_path<P: AsRef<std::path::Path>>(
672        path: P,
673    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
674        use std::fs;
675
676        let path = path.as_ref();
677        let name = path
678            .file_name()
679            .and_then(|n| n.to_str())
680            .unwrap_or("unnamed")
681            .to_string();
682
683        // Load .quillignore if it exists
684        let quillignore_path = path.join(".quillignore");
685        let ignore = if quillignore_path.exists() {
686            let ignore_content = fs::read_to_string(&quillignore_path)
687                .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
688            QuillIgnore::from_content(&ignore_content)
689        } else {
690            // Default ignore patterns
691            QuillIgnore::new(vec![
692                ".git/".to_string(),
693                ".gitignore".to_string(),
694                ".quillignore".to_string(),
695                "target/".to_string(),
696                "node_modules/".to_string(),
697            ])
698        };
699
700        // Load all files into a tree structure
701        let root = Self::load_directory_as_tree(path, path, &ignore)?;
702
703        // Create Quill from the file tree
704        Self::from_tree(root, Some(name))
705    }
706
707    /// Create a Quill from a tree structure
708    ///
709    /// This is the authoritative method for creating a Quill from an in-memory file tree.
710    ///
711    /// # Arguments
712    ///
713    /// * `root` - The root node of the file tree
714    /// * `_default_name` - Unused parameter kept for API compatibility (name always from Quill.toml)
715    ///
716    /// # Errors
717    ///
718    /// Returns an error if:
719    /// - Quill.toml is not found in the file tree
720    /// - Quill.toml is not valid UTF-8 or TOML
721    /// - The glue file specified in Quill.toml is not found or not valid UTF-8
722    /// - Validation fails
723    pub fn from_tree(
724        root: FileTreeNode,
725        _default_name: Option<String>,
726    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
727        // Read Quill.toml
728        let quill_toml_bytes = root
729            .get_file("Quill.toml")
730            .ok_or("Quill.toml not found in file tree")?;
731
732        let quill_toml_content = String::from_utf8(quill_toml_bytes.to_vec())
733            .map_err(|e| format!("Quill.toml is not valid UTF-8: {}", e))?;
734
735        // Parse TOML into QuillConfig
736        let config = QuillConfig::from_toml(&quill_toml_content)?;
737
738        // Construct Quill from QuillConfig
739        Self::from_config(config, root)
740    }
741
742    /// Create a Quill from a QuillConfig and file tree
743    ///
744    /// This method constructs a Quill from a parsed QuillConfig and validates
745    /// all file references.
746    ///
747    /// # Arguments
748    ///
749    /// * `config` - The parsed QuillConfig
750    /// * `root` - The root node of the file tree
751    ///
752    /// # Errors
753    ///
754    /// Returns an error if:
755    /// - The glue file specified in config is not found or not valid UTF-8
756    /// - The example file specified in config is not found or not valid UTF-8
757    /// - The json_schema_file is not found or not valid JSON
758    /// - Validation fails
759    fn from_config(
760        config: QuillConfig,
761        root: FileTreeNode,
762    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
763        // Build metadata from config
764        let mut metadata = config.metadata.clone();
765
766        // Add backend to metadata
767        metadata.insert(
768            "backend".to_string(),
769            QuillValue::from_json(serde_json::Value::String(config.backend.clone())),
770        );
771
772        // Add description to metadata
773        metadata.insert(
774            "description".to_string(),
775            QuillValue::from_json(serde_json::Value::String(config.description.clone())),
776        );
777
778        // Add author if present
779        if let Some(ref author) = config.author {
780            metadata.insert(
781                "author".to_string(),
782                QuillValue::from_json(serde_json::Value::String(author.clone())),
783            );
784        }
785
786        // Add typst config to metadata with typst_ prefix
787        for (key, value) in &config.typst_config {
788            metadata.insert(format!("typst_{}", key), value.clone());
789        }
790
791        // Load or build JSON schema
792        let schema = if let Some(ref json_schema_path) = config.json_schema_file {
793            // Load schema from file if specified
794            let schema_bytes = root.get_file(json_schema_path).ok_or_else(|| {
795                format!(
796                    "json_schema_file '{}' not found in file tree",
797                    json_schema_path
798                )
799            })?;
800
801            // Parse and validate JSON syntax
802            let schema_json =
803                serde_json::from_slice::<serde_json::Value>(schema_bytes).map_err(|e| {
804                    format!(
805                        "json_schema_file '{}' is not valid JSON: {}",
806                        json_schema_path, e
807                    )
808                })?;
809
810            // Warn if fields are also defined
811            if !config.fields.is_empty() {
812                eprintln!("Warning: [fields] section is overridden by json_schema_file");
813            }
814
815            QuillValue::from_json(schema_json)
816        } else {
817            // Build JSON schema from field schemas if no json_schema_file
818            build_schema_from_fields(&config.fields)
819                .map_err(|e| format!("Failed to build JSON schema from field schemas: {}", e))?
820        };
821
822        // Read the glue content from glue file (if specified)
823        let glue_content: Option<String> = if let Some(ref glue_file_name) = config.glue_file {
824            let glue_bytes = root
825                .get_file(glue_file_name)
826                .ok_or_else(|| format!("Glue file '{}' not found in file tree", glue_file_name))?;
827
828            let content = String::from_utf8(glue_bytes.to_vec())
829                .map_err(|e| format!("Glue file '{}' is not valid UTF-8: {}", glue_file_name, e))?;
830            Some(content)
831        } else {
832            // No glue file specified
833            None
834        };
835
836        // Read the markdown example content if specified
837        let example_content = if let Some(ref example_file_name) = config.example_file {
838            root.get_file(example_file_name).and_then(|bytes| {
839                String::from_utf8(bytes.to_vec())
840                    .map_err(|e| {
841                        eprintln!(
842                            "Warning: Example file '{}' is not valid UTF-8: {}",
843                            example_file_name, e
844                        );
845                        e
846                    })
847                    .ok()
848            })
849        } else {
850            None
851        };
852
853        // Extract and cache defaults and examples from schema for performance
854        let defaults = crate::schema::extract_defaults_from_schema(&schema);
855        let examples = crate::schema::extract_examples_from_schema(&schema);
856
857        let quill = Quill {
858            metadata,
859            name: config.name,
860            backend: config.backend,
861            glue: glue_content,
862            example: example_content,
863            schema,
864            defaults,
865            examples,
866            files: root,
867        };
868
869        Ok(quill)
870    }
871
872    /// Create a Quill from a JSON representation
873    ///
874    /// Parses a JSON string into an in-memory file tree and validates it. The
875    /// precise JSON contract is documented in `designs/QUILL.md`.
876    /// The JSON format MUST have a root object with a `files` key. The optional
877    /// `metadata` key provides additional metadata that overrides defaults.
878    pub fn from_json(json_str: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
879        use serde_json::Value as JsonValue;
880
881        let json: JsonValue =
882            serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
883
884        let obj = json.as_object().ok_or_else(|| "Root must be an object")?;
885
886        // Extract metadata (optional)
887        let default_name = obj
888            .get("metadata")
889            .and_then(|m| m.get("name"))
890            .and_then(|v| v.as_str())
891            .map(String::from);
892
893        // Extract files (required)
894        let files_obj = obj
895            .get("files")
896            .and_then(|v| v.as_object())
897            .ok_or_else(|| "Missing or invalid 'files' key")?;
898
899        // Parse file tree
900        let mut root_files = HashMap::new();
901        for (key, value) in files_obj {
902            root_files.insert(key.clone(), FileTreeNode::from_json_value(value)?);
903        }
904
905        let root = FileTreeNode::Directory { files: root_files };
906
907        // Create Quill from tree
908        Self::from_tree(root, default_name)
909    }
910
911    /// Recursively load all files from a directory into a tree structure
912    fn load_directory_as_tree(
913        current_dir: &Path,
914        base_dir: &Path,
915        ignore: &QuillIgnore,
916    ) -> Result<FileTreeNode, Box<dyn StdError + Send + Sync>> {
917        use std::fs;
918
919        if !current_dir.exists() {
920            return Ok(FileTreeNode::Directory {
921                files: HashMap::new(),
922            });
923        }
924
925        let mut files = HashMap::new();
926
927        for entry in fs::read_dir(current_dir)? {
928            let entry = entry?;
929            let path = entry.path();
930            let relative_path = path
931                .strip_prefix(base_dir)
932                .map_err(|e| format!("Failed to get relative path: {}", e))?
933                .to_path_buf();
934
935            // Check if this path should be ignored
936            if ignore.is_ignored(&relative_path) {
937                continue;
938            }
939
940            // Get the filename
941            let filename = path
942                .file_name()
943                .and_then(|n| n.to_str())
944                .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
945                .to_string();
946
947            if path.is_file() {
948                let contents = fs::read(&path)
949                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
950
951                files.insert(filename, FileTreeNode::File { contents });
952            } else if path.is_dir() {
953                // Recursively process subdirectory
954                let subdir_tree = Self::load_directory_as_tree(&path, base_dir, ignore)?;
955                files.insert(filename, subdir_tree);
956            }
957        }
958
959        Ok(FileTreeNode::Directory { files })
960    }
961
962    /// Get the list of typst packages to download, if specified in Quill.toml
963    pub fn typst_packages(&self) -> Vec<String> {
964        self.metadata
965            .get("typst_packages")
966            .and_then(|v| v.as_array())
967            .map(|arr| {
968                arr.iter()
969                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
970                    .collect()
971            })
972            .unwrap_or_default()
973    }
974
975    /// Get default values from the cached schema defaults
976    ///
977    /// Returns a reference to the pre-computed defaults HashMap that was extracted
978    /// during Quill construction. This is more efficient than re-parsing the schema.
979    ///
980    /// This is used by `ParsedDocument::with_defaults()` to apply default values
981    /// to missing fields.
982    pub fn extract_defaults(&self) -> &HashMap<String, QuillValue> {
983        &self.defaults
984    }
985
986    /// Get example values from the cached schema examples
987    ///
988    /// Returns a reference to the pre-computed examples HashMap that was extracted
989    /// during Quill construction. This is more efficient than re-parsing the schema.
990    pub fn extract_examples(&self) -> &HashMap<String, Vec<QuillValue>> {
991        &self.examples
992    }
993
994    /// Get file contents by path (relative to quill root)
995    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
996        self.files.get_file(path)
997    }
998
999    /// Check if a file exists in memory
1000    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1001        self.files.file_exists(path)
1002    }
1003
1004    /// Check if a directory exists in memory
1005    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
1006        self.files.dir_exists(path)
1007    }
1008
1009    /// List files in a directory (non-recursive, returns file names only)
1010    pub fn list_files<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1011        self.files.list_files(path)
1012    }
1013
1014    /// List subdirectories in a directory (non-recursive, returns directory names only)
1015    pub fn list_subdirectories<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
1016        self.files.list_subdirectories(path)
1017    }
1018
1019    /// List all files in a directory (returns paths relative to quill root)
1020    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1021        let dir_path = dir_path.as_ref();
1022        let filenames = self.files.list_files(dir_path);
1023
1024        // Convert filenames to full paths
1025        filenames
1026            .iter()
1027            .map(|name| {
1028                if dir_path == Path::new("") {
1029                    PathBuf::from(name)
1030                } else {
1031                    dir_path.join(name)
1032                }
1033            })
1034            .collect()
1035    }
1036
1037    /// List all directories in a directory (returns paths relative to quill root)
1038    pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
1039        let dir_path = dir_path.as_ref();
1040        let subdirs = self.files.list_subdirectories(dir_path);
1041
1042        // Convert subdirectory names to full paths
1043        subdirs
1044            .iter()
1045            .map(|name| {
1046                if dir_path == Path::new("") {
1047                    PathBuf::from(name)
1048                } else {
1049                    dir_path.join(name)
1050                }
1051            })
1052            .collect()
1053    }
1054
1055    /// Get all files matching a pattern (supports glob-style wildcards)
1056    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
1057        let pattern_str = pattern.as_ref().to_string_lossy();
1058        let mut matches = Vec::new();
1059
1060        // Compile the glob pattern
1061        let glob_pattern = match glob::Pattern::new(&pattern_str) {
1062            Ok(pat) => pat,
1063            Err(_) => return matches, // Invalid pattern returns empty results
1064        };
1065
1066        // Recursively search the tree for matching files
1067        self.find_files_recursive(&self.files, Path::new(""), &glob_pattern, &mut matches);
1068
1069        matches.sort();
1070        matches
1071    }
1072
1073    /// Helper method to recursively search for files matching a pattern
1074    fn find_files_recursive(
1075        &self,
1076        node: &FileTreeNode,
1077        current_path: &Path,
1078        pattern: &glob::Pattern,
1079        matches: &mut Vec<PathBuf>,
1080    ) {
1081        match node {
1082            FileTreeNode::File { .. } => {
1083                let path_str = current_path.to_string_lossy();
1084                if pattern.matches(&path_str) {
1085                    matches.push(current_path.to_path_buf());
1086                }
1087            }
1088            FileTreeNode::Directory { files } => {
1089                for (name, child_node) in files {
1090                    let child_path = if current_path == Path::new("") {
1091                        PathBuf::from(name)
1092                    } else {
1093                        current_path.join(name)
1094                    };
1095                    self.find_files_recursive(child_node, &child_path, pattern, matches);
1096                }
1097            }
1098        }
1099    }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105    use std::fs;
1106    use tempfile::TempDir;
1107
1108    #[test]
1109    fn test_quillignore_parsing() {
1110        let ignore_content = r#"
1111# This is a comment
1112*.tmp
1113target/
1114node_modules/
1115.git/
1116"#;
1117        let ignore = QuillIgnore::from_content(ignore_content);
1118        assert_eq!(ignore.patterns.len(), 4);
1119        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
1120        assert!(ignore.patterns.contains(&"target/".to_string()));
1121    }
1122
1123    #[test]
1124    fn test_quillignore_matching() {
1125        let ignore = QuillIgnore::new(vec![
1126            "*.tmp".to_string(),
1127            "target/".to_string(),
1128            "node_modules/".to_string(),
1129            ".git/".to_string(),
1130        ]);
1131
1132        // Test file patterns
1133        assert!(ignore.is_ignored("test.tmp"));
1134        assert!(ignore.is_ignored("path/to/file.tmp"));
1135        assert!(!ignore.is_ignored("test.txt"));
1136
1137        // Test directory patterns
1138        assert!(ignore.is_ignored("target"));
1139        assert!(ignore.is_ignored("target/debug"));
1140        assert!(ignore.is_ignored("target/debug/deps"));
1141        assert!(!ignore.is_ignored("src/target.rs"));
1142
1143        assert!(ignore.is_ignored("node_modules"));
1144        assert!(ignore.is_ignored("node_modules/package"));
1145        assert!(!ignore.is_ignored("my_node_modules"));
1146    }
1147
1148    #[test]
1149    fn test_in_memory_file_system() {
1150        let temp_dir = TempDir::new().unwrap();
1151        let quill_dir = temp_dir.path();
1152
1153        // Create test files
1154        fs::write(
1155            quill_dir.join("Quill.toml"),
1156            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1157        )
1158        .unwrap();
1159        fs::write(quill_dir.join("glue.typ"), "test glue").unwrap();
1160
1161        let assets_dir = quill_dir.join("assets");
1162        fs::create_dir_all(&assets_dir).unwrap();
1163        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
1164
1165        let packages_dir = quill_dir.join("packages");
1166        fs::create_dir_all(&packages_dir).unwrap();
1167        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
1168
1169        // Load quill
1170        let quill = Quill::from_path(quill_dir).unwrap();
1171
1172        // Test file access
1173        assert!(quill.file_exists("glue.typ"));
1174        assert!(quill.file_exists("assets/test.txt"));
1175        assert!(quill.file_exists("packages/package.typ"));
1176        assert!(!quill.file_exists("nonexistent.txt"));
1177
1178        // Test file content
1179        let asset_content = quill.get_file("assets/test.txt").unwrap();
1180        assert_eq!(asset_content, b"asset content");
1181
1182        // Test directory listing
1183        let asset_files = quill.list_directory("assets");
1184        assert_eq!(asset_files.len(), 1);
1185        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
1186    }
1187
1188    #[test]
1189    fn test_quillignore_integration() {
1190        let temp_dir = TempDir::new().unwrap();
1191        let quill_dir = temp_dir.path();
1192
1193        // Create .quillignore
1194        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
1195
1196        // Create test files
1197        fs::write(
1198            quill_dir.join("Quill.toml"),
1199            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1200        )
1201        .unwrap();
1202        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
1203        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
1204
1205        let target_dir = quill_dir.join("target");
1206        fs::create_dir_all(&target_dir).unwrap();
1207        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
1208
1209        // Load quill
1210        let quill = Quill::from_path(quill_dir).unwrap();
1211
1212        // Test that ignored files are not loaded
1213        assert!(quill.file_exists("glue.typ"));
1214        assert!(!quill.file_exists("should_ignore.tmp"));
1215        assert!(!quill.file_exists("target/debug.txt"));
1216    }
1217
1218    #[test]
1219    fn test_find_files_pattern() {
1220        let temp_dir = TempDir::new().unwrap();
1221        let quill_dir = temp_dir.path();
1222
1223        // Create test directory structure
1224        fs::write(
1225            quill_dir.join("Quill.toml"),
1226            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"",
1227        )
1228        .unwrap();
1229        fs::write(quill_dir.join("glue.typ"), "template").unwrap();
1230
1231        let assets_dir = quill_dir.join("assets");
1232        fs::create_dir_all(&assets_dir).unwrap();
1233        fs::write(assets_dir.join("image.png"), "png data").unwrap();
1234        fs::write(assets_dir.join("data.json"), "json data").unwrap();
1235
1236        let fonts_dir = assets_dir.join("fonts");
1237        fs::create_dir_all(&fonts_dir).unwrap();
1238        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
1239
1240        // Load quill
1241        let quill = Quill::from_path(quill_dir).unwrap();
1242
1243        // Test pattern matching
1244        let all_assets = quill.find_files("assets/*");
1245        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
1246
1247        let typ_files = quill.find_files("*.typ");
1248        assert_eq!(typ_files.len(), 1);
1249        assert!(typ_files.contains(&PathBuf::from("glue.typ")));
1250    }
1251
1252    #[test]
1253    fn test_new_standardized_toml_format() {
1254        let temp_dir = TempDir::new().unwrap();
1255        let quill_dir = temp_dir.path();
1256
1257        // Create test files using new standardized format
1258        let toml_content = r#"[Quill]
1259name = "my-custom-quill"
1260backend = "typst"
1261glue_file = "custom_glue.typ"
1262description = "Test quill with new format"
1263author = "Test Author"
1264"#;
1265        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1266        fs::write(
1267            quill_dir.join("custom_glue.typ"),
1268            "= Custom Template\n\nThis is a custom template.",
1269        )
1270        .unwrap();
1271
1272        // Load quill
1273        let quill = Quill::from_path(quill_dir).unwrap();
1274
1275        // Test that name comes from TOML, not directory
1276        assert_eq!(quill.name, "my-custom-quill");
1277
1278        // Test that backend is in metadata
1279        assert!(quill.metadata.contains_key("backend"));
1280        if let Some(backend_val) = quill.metadata.get("backend") {
1281            if let Some(backend_str) = backend_val.as_str() {
1282                assert_eq!(backend_str, "typst");
1283            } else {
1284                panic!("Backend value is not a string");
1285            }
1286        }
1287
1288        // Test that other fields are in metadata (but not version)
1289        assert!(quill.metadata.contains_key("description"));
1290        assert!(quill.metadata.contains_key("author"));
1291        assert!(!quill.metadata.contains_key("version")); // version should be excluded
1292
1293        // Test that glue template content is loaded correctly
1294        assert!(quill.glue.unwrap().contains("Custom Template"));
1295    }
1296
1297    #[test]
1298    fn test_typst_packages_parsing() {
1299        let temp_dir = TempDir::new().unwrap();
1300        let quill_dir = temp_dir.path();
1301
1302        let toml_content = r#"
1303[Quill]
1304name = "test-quill"
1305backend = "typst"
1306glue_file = "glue.typ"
1307description = "Test quill for packages"
1308
1309[typst]
1310packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
1311"#;
1312
1313        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1314        fs::write(quill_dir.join("glue.typ"), "test").unwrap();
1315
1316        let quill = Quill::from_path(quill_dir).unwrap();
1317        let packages = quill.typst_packages();
1318
1319        assert_eq!(packages.len(), 2);
1320        assert_eq!(packages[0], "@preview/bubble:0.2.2");
1321        assert_eq!(packages[1], "@preview/example:1.0.0");
1322    }
1323
1324    #[test]
1325    fn test_template_loading() {
1326        let temp_dir = TempDir::new().unwrap();
1327        let quill_dir = temp_dir.path();
1328
1329        // Create test files with example specified
1330        let toml_content = r#"[Quill]
1331name = "test-with-template"
1332backend = "typst"
1333glue_file = "glue.typ"
1334example_file = "example.md"
1335description = "Test quill with template"
1336"#;
1337        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1338        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1339        fs::write(
1340            quill_dir.join("example.md"),
1341            "---\ntitle: Test\n---\n\nThis is a test template.",
1342        )
1343        .unwrap();
1344
1345        // Load quill
1346        let quill = Quill::from_path(quill_dir).unwrap();
1347
1348        // Test that example content is loaded and includes some the text
1349        assert!(quill.example.is_some());
1350        let example = quill.example.unwrap();
1351        assert!(example.contains("title: Test"));
1352        assert!(example.contains("This is a test template"));
1353
1354        // Test that glue template is still loaded
1355        assert_eq!(quill.glue.unwrap(), "glue content");
1356    }
1357
1358    #[test]
1359    fn test_template_optional() {
1360        let temp_dir = TempDir::new().unwrap();
1361        let quill_dir = temp_dir.path();
1362
1363        // Create test files without example specified
1364        let toml_content = r#"[Quill]
1365name = "test-without-template"
1366backend = "typst"
1367glue_file = "glue.typ"
1368description = "Test quill without template"
1369"#;
1370        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
1371        fs::write(quill_dir.join("glue.typ"), "glue content").unwrap();
1372
1373        // Load quill
1374        let quill = Quill::from_path(quill_dir).unwrap();
1375
1376        // Test that example fields are None
1377        assert_eq!(quill.example, None);
1378
1379        // Test that glue template is still loaded
1380        assert_eq!(quill.glue.unwrap(), "glue content");
1381    }
1382
1383    #[test]
1384    fn test_from_tree() {
1385        // Create a simple in-memory file tree
1386        let mut root_files = HashMap::new();
1387
1388        // Add Quill.toml
1389        let quill_toml = r#"[Quill]
1390name = "test-from-tree"
1391backend = "typst"
1392glue_file = "glue.typ"
1393description = "A test quill from tree"
1394"#;
1395        root_files.insert(
1396            "Quill.toml".to_string(),
1397            FileTreeNode::File {
1398                contents: quill_toml.as_bytes().to_vec(),
1399            },
1400        );
1401
1402        // Add glue file
1403        let glue_content = "= Test Template\n\nThis is a test.";
1404        root_files.insert(
1405            "glue.typ".to_string(),
1406            FileTreeNode::File {
1407                contents: glue_content.as_bytes().to_vec(),
1408            },
1409        );
1410
1411        let root = FileTreeNode::Directory { files: root_files };
1412
1413        // Create Quill from tree
1414        let quill = Quill::from_tree(root, Some("test-from-tree".to_string())).unwrap();
1415
1416        // Validate the quill
1417        assert_eq!(quill.name, "test-from-tree");
1418        assert_eq!(quill.glue.unwrap(), glue_content);
1419        assert!(quill.metadata.contains_key("backend"));
1420        assert!(quill.metadata.contains_key("description"));
1421    }
1422
1423    #[test]
1424    fn test_from_tree_with_template() {
1425        let mut root_files = HashMap::new();
1426
1427        // Add Quill.toml with example specified
1428        let quill_toml = r#"[Quill]
1429name = "test-tree-template"
1430backend = "typst"
1431glue_file = "glue.typ"
1432example_file = "template.md"
1433description = "Test tree with template"
1434"#;
1435        root_files.insert(
1436            "Quill.toml".to_string(),
1437            FileTreeNode::File {
1438                contents: quill_toml.as_bytes().to_vec(),
1439            },
1440        );
1441
1442        // Add glue file
1443        root_files.insert(
1444            "glue.typ".to_string(),
1445            FileTreeNode::File {
1446                contents: b"glue content".to_vec(),
1447            },
1448        );
1449
1450        // Add template file
1451        let template_content = "# {{ title }}\n\n{{ body }}";
1452        root_files.insert(
1453            "template.md".to_string(),
1454            FileTreeNode::File {
1455                contents: template_content.as_bytes().to_vec(),
1456            },
1457        );
1458
1459        let root = FileTreeNode::Directory { files: root_files };
1460
1461        // Create Quill from tree
1462        let quill = Quill::from_tree(root, None).unwrap();
1463
1464        // Validate template is loaded
1465        assert_eq!(quill.example, Some(template_content.to_string()));
1466    }
1467
1468    #[test]
1469    fn test_from_json() {
1470        // Create JSON representation of a Quill using new format
1471        let json_str = r#"{
1472            "metadata": {
1473                "name": "test-from-json"
1474            },
1475            "files": {
1476                "Quill.toml": {
1477                    "contents": "[Quill]\nname = \"test-from-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill from JSON\"\n"
1478                },
1479                "glue.typ": {
1480                    "contents": "= Test Glue\n\nThis is test content."
1481                }
1482            }
1483        }"#;
1484
1485        // Create Quill from JSON
1486        let quill = Quill::from_json(json_str).unwrap();
1487
1488        // Validate the quill
1489        assert_eq!(quill.name, "test-from-json");
1490        assert!(quill.glue.unwrap().contains("Test Glue"));
1491        assert!(quill.metadata.contains_key("backend"));
1492    }
1493
1494    #[test]
1495    fn test_from_json_with_byte_array() {
1496        // Create JSON with byte array representation using new format
1497        let json_str = r#"{
1498            "files": {
1499                "Quill.toml": {
1500                    "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]
1501                },
1502                "glue.typ": {
1503                    "contents": "test glue"
1504                }
1505            }
1506        }"#;
1507
1508        // Create Quill from JSON
1509        let quill = Quill::from_json(json_str).unwrap();
1510
1511        // Validate the quill was created
1512        assert_eq!(quill.name, "test");
1513        assert_eq!(quill.glue.unwrap(), "test glue");
1514    }
1515
1516    #[test]
1517    fn test_from_json_missing_files() {
1518        // JSON without files field should fail
1519        let json_str = r#"{
1520            "metadata": {
1521                "name": "test"
1522            }
1523        }"#;
1524
1525        let result = Quill::from_json(json_str);
1526        assert!(result.is_err());
1527        // Should fail because there's no 'files' key
1528        assert!(result.unwrap_err().to_string().contains("files"));
1529    }
1530
1531    #[test]
1532    fn test_from_json_tree_structure() {
1533        // Test the new tree structure format
1534        let json_str = r#"{
1535            "files": {
1536                "Quill.toml": {
1537                    "contents": "[Quill]\nname = \"test-tree-json\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test tree JSON\"\n"
1538                },
1539                "glue.typ": {
1540                    "contents": "= Test Glue\n\nTree structure content."
1541                }
1542            }
1543        }"#;
1544
1545        let quill = Quill::from_json(json_str).unwrap();
1546
1547        assert_eq!(quill.name, "test-tree-json");
1548        assert!(quill.glue.unwrap().contains("Tree structure content"));
1549        assert!(quill.metadata.contains_key("backend"));
1550    }
1551
1552    #[test]
1553    fn test_from_json_nested_tree_structure() {
1554        // Test nested directories in tree structure
1555        let json_str = r#"{
1556            "files": {
1557                "Quill.toml": {
1558                    "contents": "[Quill]\nname = \"nested-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Nested test\"\n"
1559                },
1560                "glue.typ": {
1561                    "contents": "glue"
1562                },
1563                "src": {
1564                    "main.rs": {
1565                        "contents": "fn main() {}"
1566                    },
1567                    "lib.rs": {
1568                        "contents": "// lib"
1569                    }
1570                }
1571            }
1572        }"#;
1573
1574        let quill = Quill::from_json(json_str).unwrap();
1575
1576        assert_eq!(quill.name, "nested-test");
1577        // Verify nested files are accessible
1578        assert!(quill.file_exists("src/main.rs"));
1579        assert!(quill.file_exists("src/lib.rs"));
1580
1581        let main_rs = quill.get_file("src/main.rs").unwrap();
1582        assert_eq!(main_rs, b"fn main() {}");
1583    }
1584
1585    #[test]
1586    fn test_from_tree_structure_direct() {
1587        // Test using from_tree_structure directly
1588        let mut root_files = HashMap::new();
1589
1590        root_files.insert(
1591            "Quill.toml".to_string(),
1592            FileTreeNode::File {
1593                contents:
1594                    b"[Quill]\nname = \"direct-tree\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Direct tree test\"\n"
1595                        .to_vec(),
1596            },
1597        );
1598
1599        root_files.insert(
1600            "glue.typ".to_string(),
1601            FileTreeNode::File {
1602                contents: b"glue content".to_vec(),
1603            },
1604        );
1605
1606        // Add a nested directory
1607        let mut src_files = HashMap::new();
1608        src_files.insert(
1609            "main.rs".to_string(),
1610            FileTreeNode::File {
1611                contents: b"fn main() {}".to_vec(),
1612            },
1613        );
1614
1615        root_files.insert(
1616            "src".to_string(),
1617            FileTreeNode::Directory { files: src_files },
1618        );
1619
1620        let root = FileTreeNode::Directory { files: root_files };
1621
1622        let quill = Quill::from_tree(root, None).unwrap();
1623
1624        assert_eq!(quill.name, "direct-tree");
1625        assert!(quill.file_exists("src/main.rs"));
1626        assert!(quill.file_exists("glue.typ"));
1627    }
1628
1629    #[test]
1630    fn test_from_json_with_metadata_override() {
1631        // Test that metadata key overrides name from Quill.toml
1632        let json_str = r#"{
1633            "metadata": {
1634                "name": "override-name"
1635            },
1636            "files": {
1637                "Quill.toml": {
1638                    "contents": "[Quill]\nname = \"toml-name\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"TOML name test\"\n"
1639                },
1640                "glue.typ": {
1641                    "contents": "= glue"
1642                }
1643            }
1644        }"#;
1645
1646        let quill = Quill::from_json(json_str).unwrap();
1647        // Metadata name should be used as default, but Quill.toml takes precedence
1648        // when from_tree is called
1649        assert_eq!(quill.name, "toml-name");
1650    }
1651
1652    #[test]
1653    fn test_from_json_empty_directory() {
1654        // Test that empty directories are supported
1655        let json_str = r#"{
1656            "files": {
1657                "Quill.toml": {
1658                    "contents": "[Quill]\nname = \"empty-dir-test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Empty directory test\"\n"
1659                },
1660                "glue.typ": {
1661                    "contents": "glue"
1662                },
1663                "empty_dir": {}
1664            }
1665        }"#;
1666
1667        let quill = Quill::from_json(json_str).unwrap();
1668        assert_eq!(quill.name, "empty-dir-test");
1669        assert!(quill.dir_exists("empty_dir"));
1670        assert!(!quill.file_exists("empty_dir"));
1671    }
1672
1673    #[test]
1674    fn test_dir_exists_and_list_apis() {
1675        let mut root_files = HashMap::new();
1676
1677        // Add Quill.toml
1678        root_files.insert(
1679            "Quill.toml".to_string(),
1680            FileTreeNode::File {
1681                contents: b"[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue_file = \"glue.typ\"\ndescription = \"Test quill\"\n"
1682                    .to_vec(),
1683            },
1684        );
1685
1686        // Add glue file
1687        root_files.insert(
1688            "glue.typ".to_string(),
1689            FileTreeNode::File {
1690                contents: b"glue content".to_vec(),
1691            },
1692        );
1693
1694        // Add assets directory with files
1695        let mut assets_files = HashMap::new();
1696        assets_files.insert(
1697            "logo.png".to_string(),
1698            FileTreeNode::File {
1699                contents: vec![137, 80, 78, 71],
1700            },
1701        );
1702        assets_files.insert(
1703            "icon.svg".to_string(),
1704            FileTreeNode::File {
1705                contents: b"<svg></svg>".to_vec(),
1706            },
1707        );
1708
1709        // Add subdirectory in assets
1710        let mut fonts_files = HashMap::new();
1711        fonts_files.insert(
1712            "font.ttf".to_string(),
1713            FileTreeNode::File {
1714                contents: b"font data".to_vec(),
1715            },
1716        );
1717        assets_files.insert(
1718            "fonts".to_string(),
1719            FileTreeNode::Directory { files: fonts_files },
1720        );
1721
1722        root_files.insert(
1723            "assets".to_string(),
1724            FileTreeNode::Directory {
1725                files: assets_files,
1726            },
1727        );
1728
1729        // Add empty directory
1730        root_files.insert(
1731            "empty".to_string(),
1732            FileTreeNode::Directory {
1733                files: HashMap::new(),
1734            },
1735        );
1736
1737        let root = FileTreeNode::Directory { files: root_files };
1738        let quill = Quill::from_tree(root, None).unwrap();
1739
1740        // Test dir_exists
1741        assert!(quill.dir_exists("assets"));
1742        assert!(quill.dir_exists("assets/fonts"));
1743        assert!(quill.dir_exists("empty"));
1744        assert!(!quill.dir_exists("nonexistent"));
1745        assert!(!quill.dir_exists("glue.typ")); // file, not directory
1746
1747        // Test file_exists
1748        assert!(quill.file_exists("glue.typ"));
1749        assert!(quill.file_exists("assets/logo.png"));
1750        assert!(quill.file_exists("assets/fonts/font.ttf"));
1751        assert!(!quill.file_exists("assets")); // directory, not file
1752
1753        // Test list_files
1754        let root_files_list = quill.list_files("");
1755        assert_eq!(root_files_list.len(), 2); // Quill.toml and glue.typ
1756        assert!(root_files_list.contains(&"Quill.toml".to_string()));
1757        assert!(root_files_list.contains(&"glue.typ".to_string()));
1758
1759        let assets_files_list = quill.list_files("assets");
1760        assert_eq!(assets_files_list.len(), 2); // logo.png and icon.svg
1761        assert!(assets_files_list.contains(&"logo.png".to_string()));
1762        assert!(assets_files_list.contains(&"icon.svg".to_string()));
1763
1764        // Test list_subdirectories
1765        let root_subdirs = quill.list_subdirectories("");
1766        assert_eq!(root_subdirs.len(), 2); // assets and empty
1767        assert!(root_subdirs.contains(&"assets".to_string()));
1768        assert!(root_subdirs.contains(&"empty".to_string()));
1769
1770        let assets_subdirs = quill.list_subdirectories("assets");
1771        assert_eq!(assets_subdirs.len(), 1); // fonts
1772        assert!(assets_subdirs.contains(&"fonts".to_string()));
1773
1774        let empty_subdirs = quill.list_subdirectories("empty");
1775        assert_eq!(empty_subdirs.len(), 0);
1776    }
1777
1778    #[test]
1779    fn test_field_schemas_parsing() {
1780        let mut root_files = HashMap::new();
1781
1782        // Add Quill.toml with field schemas
1783        let quill_toml = r#"[Quill]
1784name = "taro"
1785backend = "typst"
1786glue_file = "glue.typ"
1787example_file = "taro.md"
1788description = "Test template for field schemas"
1789
1790[fields]
1791author = {description = "Author of document" }
1792ice_cream = {description = "favorite ice cream flavor"}
1793title = {description = "title of document" }
1794"#;
1795        root_files.insert(
1796            "Quill.toml".to_string(),
1797            FileTreeNode::File {
1798                contents: quill_toml.as_bytes().to_vec(),
1799            },
1800        );
1801
1802        // Add glue file
1803        let glue_content = "= Test Template\n\nThis is a test.";
1804        root_files.insert(
1805            "glue.typ".to_string(),
1806            FileTreeNode::File {
1807                contents: glue_content.as_bytes().to_vec(),
1808            },
1809        );
1810
1811        // Add template file
1812        root_files.insert(
1813            "taro.md".to_string(),
1814            FileTreeNode::File {
1815                contents: b"# Template".to_vec(),
1816            },
1817        );
1818
1819        let root = FileTreeNode::Directory { files: root_files };
1820
1821        // Create Quill from tree
1822        let quill = Quill::from_tree(root, Some("taro".to_string())).unwrap();
1823
1824        // Validate field schemas were parsed
1825        assert_eq!(quill.schema["properties"].as_object().unwrap().len(), 3);
1826        assert!(quill.schema["properties"]
1827            .as_object()
1828            .unwrap()
1829            .contains_key("author"));
1830        assert!(quill.schema["properties"]
1831            .as_object()
1832            .unwrap()
1833            .contains_key("ice_cream"));
1834        assert!(quill.schema["properties"]
1835            .as_object()
1836            .unwrap()
1837            .contains_key("title"));
1838
1839        // Verify author field schema
1840        let author_schema = quill.schema["properties"]["author"].as_object().unwrap();
1841        assert_eq!(author_schema["description"], "Author of document");
1842
1843        // Verify ice_cream field schema (no required field, should default to false)
1844        let ice_cream_schema = quill.schema["properties"]["ice_cream"].as_object().unwrap();
1845        assert_eq!(ice_cream_schema["description"], "favorite ice cream flavor");
1846
1847        // Verify title field schema
1848        let title_schema = quill.schema["properties"]["title"].as_object().unwrap();
1849        assert_eq!(title_schema["description"], "title of document");
1850    }
1851
1852    #[test]
1853    fn test_field_schema_struct() {
1854        // Test creating FieldSchema with minimal fields
1855        let schema1 = FieldSchema::new("test_name".to_string(), "Test description".to_string());
1856        assert_eq!(schema1.description, "Test description");
1857        assert_eq!(schema1.r#type, None);
1858        assert_eq!(schema1.example, None);
1859        assert_eq!(schema1.default, None);
1860
1861        // Test parsing FieldSchema from YAML with all fields
1862        let yaml_str = r#"
1863description: "Full field schema"
1864type: "string"
1865example: "Example value"
1866default: "Default value"
1867"#;
1868        let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1869        let quill_value = QuillValue::from_yaml(yaml_value).unwrap();
1870        let schema2 = FieldSchema::from_quill_value("test_name".to_string(), &quill_value).unwrap();
1871        assert_eq!(schema2.name, "test_name");
1872        assert_eq!(schema2.description, "Full field schema");
1873        assert_eq!(schema2.r#type, Some("string".to_string()));
1874        assert_eq!(
1875            schema2.example.as_ref().and_then(|v| v.as_str()),
1876            Some("Example value")
1877        );
1878        assert_eq!(
1879            schema2.default.as_ref().and_then(|v| v.as_str()),
1880            Some("Default value")
1881        );
1882    }
1883
1884    #[test]
1885    fn test_quill_without_glue_file() {
1886        // Test creating a Quill without specifying a glue file
1887        let mut root_files = HashMap::new();
1888
1889        // Add Quill.toml without glue field
1890        let quill_toml = r#"[Quill]
1891name = "test-no-glue"
1892backend = "typst"
1893description = "Test quill without glue file"
1894"#;
1895        root_files.insert(
1896            "Quill.toml".to_string(),
1897            FileTreeNode::File {
1898                contents: quill_toml.as_bytes().to_vec(),
1899            },
1900        );
1901
1902        let root = FileTreeNode::Directory { files: root_files };
1903
1904        // Create Quill from tree
1905        let quill = Quill::from_tree(root, None).unwrap();
1906
1907        // Validate that glue is null (will use auto glue)
1908        assert!(quill.glue.clone().is_none());
1909        assert_eq!(quill.name, "test-no-glue");
1910    }
1911
1912    #[test]
1913    fn test_quill_config_from_toml() {
1914        // Test parsing QuillConfig from TOML content
1915        let toml_content = r#"[Quill]
1916name = "test-config"
1917backend = "typst"
1918description = "Test configuration parsing"
1919version = "1.0.0"
1920author = "Test Author"
1921glue_file = "glue.typ"
1922example_file = "example.md"
1923
1924[typst]
1925packages = ["@preview/bubble:0.2.2"]
1926
1927[fields]
1928title = {description = "Document title", type = "string"}
1929author = {description = "Document author"}
1930"#;
1931
1932        let config = QuillConfig::from_toml(toml_content).unwrap();
1933
1934        // Verify required fields
1935        assert_eq!(config.name, "test-config");
1936        assert_eq!(config.backend, "typst");
1937        assert_eq!(config.description, "Test configuration parsing");
1938
1939        // Verify optional fields
1940        assert_eq!(config.version, Some("1.0.0".to_string()));
1941        assert_eq!(config.author, Some("Test Author".to_string()));
1942        assert_eq!(config.glue_file, Some("glue.typ".to_string()));
1943        assert_eq!(config.example_file, Some("example.md".to_string()));
1944
1945        // Verify typst config
1946        assert!(config.typst_config.contains_key("packages"));
1947
1948        // Verify field schemas
1949        assert_eq!(config.fields.len(), 2);
1950        assert!(config.fields.contains_key("title"));
1951        assert!(config.fields.contains_key("author"));
1952
1953        let title_field = &config.fields["title"];
1954        assert_eq!(title_field.description, "Document title");
1955        assert_eq!(title_field.r#type, Some("string".to_string()));
1956    }
1957
1958    #[test]
1959    fn test_quill_config_missing_required_fields() {
1960        // Test that missing required fields result in error
1961        let toml_missing_name = r#"[Quill]
1962backend = "typst"
1963description = "Missing name"
1964"#;
1965        let result = QuillConfig::from_toml(toml_missing_name);
1966        assert!(result.is_err());
1967        assert!(result
1968            .unwrap_err()
1969            .to_string()
1970            .contains("Missing required 'name'"));
1971
1972        let toml_missing_backend = r#"[Quill]
1973name = "test"
1974description = "Missing backend"
1975"#;
1976        let result = QuillConfig::from_toml(toml_missing_backend);
1977        assert!(result.is_err());
1978        assert!(result
1979            .unwrap_err()
1980            .to_string()
1981            .contains("Missing required 'backend'"));
1982
1983        let toml_missing_description = r#"[Quill]
1984name = "test"
1985backend = "typst"
1986"#;
1987        let result = QuillConfig::from_toml(toml_missing_description);
1988        assert!(result.is_err());
1989        assert!(result
1990            .unwrap_err()
1991            .to_string()
1992            .contains("Missing required 'description'"));
1993    }
1994
1995    #[test]
1996    fn test_quill_config_empty_description() {
1997        // Test that empty description results in error
1998        let toml_empty_description = r#"[Quill]
1999name = "test"
2000backend = "typst"
2001description = "   "
2002"#;
2003        let result = QuillConfig::from_toml(toml_empty_description);
2004        assert!(result.is_err());
2005        assert!(result
2006            .unwrap_err()
2007            .to_string()
2008            .contains("description' field in [Quill] section cannot be empty"));
2009    }
2010
2011    #[test]
2012    fn test_quill_config_missing_quill_section() {
2013        // Test that missing [Quill] section results in error
2014        let toml_no_section = r#"[fields]
2015title = {description = "Title"}
2016"#;
2017        let result = QuillConfig::from_toml(toml_no_section);
2018        assert!(result.is_err());
2019        assert!(result
2020            .unwrap_err()
2021            .to_string()
2022            .contains("Missing required [Quill] section"));
2023    }
2024
2025    #[test]
2026    fn test_quill_from_config_metadata() {
2027        // Test that QuillConfig metadata flows through to Quill
2028        let mut root_files = HashMap::new();
2029
2030        let quill_toml = r#"[Quill]
2031name = "metadata-test"
2032backend = "typst"
2033description = "Test metadata flow"
2034author = "Test Author"
2035custom_field = "custom_value"
2036
2037[typst]
2038packages = ["@preview/bubble:0.2.2"]
2039"#;
2040        root_files.insert(
2041            "Quill.toml".to_string(),
2042            FileTreeNode::File {
2043                contents: quill_toml.as_bytes().to_vec(),
2044            },
2045        );
2046
2047        let root = FileTreeNode::Directory { files: root_files };
2048        let quill = Quill::from_tree(root, None).unwrap();
2049
2050        // Verify metadata includes backend and description
2051        assert!(quill.metadata.contains_key("backend"));
2052        assert!(quill.metadata.contains_key("description"));
2053        assert!(quill.metadata.contains_key("author"));
2054
2055        // Verify custom field is in metadata
2056        assert!(quill.metadata.contains_key("custom_field"));
2057        assert_eq!(
2058            quill.metadata.get("custom_field").unwrap().as_str(),
2059            Some("custom_value")
2060        );
2061
2062        // Verify typst config with typst_ prefix
2063        assert!(quill.metadata.contains_key("typst_packages"));
2064    }
2065
2066    #[test]
2067    fn test_json_schema_file_override() {
2068        // Test that json_schema_file overrides [fields]
2069        let mut root_files = HashMap::new();
2070
2071        // Create a custom JSON schema with defaults
2072        let custom_schema = r#"{
2073            "$schema": "https://json-schema.org/draft/2019-09/schema",
2074            "type": "object",
2075            "properties": {
2076                "title": {
2077                    "type": "string",
2078                    "description": "Document title"
2079                },
2080                "author": {
2081                    "type": "string",
2082                    "description": "Document author",
2083                    "default": "Schema Author"
2084                },
2085                "version": {
2086                    "type": "number",
2087                    "description": "Version number",
2088                    "default": 2
2089                }
2090            },
2091            "required": ["title"]
2092        }"#;
2093
2094        root_files.insert(
2095            "schema.json".to_string(),
2096            FileTreeNode::File {
2097                contents: custom_schema.as_bytes().to_vec(),
2098            },
2099        );
2100
2101        let quill_toml = r#"[Quill]
2102name = "schema-file-test"
2103backend = "typst"
2104description = "Test json_schema_file"
2105json_schema_file = "schema.json"
2106
2107[fields]
2108author = {description = "This should be ignored", default = "Fields Author"}
2109status = {description = "This should also be ignored"}
2110"#;
2111
2112        root_files.insert(
2113            "Quill.toml".to_string(),
2114            FileTreeNode::File {
2115                contents: quill_toml.as_bytes().to_vec(),
2116            },
2117        );
2118
2119        let root = FileTreeNode::Directory { files: root_files };
2120        let quill = Quill::from_tree(root, None).unwrap();
2121
2122        // Verify that schema came from json_schema_file, not [fields]
2123        let properties = quill.schema["properties"].as_object().unwrap();
2124        assert_eq!(properties.len(), 3); // title, author, version from schema.json
2125        assert!(properties.contains_key("title"));
2126        assert!(properties.contains_key("author"));
2127        assert!(properties.contains_key("version"));
2128        assert!(!properties.contains_key("status")); // from [fields], should be ignored
2129
2130        // Verify defaults from schema file
2131        let defaults = quill.extract_defaults();
2132        assert_eq!(defaults.len(), 2); // author and version have defaults
2133        assert_eq!(
2134            defaults.get("author").unwrap().as_str(),
2135            Some("Schema Author")
2136        );
2137        assert_eq!(defaults.get("version").unwrap().as_json().as_i64(), Some(2));
2138
2139        // Verify required fields from schema
2140        let required = quill.schema["required"].as_array().unwrap();
2141        assert_eq!(required.len(), 1);
2142        assert!(required.contains(&serde_json::json!("title")));
2143    }
2144
2145    #[test]
2146    fn test_extract_defaults_method() {
2147        // Test the extract_defaults method on Quill
2148        let mut root_files = HashMap::new();
2149
2150        let quill_toml = r#"[Quill]
2151name = "defaults-test"
2152backend = "typst"
2153description = "Test defaults extraction"
2154
2155[fields]
2156title = {description = "Title"}
2157author = {description = "Author", default = "Anonymous"}
2158status = {description = "Status", default = "draft"}
2159"#;
2160
2161        root_files.insert(
2162            "Quill.toml".to_string(),
2163            FileTreeNode::File {
2164                contents: quill_toml.as_bytes().to_vec(),
2165            },
2166        );
2167
2168        let root = FileTreeNode::Directory { files: root_files };
2169        let quill = Quill::from_tree(root, None).unwrap();
2170
2171        // Extract defaults
2172        let defaults = quill.extract_defaults();
2173
2174        // Verify only fields with defaults are returned
2175        assert_eq!(defaults.len(), 2);
2176        assert!(!defaults.contains_key("title")); // no default
2177        assert!(defaults.contains_key("author"));
2178        assert!(defaults.contains_key("status"));
2179
2180        // Verify default values
2181        assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
2182        assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
2183    }
2184
2185    #[test]
2186    fn test_field_order_preservation() {
2187        let toml_content = r#"[Quill]
2188name = "order-test"
2189backend = "typst"
2190description = "Test field order"
2191
2192[fields]
2193first = {description = "First field"}
2194second = {description = "Second field"}
2195third = {description = "Third field", ui = {order = 10}}
2196fourth = {description = "Fourth field"}
2197"#;
2198
2199        let config = QuillConfig::from_toml(toml_content).unwrap();
2200
2201        // Check that fields have correct order based on TOML position
2202        // Explicit ui.order in TOML should be ignored/overwritten
2203
2204        let first = config.fields.get("first").unwrap();
2205        assert_eq!(first.ui.as_ref().unwrap().order, Some(0));
2206
2207        let second = config.fields.get("second").unwrap();
2208        assert_eq!(second.ui.as_ref().unwrap().order, Some(1));
2209
2210        let third = config.fields.get("third").unwrap();
2211        // Should be 2 because it's the 3rd field, ignoring the explicit 10
2212        assert_eq!(third.ui.as_ref().unwrap().order, Some(2));
2213
2214        let fourth = config.fields.get("fourth").unwrap();
2215        assert_eq!(fourth.ui.as_ref().unwrap().order, Some(3));
2216    }
2217}