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