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