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