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