holoconf_core/
schema.rs

1//! Schema validation for configuration (ADR-007, FEAT-004)
2//!
3//! Provides JSON Schema based validation with two-phase validation:
4//! - Phase 1 (structural): Validates structure after merge, interpolations allowed
5//! - Phase 2 (type/value): Validates resolved values against constraints
6
7use std::path::Path;
8use std::sync::Arc;
9
10use crate::error::{Error, Result};
11use crate::value::Value;
12
13/// Schema for validating configuration
14#[derive(Debug, Clone)]
15pub struct Schema {
16    /// The JSON Schema as a serde_json::Value
17    schema: serde_json::Value,
18    /// Compiled JSON Schema validator (wrapped in Arc for Clone)
19    compiled: Arc<jsonschema::Validator>,
20}
21
22impl Schema {
23    /// Load a schema from a JSON string
24    pub fn from_json(json: &str) -> Result<Self> {
25        let schema: serde_json::Value = serde_json::from_str(json)
26            .map_err(|e| Error::parse(format!("Invalid JSON schema: {}", e)))?;
27        Self::from_value(schema)
28    }
29
30    /// Load a schema from a YAML string
31    pub fn from_yaml(yaml: &str) -> Result<Self> {
32        let schema: serde_json::Value = serde_yaml::from_str(yaml)
33            .map_err(|e| Error::parse(format!("Invalid YAML schema: {}", e)))?;
34        Self::from_value(schema)
35    }
36
37    /// Load a schema from a file (JSON or YAML based on extension)
38    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
39        let path = path.as_ref();
40        let content = std::fs::read_to_string(path)
41            .map_err(|_| Error::file_not_found(path.display().to_string(), None))?;
42
43        match path.extension().and_then(|e| e.to_str()) {
44            Some("json") => Self::from_json(&content),
45            Some("yaml") | Some("yml") => Self::from_yaml(&content),
46            _ => Self::from_yaml(&content), // Default to YAML
47        }
48    }
49
50    /// Create a schema from a serde_json::Value
51    fn from_value(schema: serde_json::Value) -> Result<Self> {
52        let compiled = jsonschema::validator_for(&schema)
53            .map_err(|e| Error::parse(format!("Invalid JSON Schema: {}", e)))?;
54        Ok(Self {
55            schema,
56            compiled: Arc::new(compiled),
57        })
58    }
59
60    /// Validate a Value against this schema
61    ///
62    /// Returns Ok(()) if valid, or an error with details about the first validation failure.
63    pub fn validate(&self, value: &Value) -> Result<()> {
64        // Convert Value to serde_json::Value for validation
65        let json_value = value_to_json(value);
66
67        // Use iter_errors to get an iterator of validation errors
68        let mut errors = self.compiled.iter_errors(&json_value);
69        if let Some(error) = errors.next() {
70            let path = error.instance_path.to_string();
71            let message = error.to_string();
72            return Err(Error::validation(
73                if path.is_empty() { "<root>" } else { &path },
74                &message,
75            ));
76        }
77        Ok(())
78    }
79
80    /// Validate and collect all errors (instead of failing on first)
81    pub fn validate_collect(&self, value: &Value) -> Vec<ValidationError> {
82        let json_value = value_to_json(value);
83
84        self.compiled
85            .iter_errors(&json_value)
86            .map(|e| ValidationError {
87                path: e.instance_path.to_string(),
88                message: e.to_string(),
89            })
90            .collect()
91    }
92
93    /// Get the raw schema value
94    pub fn as_value(&self) -> &serde_json::Value {
95        &self.schema
96    }
97
98    /// Output schema as YAML
99    pub fn to_yaml(&self) -> Result<String> {
100        serde_yaml::to_string(&self.schema)
101            .map_err(|e| Error::internal(format!("Failed to serialize schema: {}", e)))
102    }
103
104    /// Output schema as JSON
105    pub fn to_json(&self) -> Result<String> {
106        serde_json::to_string_pretty(&self.schema)
107            .map_err(|e| Error::internal(format!("Failed to serialize schema: {}", e)))
108    }
109
110    /// Generate markdown documentation from the schema
111    pub fn to_markdown(&self) -> String {
112        generate_markdown_doc(&self.schema)
113    }
114
115    /// Generate a YAML template from the schema
116    ///
117    /// Creates a configuration template with default values and comments
118    /// indicating required fields and descriptions.
119    pub fn to_template(&self) -> String {
120        generate_template(&self.schema)
121    }
122
123    /// Get the default value for a config path from the schema
124    ///
125    /// Navigates the schema's `properties` structure to find the default
126    /// value for the given dot-separated path.
127    ///
128    /// # Example
129    /// ```
130    /// # use holoconf_core::Schema;
131    /// let schema = Schema::from_yaml(r#"
132    /// type: object
133    /// properties:
134    ///   database:
135    ///     type: object
136    ///     properties:
137    ///       port:
138    ///         type: integer
139    ///         default: 5432
140    /// "#).unwrap();
141    ///
142    /// assert_eq!(schema.get_default("database.port"), Some(holoconf_core::Value::Integer(5432)));
143    /// assert_eq!(schema.get_default("missing"), None);
144    /// ```
145    pub fn get_default(&self, path: &str) -> Option<Value> {
146        if path.is_empty() {
147            return self.schema.get("default").map(json_to_value);
148        }
149
150        let segments: Vec<&str> = path.split('.').collect();
151        self.get_default_at_path(&self.schema, &segments)
152    }
153
154    /// Internal helper to navigate schema and find default at path
155    fn get_default_at_path(&self, schema: &serde_json::Value, segments: &[&str]) -> Option<Value> {
156        if segments.is_empty() {
157            // At target - check for default
158            return schema.get("default").map(json_to_value);
159        }
160
161        let segment = segments[0];
162        let remaining = &segments[1..];
163
164        // Navigate into properties
165        if let Some(properties) = schema.get("properties") {
166            if let Some(prop_schema) = properties.get(segment) {
167                return self.get_default_at_path(prop_schema, remaining);
168            }
169        }
170
171        None
172    }
173
174    /// Check if null is allowed for a config path in the schema
175    ///
176    /// Returns true if:
177    /// - The schema allows `type: "null"` or `type: ["string", "null"]`
178    /// - The path doesn't exist in the schema (permissive by default)
179    ///
180    /// # Example
181    /// ```
182    /// # use holoconf_core::Schema;
183    /// let schema = Schema::from_yaml(r#"
184    /// type: object
185    /// properties:
186    ///   nullable_field:
187    ///     type: ["string", "null"]
188    ///   non_nullable:
189    ///     type: string
190    /// "#).unwrap();
191    ///
192    /// assert!(schema.allows_null("nullable_field"));
193    /// assert!(!schema.allows_null("non_nullable"));
194    /// assert!(schema.allows_null("missing")); // permissive for undefined paths
195    /// ```
196    pub fn allows_null(&self, path: &str) -> bool {
197        if path.is_empty() {
198            return self.type_allows_null(&self.schema);
199        }
200
201        let segments: Vec<&str> = path.split('.').collect();
202        self.allows_null_at_path(&self.schema, &segments)
203    }
204
205    /// Internal helper to check if null is allowed at a path
206    fn allows_null_at_path(&self, schema: &serde_json::Value, segments: &[&str]) -> bool {
207        if segments.is_empty() {
208            return self.type_allows_null(schema);
209        }
210
211        let segment = segments[0];
212        let remaining = &segments[1..];
213
214        // Navigate into properties
215        if let Some(properties) = schema.get("properties") {
216            if let Some(prop_schema) = properties.get(segment) {
217                return self.allows_null_at_path(prop_schema, remaining);
218            }
219        }
220
221        // Path not found in schema - be permissive
222        true
223    }
224
225    /// Check if a schema type allows null
226    fn type_allows_null(&self, schema: &serde_json::Value) -> bool {
227        match schema.get("type") {
228            Some(serde_json::Value::String(t)) => t == "null",
229            Some(serde_json::Value::Array(types)) => {
230                types.iter().any(|t| t.as_str() == Some("null"))
231            }
232            None => true, // No type constraint means anything is allowed
233            _ => false,   // Invalid type value (number, bool, object, null) - treat as not nullable
234        }
235    }
236}
237
238/// A single validation error
239#[derive(Debug, Clone)]
240pub struct ValidationError {
241    /// Path to the invalid value (e.g., "/database/port")
242    pub path: String,
243    /// Error message
244    pub message: String,
245}
246
247impl std::fmt::Display for ValidationError {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        if self.path.is_empty() {
250            write!(f, "{}", self.message)
251        } else {
252            write!(f, "{}: {}", self.path, self.message)
253        }
254    }
255}
256
257/// Generate markdown documentation from a JSON Schema
258fn generate_markdown_doc(schema: &serde_json::Value) -> String {
259    let mut output = String::new();
260
261    // Get title or use default
262    let title = schema
263        .get("title")
264        .and_then(|v| v.as_str())
265        .unwrap_or("Configuration Reference");
266    output.push_str(&format!("# {}\n\n", title));
267
268    // Get top-level description
269    if let Some(desc) = schema.get("description").and_then(|v| v.as_str()) {
270        output.push_str(&format!("{}\n\n", desc));
271    }
272
273    // Get required fields at root level
274    let root_required: Vec<&str> = schema
275        .get("required")
276        .and_then(|v| v.as_array())
277        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
278        .unwrap_or_default();
279
280    // Process top-level properties
281    if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
282        for (name, prop_schema) in properties {
283            generate_section(
284                &mut output,
285                name,
286                prop_schema,
287                root_required.contains(&name.as_str()),
288                2,
289            );
290        }
291    }
292
293    output
294}
295
296/// Generate a section for a property (potentially recursive for nested objects)
297fn generate_section(
298    output: &mut String,
299    name: &str,
300    schema: &serde_json::Value,
301    is_required: bool,
302    heading_level: usize,
303) {
304    let heading = "#".repeat(heading_level);
305    let required_marker = if is_required { " (required)" } else { "" };
306
307    // Section heading
308    output.push_str(&format!("{} {}{}\n\n", heading, name, required_marker));
309
310    // Description
311    if let Some(desc) = schema.get("description").and_then(|v| v.as_str()) {
312        output.push_str(&format!("{}\n\n", desc));
313    }
314
315    // Check if this is an object with nested properties
316    let is_object = schema
317        .get("type")
318        .and_then(|v| v.as_str())
319        .map(|t| t == "object")
320        .unwrap_or(false);
321
322    if is_object {
323        if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
324            // Get required fields for this level
325            let required: Vec<&str> = schema
326                .get("required")
327                .and_then(|v| v.as_array())
328                .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
329                .unwrap_or_default();
330
331            // Generate table for immediate properties
332            output.push_str("| Key | Type | Required | Default | Description |\n");
333            output.push_str("|-----|------|----------|---------|-------------|\n");
334
335            for (prop_name, prop_schema) in properties {
336                let prop_type = get_type_string(prop_schema);
337                let prop_required = if required.contains(&prop_name.as_str()) {
338                    "Yes"
339                } else {
340                    "No"
341                };
342                let prop_default = schema_default_string(prop_schema);
343                let prop_desc = prop_schema
344                    .get("description")
345                    .and_then(|v| v.as_str())
346                    .unwrap_or("-");
347
348                output.push_str(&format!(
349                    "| {} | {} | {} | {} | {} |\n",
350                    prop_name, prop_type, prop_required, prop_default, prop_desc
351                ));
352            }
353
354            output.push('\n');
355
356            // Recursively generate sections for nested objects
357            for (prop_name, prop_schema) in properties {
358                let prop_is_object = prop_schema
359                    .get("type")
360                    .and_then(|v| v.as_str())
361                    .map(|t| t == "object")
362                    .unwrap_or(false);
363
364                if prop_is_object && prop_schema.get("properties").is_some() {
365                    let nested_required = required.contains(&prop_name.as_str());
366                    generate_section(
367                        output,
368                        prop_name,
369                        prop_schema,
370                        nested_required,
371                        heading_level + 1,
372                    );
373                }
374            }
375        }
376    } else {
377        // For non-object types at top level, just show a simple table
378        output.push_str("| Key | Type | Required | Default | Description |\n");
379        output.push_str("|-----|------|----------|---------|-------------|\n");
380
381        let prop_type = get_type_string(schema);
382        let prop_required = if is_required { "Yes" } else { "No" };
383        let prop_default = schema_default_string(schema);
384        let prop_desc = schema
385            .get("description")
386            .and_then(|v| v.as_str())
387            .unwrap_or("-");
388
389        output.push_str(&format!(
390            "| {} | {} | {} | {} | {} |\n\n",
391            name, prop_type, prop_required, prop_default, prop_desc
392        ));
393    }
394}
395
396/// Get a human-readable type string from a schema
397fn get_type_string(schema: &serde_json::Value) -> String {
398    // Handle enum
399    if let Some(enum_vals) = schema.get("enum").and_then(|v| v.as_array()) {
400        let vals: Vec<String> = enum_vals
401            .iter()
402            .map(|v| {
403                if v.is_string() {
404                    format!("\"{}\"", v.as_str().unwrap())
405                } else {
406                    v.to_string()
407                }
408            })
409            .collect();
410        return format!("enum: {}", vals.join(", "));
411    }
412
413    // Handle type
414    let base_type = schema.get("type").and_then(|v| v.as_str()).unwrap_or("any");
415
416    // Add constraints info
417    let mut constraints = Vec::new();
418
419    if let Some(min) = schema.get("minimum") {
420        constraints.push(format!("min: {}", min));
421    }
422    if let Some(max) = schema.get("maximum") {
423        constraints.push(format!("max: {}", max));
424    }
425    if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
426        constraints.push(format!("pattern: {}", pattern));
427    }
428    if let Some(min_len) = schema.get("minLength") {
429        constraints.push(format!("minLength: {}", min_len));
430    }
431    if let Some(max_len) = schema.get("maxLength") {
432        constraints.push(format!("maxLength: {}", max_len));
433    }
434
435    if constraints.is_empty() {
436        base_type.to_string()
437    } else {
438        format!("{} ({})", base_type, constraints.join(", "))
439    }
440}
441
442/// Get the default value as a string, or "-" if none
443fn schema_default_string(schema: &serde_json::Value) -> String {
444    schema
445        .get("default")
446        .map(|v| {
447            if v.is_string() {
448                format!("\"{}\"", v.as_str().unwrap())
449            } else {
450                v.to_string()
451            }
452        })
453        .unwrap_or_else(|| "-".to_string())
454}
455
456/// Convert a serde_json::Value to holoconf Value
457fn json_to_value(json: &serde_json::Value) -> Value {
458    match json {
459        serde_json::Value::Null => Value::Null,
460        serde_json::Value::Bool(b) => Value::Bool(*b),
461        serde_json::Value::Number(n) => {
462            if let Some(i) = n.as_i64() {
463                Value::Integer(i)
464            } else if let Some(f) = n.as_f64() {
465                Value::Float(f)
466            } else {
467                // Fallback for very large numbers
468                Value::Float(n.as_f64().unwrap_or(0.0))
469            }
470        }
471        serde_json::Value::String(s) => Value::String(s.clone()),
472        serde_json::Value::Array(arr) => Value::Sequence(arr.iter().map(json_to_value).collect()),
473        serde_json::Value::Object(obj) => {
474            let map: indexmap::IndexMap<String, Value> = obj
475                .iter()
476                .map(|(k, v)| (k.clone(), json_to_value(v)))
477                .collect();
478            Value::Mapping(map)
479        }
480    }
481}
482
483/// Convert a holoconf Value to serde_json::Value
484fn value_to_json(value: &Value) -> serde_json::Value {
485    match value {
486        Value::Null => serde_json::Value::Null,
487        Value::Bool(b) => serde_json::Value::Bool(*b),
488        Value::Integer(i) => serde_json::Value::Number((*i).into()),
489        Value::Float(f) => serde_json::Number::from_f64(*f)
490            .map(serde_json::Value::Number)
491            .unwrap_or(serde_json::Value::Null),
492        Value::String(s) => serde_json::Value::String(s.clone()),
493        Value::Bytes(bytes) => {
494            // Serialize bytes as base64 string
495            use base64::{engine::general_purpose::STANDARD, Engine as _};
496            serde_json::Value::String(STANDARD.encode(bytes))
497        }
498        Value::Sequence(seq) => serde_json::Value::Array(seq.iter().map(value_to_json).collect()),
499        Value::Mapping(map) => {
500            let obj: serde_json::Map<String, serde_json::Value> = map
501                .iter()
502                .map(|(k, v)| (k.clone(), value_to_json(v)))
503                .collect();
504            serde_json::Value::Object(obj)
505        }
506    }
507}
508
509/// Generate a YAML template from a JSON Schema
510fn generate_template(schema: &serde_json::Value) -> String {
511    let mut output = String::new();
512
513    // Get title for header comment
514    if let Some(title) = schema.get("title").and_then(|v| v.as_str()) {
515        output.push_str(&format!("# Generated from: {}\n", title));
516    } else {
517        output.push_str("# Configuration template generated from schema\n");
518    }
519    output.push_str("# Required fields marked with # REQUIRED\n\n");
520
521    // Get required fields at root level
522    let root_required: Vec<&str> = schema
523        .get("required")
524        .and_then(|v| v.as_array())
525        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
526        .unwrap_or_default();
527
528    // Process top-level properties
529    if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
530        for (name, prop_schema) in properties {
531            let is_required = root_required.contains(&name.as_str());
532            generate_template_property(&mut output, name, prop_schema, is_required, 0);
533        }
534    }
535
536    output
537}
538
539/// Generate template output for a single property
540fn generate_template_property(
541    output: &mut String,
542    name: &str,
543    schema: &serde_json::Value,
544    is_required: bool,
545    indent_level: usize,
546) {
547    let indent = "  ".repeat(indent_level);
548
549    // Build the comment parts
550    let mut comment_parts = Vec::new();
551    if is_required {
552        comment_parts.push("REQUIRED".to_string());
553    }
554    if let Some(desc) = schema.get("description").and_then(|v| v.as_str()) {
555        comment_parts.push(desc.to_string());
556    }
557
558    // Get the type
559    let prop_type = schema.get("type").and_then(|v| v.as_str()).unwrap_or("any");
560
561    // Handle object type
562    if prop_type == "object" {
563        // Write comment if any
564        if !comment_parts.is_empty() {
565            output.push_str(&format!("{}# {}\n", indent, comment_parts.join(" - ")));
566        }
567        output.push_str(&format!("{}{}:\n", indent, name));
568
569        // Get required fields for this object
570        let required: Vec<&str> = schema
571            .get("required")
572            .and_then(|v| v.as_array())
573            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
574            .unwrap_or_default();
575
576        // Process nested properties
577        if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
578            for (prop_name, prop_schema) in properties {
579                let prop_required = required.contains(&prop_name.as_str());
580                generate_template_property(
581                    output,
582                    prop_name,
583                    prop_schema,
584                    prop_required,
585                    indent_level + 1,
586                );
587            }
588        }
589    } else {
590        // For scalar types, get the default value or a placeholder
591        let value_str = get_template_value(schema, prop_type);
592
593        // Build the line
594        let mut line = format!("{}{}: {}", indent, name, value_str);
595
596        // Add inline comment if there's a description or default info
597        if let Some(default) = schema.get("default") {
598            if !comment_parts.is_empty() {
599                line.push_str(&format!(
600                    "  # {} (default: {})",
601                    comment_parts.join(" - "),
602                    format_json_value(default)
603                ));
604            } else {
605                line.push_str(&format!("  # default: {}", format_json_value(default)));
606            }
607        } else if !comment_parts.is_empty() {
608            line.push_str(&format!("  # {}", comment_parts.join(" - ")));
609        }
610
611        output.push_str(&line);
612        output.push('\n');
613    }
614}
615
616/// Get an appropriate template value for a property
617fn get_template_value(schema: &serde_json::Value, prop_type: &str) -> String {
618    // Use default value if available
619    if let Some(default) = schema.get("default") {
620        return format_json_value(default);
621    }
622
623    // Use first enum value if it's an enum
624    if let Some(enum_vals) = schema.get("enum").and_then(|v| v.as_array()) {
625        if let Some(first) = enum_vals.first() {
626            return format_json_value(first);
627        }
628    }
629
630    // Otherwise, provide a placeholder based on type
631    match prop_type {
632        "string" => "\"\"".to_string(),
633        "integer" => "0".to_string(),
634        "number" => "0.0".to_string(),
635        "boolean" => "false".to_string(),
636        "array" => "[]".to_string(),
637        "null" => "null".to_string(),
638        _ => "null".to_string(),
639    }
640}
641
642/// Format a JSON value for YAML output
643fn format_json_value(value: &serde_json::Value) -> String {
644    match value {
645        serde_json::Value::Null => "null".to_string(),
646        serde_json::Value::Bool(b) => b.to_string(),
647        serde_json::Value::Number(n) => n.to_string(),
648        serde_json::Value::String(s) => {
649            // Check if we need to quote the string
650            if s.is_empty()
651                || s.contains(':')
652                || s.contains('#')
653                || s.starts_with(' ')
654                || s.ends_with(' ')
655            {
656                format!("\"{}\"", s.replace('"', "\\\""))
657            } else {
658                s.clone()
659            }
660        }
661        serde_json::Value::Array(arr) => {
662            if arr.is_empty() {
663                "[]".to_string()
664            } else {
665                // For non-empty arrays, format as YAML flow style
666                let items: Vec<String> = arr.iter().map(format_json_value).collect();
667                format!("[{}]", items.join(", "))
668            }
669        }
670        serde_json::Value::Object(_) => "{}".to_string(),
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn test_schema_from_yaml() {
680        let schema_yaml = r#"
681type: object
682required:
683  - name
684properties:
685  name:
686    type: string
687  port:
688    type: integer
689    minimum: 1
690    maximum: 65535
691"#;
692        let schema = Schema::from_yaml(schema_yaml).unwrap();
693        assert!(schema.as_value().is_object());
694    }
695
696    #[test]
697    fn test_validate_valid_config() {
698        let schema = Schema::from_yaml(
699            r#"
700type: object
701properties:
702  name:
703    type: string
704  port:
705    type: integer
706"#,
707        )
708        .unwrap();
709
710        let mut map = indexmap::IndexMap::new();
711        map.insert("name".into(), Value::String("myapp".into()));
712        map.insert("port".into(), Value::Integer(8080));
713        let config = Value::Mapping(map);
714
715        assert!(schema.validate(&config).is_ok());
716    }
717
718    #[test]
719    fn test_validate_missing_required() {
720        let schema = Schema::from_yaml(
721            r#"
722type: object
723required:
724  - name
725properties:
726  name:
727    type: string
728"#,
729        )
730        .unwrap();
731
732        let config = Value::Mapping(indexmap::IndexMap::new());
733        let result = schema.validate(&config);
734        assert!(result.is_err());
735        let err = result.unwrap_err();
736        assert!(err.to_string().contains("name"));
737    }
738
739    #[test]
740    fn test_validate_wrong_type() {
741        let schema = Schema::from_yaml(
742            r#"
743type: object
744properties:
745  port:
746    type: integer
747"#,
748        )
749        .unwrap();
750
751        let mut map = indexmap::IndexMap::new();
752        map.insert("port".into(), Value::String("not-a-number".into()));
753        let config = Value::Mapping(map);
754
755        let result = schema.validate(&config);
756        assert!(result.is_err());
757    }
758
759    #[test]
760    fn test_validate_constraint_violation() {
761        let schema = Schema::from_yaml(
762            r#"
763type: object
764properties:
765  port:
766    type: integer
767    minimum: 1
768    maximum: 65535
769"#,
770        )
771        .unwrap();
772
773        let mut map = indexmap::IndexMap::new();
774        map.insert("port".into(), Value::Integer(70000));
775        let config = Value::Mapping(map);
776
777        let result = schema.validate(&config);
778        assert!(result.is_err());
779    }
780
781    #[test]
782    fn test_validate_enum() {
783        let schema = Schema::from_yaml(
784            r#"
785type: object
786properties:
787  log_level:
788    type: string
789    enum: [debug, info, warn, error]
790"#,
791        )
792        .unwrap();
793
794        // Valid enum value
795        let mut map = indexmap::IndexMap::new();
796        map.insert("log_level".into(), Value::String("info".into()));
797        let config = Value::Mapping(map);
798        assert!(schema.validate(&config).is_ok());
799
800        // Invalid enum value
801        let mut map = indexmap::IndexMap::new();
802        map.insert("log_level".into(), Value::String("verbose".into()));
803        let config = Value::Mapping(map);
804        assert!(schema.validate(&config).is_err());
805    }
806
807    #[test]
808    fn test_validate_nested() {
809        let schema = Schema::from_yaml(
810            r#"
811type: object
812properties:
813  database:
814    type: object
815    required: [host]
816    properties:
817      host:
818        type: string
819      port:
820        type: integer
821        default: 5432
822"#,
823        )
824        .unwrap();
825
826        // Valid nested config
827        let mut db = indexmap::IndexMap::new();
828        db.insert("host".into(), Value::String("localhost".into()));
829        db.insert("port".into(), Value::Integer(5432));
830        let mut map = indexmap::IndexMap::new();
831        map.insert("database".into(), Value::Mapping(db));
832        let config = Value::Mapping(map);
833        assert!(schema.validate(&config).is_ok());
834
835        // Missing required nested key
836        let db = indexmap::IndexMap::new();
837        let mut map = indexmap::IndexMap::new();
838        map.insert("database".into(), Value::Mapping(db));
839        let config = Value::Mapping(map);
840        assert!(schema.validate(&config).is_err());
841    }
842
843    #[test]
844    fn test_validate_collect_multiple_errors() {
845        let schema = Schema::from_yaml(
846            r#"
847type: object
848required:
849  - name
850  - port
851properties:
852  name:
853    type: string
854  port:
855    type: integer
856"#,
857        )
858        .unwrap();
859
860        let config = Value::Mapping(indexmap::IndexMap::new());
861        let errors = schema.validate_collect(&config);
862        // Should have at least one error about missing required fields
863        assert!(!errors.is_empty());
864    }
865
866    #[test]
867    fn test_validate_additional_properties_allowed() {
868        // By default, additional properties are allowed
869        let schema = Schema::from_yaml(
870            r#"
871type: object
872properties:
873  name:
874    type: string
875"#,
876        )
877        .unwrap();
878
879        let mut map = indexmap::IndexMap::new();
880        map.insert("name".into(), Value::String("myapp".into()));
881        map.insert("extra".into(), Value::String("allowed".into()));
882        let config = Value::Mapping(map);
883        assert!(schema.validate(&config).is_ok());
884    }
885
886    #[test]
887    fn test_validate_additional_properties_denied() {
888        let schema = Schema::from_yaml(
889            r#"
890type: object
891properties:
892  name:
893    type: string
894additionalProperties: false
895"#,
896        )
897        .unwrap();
898
899        let mut map = indexmap::IndexMap::new();
900        map.insert("name".into(), Value::String("myapp".into()));
901        map.insert("extra".into(), Value::String("not allowed".into()));
902        let config = Value::Mapping(map);
903        assert!(schema.validate(&config).is_err());
904    }
905
906    #[test]
907    fn test_validate_array() {
908        let schema = Schema::from_yaml(
909            r#"
910type: object
911properties:
912  servers:
913    type: array
914    items:
915      type: string
916"#,
917        )
918        .unwrap();
919
920        let mut map = indexmap::IndexMap::new();
921        map.insert(
922            "servers".into(),
923            Value::Sequence(vec![
924                Value::String("server1".into()),
925                Value::String("server2".into()),
926            ]),
927        );
928        let config = Value::Mapping(map);
929        assert!(schema.validate(&config).is_ok());
930
931        // Wrong item type
932        let mut map = indexmap::IndexMap::new();
933        map.insert(
934            "servers".into(),
935            Value::Sequence(vec![Value::String("server1".into()), Value::Integer(123)]),
936        );
937        let config = Value::Mapping(map);
938        assert!(schema.validate(&config).is_err());
939    }
940
941    #[test]
942    fn test_validate_pattern() {
943        let schema = Schema::from_yaml(
944            r#"
945type: object
946properties:
947  version:
948    type: string
949    pattern: "^\\d+\\.\\d+\\.\\d+$"
950"#,
951        )
952        .unwrap();
953
954        // Valid semver
955        let mut map = indexmap::IndexMap::new();
956        map.insert("version".into(), Value::String("1.2.3".into()));
957        let config = Value::Mapping(map);
958        assert!(schema.validate(&config).is_ok());
959
960        // Invalid format
961        let mut map = indexmap::IndexMap::new();
962        map.insert("version".into(), Value::String("v1.2".into()));
963        let config = Value::Mapping(map);
964        assert!(schema.validate(&config).is_err());
965    }
966
967    #[test]
968    fn test_schema_from_json() {
969        let schema_json = r#"{
970            "type": "object",
971            "required": ["name"],
972            "properties": {
973                "name": { "type": "string" },
974                "port": { "type": "integer" }
975            }
976        }"#;
977        let schema = Schema::from_json(schema_json).unwrap();
978        assert!(schema.as_value().is_object());
979
980        // Validate with it
981        let mut map = indexmap::IndexMap::new();
982        map.insert("name".into(), Value::String("test".into()));
983        let config = Value::Mapping(map);
984        assert!(schema.validate(&config).is_ok());
985    }
986
987    #[test]
988    fn test_schema_from_json_invalid() {
989        let invalid_json = "not valid json {{{";
990        let result = Schema::from_json(invalid_json);
991        assert!(result.is_err());
992        assert!(result
993            .unwrap_err()
994            .to_string()
995            .contains("Invalid JSON schema"));
996    }
997
998    #[test]
999    fn test_schema_from_yaml_invalid() {
1000        let invalid_yaml = ":: invalid yaml :::";
1001        let result = Schema::from_yaml(invalid_yaml);
1002        assert!(result.is_err());
1003        assert!(result
1004            .unwrap_err()
1005            .to_string()
1006            .contains("Invalid YAML schema"));
1007    }
1008
1009    #[test]
1010    fn test_schema_from_file_yaml() {
1011        let dir = std::env::temp_dir();
1012        let path = dir.join("test_schema.yaml");
1013
1014        let schema_content = r#"
1015type: object
1016properties:
1017  name:
1018    type: string
1019"#;
1020        std::fs::write(&path, schema_content).unwrap();
1021
1022        let schema = Schema::from_file(&path).unwrap();
1023        assert!(schema.as_value().is_object());
1024
1025        std::fs::remove_file(&path).ok();
1026    }
1027
1028    #[test]
1029    fn test_schema_from_file_json() {
1030        let dir = std::env::temp_dir();
1031        let path = dir.join("test_schema.json");
1032
1033        let schema_content = r#"{"type": "object", "properties": {"name": {"type": "string"}}}"#;
1034        std::fs::write(&path, schema_content).unwrap();
1035
1036        let schema = Schema::from_file(&path).unwrap();
1037        assert!(schema.as_value().is_object());
1038
1039        std::fs::remove_file(&path).ok();
1040    }
1041
1042    #[test]
1043    fn test_schema_from_file_yml_extension() {
1044        let dir = std::env::temp_dir();
1045        let path = dir.join("test_schema.yml");
1046
1047        let schema_content = r#"
1048type: object
1049properties:
1050  name:
1051    type: string
1052"#;
1053        std::fs::write(&path, schema_content).unwrap();
1054
1055        let schema = Schema::from_file(&path).unwrap();
1056        assert!(schema.as_value().is_object());
1057
1058        std::fs::remove_file(&path).ok();
1059    }
1060
1061    #[test]
1062    fn test_schema_from_file_no_extension() {
1063        let dir = std::env::temp_dir();
1064        let path = dir.join("test_schema_no_ext");
1065
1066        // Default to YAML parsing
1067        let schema_content = r#"
1068type: object
1069properties:
1070  name:
1071    type: string
1072"#;
1073        std::fs::write(&path, schema_content).unwrap();
1074
1075        let schema = Schema::from_file(&path).unwrap();
1076        assert!(schema.as_value().is_object());
1077
1078        std::fs::remove_file(&path).ok();
1079    }
1080
1081    #[test]
1082    fn test_schema_from_file_not_found() {
1083        let result = Schema::from_file("/nonexistent/path/to/schema.yaml");
1084        assert!(result.is_err());
1085        assert!(result.unwrap_err().to_string().contains("File not found"));
1086    }
1087
1088    #[test]
1089    fn test_schema_to_yaml() {
1090        let schema = Schema::from_yaml(
1091            r#"
1092type: object
1093properties:
1094  name:
1095    type: string
1096"#,
1097        )
1098        .unwrap();
1099
1100        let yaml = schema.to_yaml().unwrap();
1101        assert!(yaml.contains("type"));
1102        assert!(yaml.contains("object"));
1103        assert!(yaml.contains("properties"));
1104    }
1105
1106    #[test]
1107    fn test_schema_to_json() {
1108        let schema = Schema::from_yaml(
1109            r#"
1110type: object
1111properties:
1112  name:
1113    type: string
1114"#,
1115        )
1116        .unwrap();
1117
1118        let json = schema.to_json().unwrap();
1119        assert!(json.contains("\"type\""));
1120        assert!(json.contains("\"object\""));
1121        assert!(json.contains("\"properties\""));
1122    }
1123
1124    #[test]
1125    fn test_schema_to_markdown_basic() {
1126        let schema = Schema::from_yaml(
1127            r#"
1128title: Test Configuration
1129description: A test configuration schema
1130type: object
1131required:
1132  - name
1133properties:
1134  name:
1135    type: string
1136    description: The application name
1137  port:
1138    type: integer
1139    description: The server port
1140    default: 8080
1141    minimum: 1
1142    maximum: 65535
1143"#,
1144        )
1145        .unwrap();
1146
1147        let markdown = schema.to_markdown();
1148        assert!(markdown.contains("# Test Configuration"));
1149        assert!(markdown.contains("A test configuration schema"));
1150        assert!(markdown.contains("name"));
1151        assert!(markdown.contains("port"));
1152        assert!(markdown.contains("(required)"));
1153    }
1154
1155    #[test]
1156    fn test_schema_to_markdown_nested() {
1157        let schema = Schema::from_yaml(
1158            r#"
1159title: Nested Config
1160type: object
1161properties:
1162  database:
1163    type: object
1164    description: Database settings
1165    required:
1166      - host
1167    properties:
1168      host:
1169        type: string
1170        description: Database host
1171      port:
1172        type: integer
1173        default: 5432
1174"#,
1175        )
1176        .unwrap();
1177
1178        let markdown = schema.to_markdown();
1179        assert!(markdown.contains("database"));
1180        assert!(markdown.contains("host"));
1181        assert!(markdown.contains("5432"));
1182    }
1183
1184    #[test]
1185    fn test_schema_to_markdown_enum() {
1186        let schema = Schema::from_yaml(
1187            r#"
1188type: object
1189properties:
1190  log_level:
1191    type: string
1192    enum: [debug, info, warn, error]
1193"#,
1194        )
1195        .unwrap();
1196
1197        let markdown = schema.to_markdown();
1198        assert!(markdown.contains("enum:"));
1199    }
1200
1201    #[test]
1202    fn test_schema_to_markdown_constraints() {
1203        let schema = Schema::from_yaml(
1204            r#"
1205type: object
1206properties:
1207  name:
1208    type: string
1209    minLength: 1
1210    maxLength: 100
1211    pattern: "^[a-z]+$"
1212"#,
1213        )
1214        .unwrap();
1215
1216        let markdown = schema.to_markdown();
1217        assert!(markdown.contains("minLength"));
1218        assert!(markdown.contains("maxLength"));
1219        assert!(markdown.contains("pattern"));
1220    }
1221
1222    #[test]
1223    fn test_schema_to_template_basic() {
1224        let schema = Schema::from_yaml(
1225            r#"
1226title: Test Config
1227type: object
1228required:
1229  - name
1230properties:
1231  name:
1232    type: string
1233    description: The application name
1234  port:
1235    type: integer
1236    default: 8080
1237"#,
1238        )
1239        .unwrap();
1240
1241        let template = schema.to_template();
1242        assert!(template.contains("name:"));
1243        assert!(template.contains("port:"));
1244        assert!(template.contains("8080"));
1245        assert!(template.contains("REQUIRED"));
1246    }
1247
1248    #[test]
1249    fn test_schema_to_template_nested() {
1250        let schema = Schema::from_yaml(
1251            r#"
1252type: object
1253properties:
1254  database:
1255    type: object
1256    required:
1257      - host
1258    properties:
1259      host:
1260        type: string
1261        description: Database host
1262      port:
1263        type: integer
1264        default: 5432
1265"#,
1266        )
1267        .unwrap();
1268
1269        let template = schema.to_template();
1270        assert!(template.contains("database:"));
1271        assert!(template.contains("host:"));
1272        assert!(template.contains("port:"));
1273        assert!(template.contains("5432"));
1274    }
1275
1276    #[test]
1277    fn test_schema_to_template_types() {
1278        let schema = Schema::from_yaml(
1279            r#"
1280type: object
1281properties:
1282  string_field:
1283    type: string
1284  int_field:
1285    type: integer
1286  number_field:
1287    type: number
1288  bool_field:
1289    type: boolean
1290  array_field:
1291    type: array
1292  null_field:
1293    type: "null"
1294"#,
1295        )
1296        .unwrap();
1297
1298        let template = schema.to_template();
1299        assert!(template.contains("string_field: \"\""));
1300        assert!(template.contains("int_field: 0"));
1301        assert!(template.contains("number_field: 0.0"));
1302        assert!(template.contains("bool_field: false"));
1303        assert!(template.contains("array_field: []"));
1304        assert!(template.contains("null_field: null"));
1305    }
1306
1307    #[test]
1308    fn test_schema_to_template_enum() {
1309        let schema = Schema::from_yaml(
1310            r#"
1311type: object
1312properties:
1313  log_level:
1314    type: string
1315    enum: [debug, info, warn, error]
1316"#,
1317        )
1318        .unwrap();
1319
1320        let template = schema.to_template();
1321        // Should use first enum value as default
1322        assert!(template.contains("log_level: debug") || template.contains("log_level: \"debug\""));
1323    }
1324
1325    #[test]
1326    fn test_validation_error_display() {
1327        let err = ValidationError {
1328            path: "/database/port".to_string(),
1329            message: "expected integer".to_string(),
1330        };
1331        let display = format!("{}", err);
1332        assert_eq!(display, "/database/port: expected integer");
1333    }
1334
1335    #[test]
1336    fn test_validation_error_display_empty_path() {
1337        let err = ValidationError {
1338            path: "".to_string(),
1339            message: "missing required field".to_string(),
1340        };
1341        let display = format!("{}", err);
1342        assert_eq!(display, "missing required field");
1343    }
1344
1345    #[test]
1346    fn test_value_to_json_null() {
1347        let v = Value::Null;
1348        let json = value_to_json(&v);
1349        assert!(json.is_null());
1350    }
1351
1352    #[test]
1353    fn test_value_to_json_bool() {
1354        let v = Value::Bool(true);
1355        let json = value_to_json(&v);
1356        assert_eq!(json, serde_json::Value::Bool(true));
1357    }
1358
1359    #[test]
1360    fn test_value_to_json_integer() {
1361        let v = Value::Integer(42);
1362        let json = value_to_json(&v);
1363        assert_eq!(json, serde_json::json!(42));
1364    }
1365
1366    #[test]
1367    fn test_value_to_json_float() {
1368        let v = Value::Float(2.71);
1369        let json = value_to_json(&v);
1370        assert!(json.is_number());
1371    }
1372
1373    #[test]
1374    fn test_value_to_json_float_nan() {
1375        // NaN cannot be represented in JSON, should return null
1376        let v = Value::Float(f64::NAN);
1377        let json = value_to_json(&v);
1378        assert!(json.is_null());
1379    }
1380
1381    #[test]
1382    fn test_value_to_json_string() {
1383        let v = Value::String("hello".into());
1384        let json = value_to_json(&v);
1385        assert_eq!(json, serde_json::json!("hello"));
1386    }
1387
1388    #[test]
1389    fn test_value_to_json_bytes() {
1390        let v = Value::Bytes(vec![72, 101, 108, 108, 111]); // "Hello"
1391        let json = value_to_json(&v);
1392        // Should be base64 encoded
1393        assert!(json.is_string());
1394        assert_eq!(json.as_str().unwrap(), "SGVsbG8=");
1395    }
1396
1397    #[test]
1398    fn test_value_to_json_sequence() {
1399        let v = Value::Sequence(vec![Value::Integer(1), Value::Integer(2)]);
1400        let json = value_to_json(&v);
1401        assert!(json.is_array());
1402        assert_eq!(json, serde_json::json!([1, 2]));
1403    }
1404
1405    #[test]
1406    fn test_value_to_json_mapping() {
1407        let mut map = indexmap::IndexMap::new();
1408        map.insert("key".to_string(), Value::String("value".into()));
1409        let v = Value::Mapping(map);
1410        let json = value_to_json(&v);
1411        assert!(json.is_object());
1412        assert_eq!(json["key"], "value");
1413    }
1414
1415    #[test]
1416    fn test_format_json_value_null() {
1417        let v = serde_json::Value::Null;
1418        assert_eq!(format_json_value(&v), "null");
1419    }
1420
1421    #[test]
1422    fn test_format_json_value_bool() {
1423        assert_eq!(format_json_value(&serde_json::json!(true)), "true");
1424        assert_eq!(format_json_value(&serde_json::json!(false)), "false");
1425    }
1426
1427    #[test]
1428    fn test_format_json_value_number() {
1429        assert_eq!(format_json_value(&serde_json::json!(42)), "42");
1430        assert_eq!(format_json_value(&serde_json::json!(2.71)), "2.71");
1431    }
1432
1433    #[test]
1434    fn test_format_json_value_string_simple() {
1435        assert_eq!(format_json_value(&serde_json::json!("hello")), "hello");
1436    }
1437
1438    #[test]
1439    fn test_format_json_value_string_needs_quoting() {
1440        // Empty string needs quotes
1441        assert_eq!(format_json_value(&serde_json::json!("")), "\"\"");
1442        // Contains colon
1443        assert_eq!(
1444            format_json_value(&serde_json::json!("key:value")),
1445            "\"key:value\""
1446        );
1447        // Contains hash
1448        assert_eq!(
1449            format_json_value(&serde_json::json!("has#comment")),
1450            "\"has#comment\""
1451        );
1452        // Starts with space
1453        assert_eq!(
1454            format_json_value(&serde_json::json!(" leading")),
1455            "\" leading\""
1456        );
1457        // Ends with space
1458        assert_eq!(
1459            format_json_value(&serde_json::json!("trailing ")),
1460            "\"trailing \""
1461        );
1462    }
1463
1464    #[test]
1465    fn test_format_json_value_string_with_quotes_needing_escape() {
1466        // String that needs quoting AND contains quotes should escape them
1467        // Empty string triggers quoting, so let's test that
1468        let v = serde_json::Value::String("has:\"quotes\"".to_string());
1469        let formatted = format_json_value(&v);
1470        // The colon triggers quoting, and the quotes get escaped
1471        assert!(formatted.contains("\\\""));
1472        assert!(formatted.starts_with('"'));
1473    }
1474
1475    #[test]
1476    fn test_format_json_value_string_no_quoting_needed() {
1477        // String without special chars doesn't get quoted
1478        let v = serde_json::Value::String("has \"quotes\"".to_string());
1479        let formatted = format_json_value(&v);
1480        // No colon/hash/spaces so it's returned as-is without quoting
1481        assert_eq!(formatted, "has \"quotes\"");
1482    }
1483
1484    #[test]
1485    fn test_format_json_value_array_empty() {
1486        assert_eq!(format_json_value(&serde_json::json!([])), "[]");
1487    }
1488
1489    #[test]
1490    fn test_format_json_value_array_with_items() {
1491        assert_eq!(
1492            format_json_value(&serde_json::json!([1, 2, 3])),
1493            "[1, 2, 3]"
1494        );
1495    }
1496
1497    #[test]
1498    fn test_format_json_value_object() {
1499        assert_eq!(format_json_value(&serde_json::json!({})), "{}");
1500    }
1501
1502    #[test]
1503    fn test_get_type_string_basic() {
1504        let schema = serde_json::json!({"type": "string"});
1505        assert_eq!(get_type_string(&schema), "string");
1506    }
1507
1508    #[test]
1509    fn test_get_type_string_with_constraints() {
1510        let schema = serde_json::json!({
1511            "type": "integer",
1512            "minimum": 1,
1513            "maximum": 100
1514        });
1515        let type_str = get_type_string(&schema);
1516        assert!(type_str.contains("integer"));
1517        assert!(type_str.contains("min: 1"));
1518        assert!(type_str.contains("max: 100"));
1519    }
1520
1521    #[test]
1522    fn test_get_type_string_with_string_constraints() {
1523        let schema = serde_json::json!({
1524            "type": "string",
1525            "minLength": 1,
1526            "maxLength": 50,
1527            "pattern": "^[a-z]+$"
1528        });
1529        let type_str = get_type_string(&schema);
1530        assert!(type_str.contains("string"));
1531        assert!(type_str.contains("minLength: 1"));
1532        assert!(type_str.contains("maxLength: 50"));
1533        assert!(type_str.contains("pattern:"));
1534    }
1535
1536    #[test]
1537    fn test_get_type_string_enum() {
1538        let schema = serde_json::json!({
1539            "enum": ["a", "b", "c"]
1540        });
1541        let type_str = get_type_string(&schema);
1542        assert!(type_str.starts_with("enum:"));
1543        assert!(type_str.contains("\"a\""));
1544        assert!(type_str.contains("\"b\""));
1545    }
1546
1547    #[test]
1548    fn test_get_type_string_enum_numeric() {
1549        let schema = serde_json::json!({
1550            "enum": [1, 2, 3]
1551        });
1552        let type_str = get_type_string(&schema);
1553        assert!(type_str.contains("1"));
1554        assert!(type_str.contains("2"));
1555    }
1556
1557    #[test]
1558    fn test_get_type_string_no_type() {
1559        let schema = serde_json::json!({});
1560        assert_eq!(get_type_string(&schema), "any");
1561    }
1562
1563    #[test]
1564    fn test_schema_default_string_with_default() {
1565        let schema = serde_json::json!({"default": 42});
1566        assert_eq!(schema_default_string(&schema), "42");
1567    }
1568
1569    #[test]
1570    fn test_schema_default_string_with_string_default() {
1571        let schema = serde_json::json!({"default": "hello"});
1572        assert_eq!(schema_default_string(&schema), "\"hello\"");
1573    }
1574
1575    #[test]
1576    fn test_schema_default_string_no_default() {
1577        let schema = serde_json::json!({});
1578        assert_eq!(schema_default_string(&schema), "-");
1579    }
1580
1581    #[test]
1582    fn test_get_template_value_with_default() {
1583        let schema = serde_json::json!({"type": "string", "default": "myvalue"});
1584        assert_eq!(get_template_value(&schema, "string"), "myvalue");
1585    }
1586
1587    #[test]
1588    fn test_get_template_value_with_enum() {
1589        let schema = serde_json::json!({"type": "string", "enum": ["first", "second"]});
1590        assert_eq!(get_template_value(&schema, "string"), "first");
1591    }
1592
1593    #[test]
1594    fn test_get_template_value_placeholders() {
1595        assert_eq!(get_template_value(&serde_json::json!({}), "string"), "\"\"");
1596        assert_eq!(get_template_value(&serde_json::json!({}), "integer"), "0");
1597        assert_eq!(get_template_value(&serde_json::json!({}), "number"), "0.0");
1598        assert_eq!(
1599            get_template_value(&serde_json::json!({}), "boolean"),
1600            "false"
1601        );
1602        assert_eq!(get_template_value(&serde_json::json!({}), "array"), "[]");
1603        assert_eq!(get_template_value(&serde_json::json!({}), "null"), "null");
1604        assert_eq!(
1605            get_template_value(&serde_json::json!({}), "unknown"),
1606            "null"
1607        );
1608    }
1609
1610    #[test]
1611    fn test_schema_to_markdown_no_title() {
1612        // Schema without title should use default
1613        let schema = Schema::from_yaml(
1614            r#"
1615type: object
1616properties:
1617  name:
1618    type: string
1619"#,
1620        )
1621        .unwrap();
1622
1623        let markdown = schema.to_markdown();
1624        assert!(markdown.contains("# Configuration Reference"));
1625    }
1626
1627    #[test]
1628    fn test_schema_to_markdown_non_object_property() {
1629        // Top-level property that is not an object
1630        let schema = Schema::from_yaml(
1631            r#"
1632type: object
1633required:
1634  - port
1635properties:
1636  port:
1637    type: integer
1638    description: Server port
1639"#,
1640        )
1641        .unwrap();
1642
1643        let markdown = schema.to_markdown();
1644        assert!(markdown.contains("port"));
1645        assert!(markdown.contains("(required)"));
1646    }
1647
1648    #[test]
1649    fn test_schema_to_template_no_title() {
1650        let schema = Schema::from_yaml(
1651            r#"
1652type: object
1653properties:
1654  name:
1655    type: string
1656"#,
1657        )
1658        .unwrap();
1659
1660        let template = schema.to_template();
1661        assert!(template.contains("Configuration template generated from schema"));
1662    }
1663
1664    #[test]
1665    fn test_schema_to_template_with_description() {
1666        let schema = Schema::from_yaml(
1667            r#"
1668type: object
1669properties:
1670  name:
1671    type: string
1672    description: The name field
1673"#,
1674        )
1675        .unwrap();
1676
1677        let template = schema.to_template();
1678        assert!(template.contains("The name field"));
1679    }
1680
1681    #[test]
1682    fn test_schema_to_template_with_default_and_description() {
1683        let schema = Schema::from_yaml(
1684            r#"
1685type: object
1686properties:
1687  port:
1688    type: integer
1689    description: Server port
1690    default: 8080
1691"#,
1692        )
1693        .unwrap();
1694
1695        let template = schema.to_template();
1696        assert!(template.contains("8080"));
1697        assert!(template.contains("default:"));
1698    }
1699
1700    // Tests for get_default() and allows_null()
1701
1702    #[test]
1703    fn test_get_default_simple() {
1704        let schema = Schema::from_yaml(
1705            r#"
1706type: object
1707properties:
1708  pool_size:
1709    type: integer
1710    default: 10
1711  timeout:
1712    type: number
1713    default: 30.5
1714  enabled:
1715    type: boolean
1716    default: true
1717  name:
1718    type: string
1719    default: "default_name"
1720"#,
1721        )
1722        .unwrap();
1723
1724        assert_eq!(schema.get_default("pool_size"), Some(Value::Integer(10)));
1725        assert_eq!(schema.get_default("timeout"), Some(Value::Float(30.5)));
1726        assert_eq!(schema.get_default("enabled"), Some(Value::Bool(true)));
1727        assert_eq!(
1728            schema.get_default("name"),
1729            Some(Value::String("default_name".into()))
1730        );
1731        assert_eq!(schema.get_default("nonexistent"), None);
1732    }
1733
1734    #[test]
1735    fn test_get_default_nested() {
1736        let schema = Schema::from_yaml(
1737            r#"
1738type: object
1739properties:
1740  database:
1741    type: object
1742    properties:
1743      host:
1744        type: string
1745        default: localhost
1746      port:
1747        type: integer
1748        default: 5432
1749      pool:
1750        type: object
1751        properties:
1752          size:
1753            type: integer
1754            default: 10
1755"#,
1756        )
1757        .unwrap();
1758
1759        assert_eq!(
1760            schema.get_default("database.host"),
1761            Some(Value::String("localhost".into()))
1762        );
1763        assert_eq!(
1764            schema.get_default("database.port"),
1765            Some(Value::Integer(5432))
1766        );
1767        assert_eq!(
1768            schema.get_default("database.pool.size"),
1769            Some(Value::Integer(10))
1770        );
1771        assert_eq!(schema.get_default("database.nonexistent"), None);
1772    }
1773
1774    #[test]
1775    fn test_get_default_object_level() {
1776        let schema = Schema::from_yaml(
1777            r#"
1778type: object
1779properties:
1780  logging:
1781    type: object
1782    default:
1783      level: info
1784      format: json
1785"#,
1786        )
1787        .unwrap();
1788
1789        let default = schema.get_default("logging").unwrap();
1790        match default {
1791            Value::Mapping(map) => {
1792                assert_eq!(map.get("level"), Some(&Value::String("info".into())));
1793                assert_eq!(map.get("format"), Some(&Value::String("json".into())));
1794            }
1795            _ => panic!("Expected mapping default"),
1796        }
1797    }
1798
1799    #[test]
1800    fn test_get_default_null_default() {
1801        let schema = Schema::from_yaml(
1802            r#"
1803type: object
1804properties:
1805  optional_value:
1806    type:
1807      - string
1808      - "null"
1809    default: null
1810"#,
1811        )
1812        .unwrap();
1813
1814        assert_eq!(schema.get_default("optional_value"), Some(Value::Null));
1815    }
1816
1817    #[test]
1818    fn test_allows_null_single_type() {
1819        let schema = Schema::from_yaml(
1820            r#"
1821type: object
1822properties:
1823  required_string:
1824    type: string
1825  nullable_string:
1826    type: "null"
1827"#,
1828        )
1829        .unwrap();
1830
1831        assert!(!schema.allows_null("required_string"));
1832        assert!(schema.allows_null("nullable_string"));
1833    }
1834
1835    #[test]
1836    fn test_allows_null_array_type() {
1837        let schema = Schema::from_yaml(
1838            r#"
1839type: object
1840properties:
1841  nullable_value:
1842    type:
1843      - string
1844      - "null"
1845  non_nullable:
1846    type:
1847      - string
1848      - integer
1849"#,
1850        )
1851        .unwrap();
1852
1853        assert!(schema.allows_null("nullable_value"));
1854        assert!(!schema.allows_null("non_nullable"));
1855    }
1856
1857    #[test]
1858    fn test_allows_null_nested() {
1859        let schema = Schema::from_yaml(
1860            r#"
1861type: object
1862properties:
1863  database:
1864    type: object
1865    properties:
1866      connection_string:
1867        type:
1868          - string
1869          - "null"
1870        default: null
1871"#,
1872        )
1873        .unwrap();
1874
1875        assert!(schema.allows_null("database.connection_string"));
1876    }
1877
1878    #[test]
1879    fn test_allows_null_no_type_specified() {
1880        // When type is not specified, null is implicitly allowed
1881        let schema = Schema::from_yaml(
1882            r#"
1883type: object
1884properties:
1885  any_value: {}
1886"#,
1887        )
1888        .unwrap();
1889
1890        assert!(schema.allows_null("any_value"));
1891    }
1892}