Skip to main content

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::Stream(_) => {
499            // Streams should be materialized before schema validation
500            // This is a programming error, not a user error
501            panic!("Value::Stream should be materialized before schema validation")
502        }
503        Value::Sequence(seq) => serde_json::Value::Array(seq.iter().map(value_to_json).collect()),
504        Value::Mapping(map) => {
505            let obj: serde_json::Map<String, serde_json::Value> = map
506                .iter()
507                .map(|(k, v)| (k.clone(), value_to_json(v)))
508                .collect();
509            serde_json::Value::Object(obj)
510        }
511    }
512}
513
514/// Generate a YAML template from a JSON Schema
515fn generate_template(schema: &serde_json::Value) -> String {
516    let mut output = String::new();
517
518    // Get title for header comment
519    if let Some(title) = schema.get("title").and_then(|v| v.as_str()) {
520        output.push_str(&format!("# Generated from: {}\n", title));
521    } else {
522        output.push_str("# Configuration template generated from schema\n");
523    }
524    output.push_str("# Required fields marked with # REQUIRED\n\n");
525
526    // Get required fields at root level
527    let root_required: Vec<&str> = schema
528        .get("required")
529        .and_then(|v| v.as_array())
530        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
531        .unwrap_or_default();
532
533    // Process top-level properties
534    if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
535        for (name, prop_schema) in properties {
536            let is_required = root_required.contains(&name.as_str());
537            generate_template_property(&mut output, name, prop_schema, is_required, 0);
538        }
539    }
540
541    output
542}
543
544/// Generate template output for a single property
545fn generate_template_property(
546    output: &mut String,
547    name: &str,
548    schema: &serde_json::Value,
549    is_required: bool,
550    indent_level: usize,
551) {
552    let indent = "  ".repeat(indent_level);
553
554    // Build the comment parts
555    let mut comment_parts = Vec::new();
556    if is_required {
557        comment_parts.push("REQUIRED".to_string());
558    }
559    if let Some(desc) = schema.get("description").and_then(|v| v.as_str()) {
560        comment_parts.push(desc.to_string());
561    }
562
563    // Get the type
564    let prop_type = schema.get("type").and_then(|v| v.as_str()).unwrap_or("any");
565
566    // Handle object type
567    if prop_type == "object" {
568        // Write comment if any
569        if !comment_parts.is_empty() {
570            output.push_str(&format!("{}# {}\n", indent, comment_parts.join(" - ")));
571        }
572        output.push_str(&format!("{}{}:\n", indent, name));
573
574        // Get required fields for this object
575        let required: Vec<&str> = schema
576            .get("required")
577            .and_then(|v| v.as_array())
578            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
579            .unwrap_or_default();
580
581        // Process nested properties
582        if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
583            for (prop_name, prop_schema) in properties {
584                let prop_required = required.contains(&prop_name.as_str());
585                generate_template_property(
586                    output,
587                    prop_name,
588                    prop_schema,
589                    prop_required,
590                    indent_level + 1,
591                );
592            }
593        }
594    } else {
595        // For scalar types, get the default value or a placeholder
596        let value_str = get_template_value(schema, prop_type);
597
598        // Build the line
599        let mut line = format!("{}{}: {}", indent, name, value_str);
600
601        // Add inline comment if there's a description or default info
602        if let Some(default) = schema.get("default") {
603            if !comment_parts.is_empty() {
604                line.push_str(&format!(
605                    "  # {} (default: {})",
606                    comment_parts.join(" - "),
607                    format_json_value(default)
608                ));
609            } else {
610                line.push_str(&format!("  # default: {}", format_json_value(default)));
611            }
612        } else if !comment_parts.is_empty() {
613            line.push_str(&format!("  # {}", comment_parts.join(" - ")));
614        }
615
616        output.push_str(&line);
617        output.push('\n');
618    }
619}
620
621/// Get an appropriate template value for a property
622fn get_template_value(schema: &serde_json::Value, prop_type: &str) -> String {
623    // Use default value if available
624    if let Some(default) = schema.get("default") {
625        return format_json_value(default);
626    }
627
628    // Use first enum value if it's an enum
629    if let Some(enum_vals) = schema.get("enum").and_then(|v| v.as_array()) {
630        if let Some(first) = enum_vals.first() {
631            return format_json_value(first);
632        }
633    }
634
635    // Otherwise, provide a placeholder based on type
636    match prop_type {
637        "string" => "\"\"".to_string(),
638        "integer" => "0".to_string(),
639        "number" => "0.0".to_string(),
640        "boolean" => "false".to_string(),
641        "array" => "[]".to_string(),
642        "null" => "null".to_string(),
643        _ => "null".to_string(),
644    }
645}
646
647/// Format a JSON value for YAML output
648fn format_json_value(value: &serde_json::Value) -> String {
649    match value {
650        serde_json::Value::Null => "null".to_string(),
651        serde_json::Value::Bool(b) => b.to_string(),
652        serde_json::Value::Number(n) => n.to_string(),
653        serde_json::Value::String(s) => {
654            // Check if we need to quote the string
655            if s.is_empty()
656                || s.contains(':')
657                || s.contains('#')
658                || s.starts_with(' ')
659                || s.ends_with(' ')
660            {
661                format!("\"{}\"", s.replace('"', "\\\""))
662            } else {
663                s.clone()
664            }
665        }
666        serde_json::Value::Array(arr) => {
667            if arr.is_empty() {
668                "[]".to_string()
669            } else {
670                // For non-empty arrays, format as YAML flow style
671                let items: Vec<String> = arr.iter().map(format_json_value).collect();
672                format!("[{}]", items.join(", "))
673            }
674        }
675        serde_json::Value::Object(_) => "{}".to_string(),
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn test_schema_from_yaml() {
685        let schema_yaml = r#"
686type: object
687required:
688  - name
689properties:
690  name:
691    type: string
692  port:
693    type: integer
694    minimum: 1
695    maximum: 65535
696"#;
697        let schema = Schema::from_yaml(schema_yaml).unwrap();
698        assert!(schema.as_value().is_object());
699    }
700
701    #[test]
702    fn test_validate_valid_config() {
703        let schema = Schema::from_yaml(
704            r#"
705type: object
706properties:
707  name:
708    type: string
709  port:
710    type: integer
711"#,
712        )
713        .unwrap();
714
715        let mut map = indexmap::IndexMap::new();
716        map.insert("name".into(), Value::String("myapp".into()));
717        map.insert("port".into(), Value::Integer(8080));
718        let config = Value::Mapping(map);
719
720        assert!(schema.validate(&config).is_ok());
721    }
722
723    #[test]
724    fn test_validate_missing_required() {
725        let schema = Schema::from_yaml(
726            r#"
727type: object
728required:
729  - name
730properties:
731  name:
732    type: string
733"#,
734        )
735        .unwrap();
736
737        let config = Value::Mapping(indexmap::IndexMap::new());
738        let result = schema.validate(&config);
739        assert!(result.is_err());
740        let err = result.unwrap_err();
741        assert!(err.to_string().contains("name"));
742    }
743
744    #[test]
745    fn test_validate_wrong_type() {
746        let schema = Schema::from_yaml(
747            r#"
748type: object
749properties:
750  port:
751    type: integer
752"#,
753        )
754        .unwrap();
755
756        let mut map = indexmap::IndexMap::new();
757        map.insert("port".into(), Value::String("not-a-number".into()));
758        let config = Value::Mapping(map);
759
760        let result = schema.validate(&config);
761        assert!(result.is_err());
762    }
763
764    #[test]
765    fn test_validate_constraint_violation() {
766        let schema = Schema::from_yaml(
767            r#"
768type: object
769properties:
770  port:
771    type: integer
772    minimum: 1
773    maximum: 65535
774"#,
775        )
776        .unwrap();
777
778        let mut map = indexmap::IndexMap::new();
779        map.insert("port".into(), Value::Integer(70000));
780        let config = Value::Mapping(map);
781
782        let result = schema.validate(&config);
783        assert!(result.is_err());
784    }
785
786    #[test]
787    fn test_validate_enum() {
788        let schema = Schema::from_yaml(
789            r#"
790type: object
791properties:
792  log_level:
793    type: string
794    enum: [debug, info, warn, error]
795"#,
796        )
797        .unwrap();
798
799        // Valid enum value
800        let mut map = indexmap::IndexMap::new();
801        map.insert("log_level".into(), Value::String("info".into()));
802        let config = Value::Mapping(map);
803        assert!(schema.validate(&config).is_ok());
804
805        // Invalid enum value
806        let mut map = indexmap::IndexMap::new();
807        map.insert("log_level".into(), Value::String("verbose".into()));
808        let config = Value::Mapping(map);
809        assert!(schema.validate(&config).is_err());
810    }
811
812    #[test]
813    fn test_validate_nested() {
814        let schema = Schema::from_yaml(
815            r#"
816type: object
817properties:
818  database:
819    type: object
820    required: [host]
821    properties:
822      host:
823        type: string
824      port:
825        type: integer
826        default: 5432
827"#,
828        )
829        .unwrap();
830
831        // Valid nested config
832        let mut db = indexmap::IndexMap::new();
833        db.insert("host".into(), Value::String("localhost".into()));
834        db.insert("port".into(), Value::Integer(5432));
835        let mut map = indexmap::IndexMap::new();
836        map.insert("database".into(), Value::Mapping(db));
837        let config = Value::Mapping(map);
838        assert!(schema.validate(&config).is_ok());
839
840        // Missing required nested key
841        let db = indexmap::IndexMap::new();
842        let mut map = indexmap::IndexMap::new();
843        map.insert("database".into(), Value::Mapping(db));
844        let config = Value::Mapping(map);
845        assert!(schema.validate(&config).is_err());
846    }
847
848    #[test]
849    fn test_validate_collect_multiple_errors() {
850        let schema = Schema::from_yaml(
851            r#"
852type: object
853required:
854  - name
855  - port
856properties:
857  name:
858    type: string
859  port:
860    type: integer
861"#,
862        )
863        .unwrap();
864
865        let config = Value::Mapping(indexmap::IndexMap::new());
866        let errors = schema.validate_collect(&config);
867        // Should have at least one error about missing required fields
868        assert!(!errors.is_empty());
869    }
870
871    #[test]
872    fn test_validate_additional_properties_allowed() {
873        // By default, additional properties are allowed
874        let schema = Schema::from_yaml(
875            r#"
876type: object
877properties:
878  name:
879    type: string
880"#,
881        )
882        .unwrap();
883
884        let mut map = indexmap::IndexMap::new();
885        map.insert("name".into(), Value::String("myapp".into()));
886        map.insert("extra".into(), Value::String("allowed".into()));
887        let config = Value::Mapping(map);
888        assert!(schema.validate(&config).is_ok());
889    }
890
891    #[test]
892    fn test_validate_additional_properties_denied() {
893        let schema = Schema::from_yaml(
894            r#"
895type: object
896properties:
897  name:
898    type: string
899additionalProperties: false
900"#,
901        )
902        .unwrap();
903
904        let mut map = indexmap::IndexMap::new();
905        map.insert("name".into(), Value::String("myapp".into()));
906        map.insert("extra".into(), Value::String("not allowed".into()));
907        let config = Value::Mapping(map);
908        assert!(schema.validate(&config).is_err());
909    }
910
911    #[test]
912    fn test_validate_array() {
913        let schema = Schema::from_yaml(
914            r#"
915type: object
916properties:
917  servers:
918    type: array
919    items:
920      type: string
921"#,
922        )
923        .unwrap();
924
925        let mut map = indexmap::IndexMap::new();
926        map.insert(
927            "servers".into(),
928            Value::Sequence(vec![
929                Value::String("server1".into()),
930                Value::String("server2".into()),
931            ]),
932        );
933        let config = Value::Mapping(map);
934        assert!(schema.validate(&config).is_ok());
935
936        // Wrong item type
937        let mut map = indexmap::IndexMap::new();
938        map.insert(
939            "servers".into(),
940            Value::Sequence(vec![Value::String("server1".into()), Value::Integer(123)]),
941        );
942        let config = Value::Mapping(map);
943        assert!(schema.validate(&config).is_err());
944    }
945
946    #[test]
947    fn test_validate_pattern() {
948        let schema = Schema::from_yaml(
949            r#"
950type: object
951properties:
952  version:
953    type: string
954    pattern: "^\\d+\\.\\d+\\.\\d+$"
955"#,
956        )
957        .unwrap();
958
959        // Valid semver
960        let mut map = indexmap::IndexMap::new();
961        map.insert("version".into(), Value::String("1.2.3".into()));
962        let config = Value::Mapping(map);
963        assert!(schema.validate(&config).is_ok());
964
965        // Invalid format
966        let mut map = indexmap::IndexMap::new();
967        map.insert("version".into(), Value::String("v1.2".into()));
968        let config = Value::Mapping(map);
969        assert!(schema.validate(&config).is_err());
970    }
971
972    #[test]
973    fn test_schema_from_json() {
974        let schema_json = r#"{
975            "type": "object",
976            "required": ["name"],
977            "properties": {
978                "name": { "type": "string" },
979                "port": { "type": "integer" }
980            }
981        }"#;
982        let schema = Schema::from_json(schema_json).unwrap();
983        assert!(schema.as_value().is_object());
984
985        // Validate with it
986        let mut map = indexmap::IndexMap::new();
987        map.insert("name".into(), Value::String("test".into()));
988        let config = Value::Mapping(map);
989        assert!(schema.validate(&config).is_ok());
990    }
991
992    #[test]
993    fn test_schema_from_json_invalid() {
994        let invalid_json = "not valid json {{{";
995        let result = Schema::from_json(invalid_json);
996        assert!(result.is_err());
997        assert!(result
998            .unwrap_err()
999            .to_string()
1000            .contains("Invalid JSON schema"));
1001    }
1002
1003    #[test]
1004    fn test_schema_from_yaml_invalid() {
1005        let invalid_yaml = ":: invalid yaml :::";
1006        let result = Schema::from_yaml(invalid_yaml);
1007        assert!(result.is_err());
1008        assert!(result
1009            .unwrap_err()
1010            .to_string()
1011            .contains("Invalid YAML schema"));
1012    }
1013
1014    #[test]
1015    fn test_schema_from_file_yaml() {
1016        let dir = std::env::temp_dir();
1017        let path = dir.join("test_schema.yaml");
1018
1019        let schema_content = r#"
1020type: object
1021properties:
1022  name:
1023    type: string
1024"#;
1025        std::fs::write(&path, schema_content).unwrap();
1026
1027        let schema = Schema::from_file(&path).unwrap();
1028        assert!(schema.as_value().is_object());
1029
1030        std::fs::remove_file(&path).ok();
1031    }
1032
1033    #[test]
1034    fn test_schema_from_file_json() {
1035        let dir = std::env::temp_dir();
1036        let path = dir.join("test_schema.json");
1037
1038        let schema_content = r#"{"type": "object", "properties": {"name": {"type": "string"}}}"#;
1039        std::fs::write(&path, schema_content).unwrap();
1040
1041        let schema = Schema::from_file(&path).unwrap();
1042        assert!(schema.as_value().is_object());
1043
1044        std::fs::remove_file(&path).ok();
1045    }
1046
1047    #[test]
1048    fn test_schema_from_file_yml_extension() {
1049        let dir = std::env::temp_dir();
1050        let path = dir.join("test_schema.yml");
1051
1052        let schema_content = r#"
1053type: object
1054properties:
1055  name:
1056    type: string
1057"#;
1058        std::fs::write(&path, schema_content).unwrap();
1059
1060        let schema = Schema::from_file(&path).unwrap();
1061        assert!(schema.as_value().is_object());
1062
1063        std::fs::remove_file(&path).ok();
1064    }
1065
1066    #[test]
1067    fn test_schema_from_file_no_extension() {
1068        let dir = std::env::temp_dir();
1069        let path = dir.join("test_schema_no_ext");
1070
1071        // Default to YAML parsing
1072        let schema_content = r#"
1073type: object
1074properties:
1075  name:
1076    type: string
1077"#;
1078        std::fs::write(&path, schema_content).unwrap();
1079
1080        let schema = Schema::from_file(&path).unwrap();
1081        assert!(schema.as_value().is_object());
1082
1083        std::fs::remove_file(&path).ok();
1084    }
1085
1086    #[test]
1087    fn test_schema_from_file_not_found() {
1088        let result = Schema::from_file("/nonexistent/path/to/schema.yaml");
1089        assert!(result.is_err());
1090        assert!(result.unwrap_err().to_string().contains("File not found"));
1091    }
1092
1093    #[test]
1094    fn test_schema_to_yaml() {
1095        let schema = Schema::from_yaml(
1096            r#"
1097type: object
1098properties:
1099  name:
1100    type: string
1101"#,
1102        )
1103        .unwrap();
1104
1105        let yaml = schema.to_yaml().unwrap();
1106        assert!(yaml.contains("type"));
1107        assert!(yaml.contains("object"));
1108        assert!(yaml.contains("properties"));
1109    }
1110
1111    #[test]
1112    fn test_schema_to_json() {
1113        let schema = Schema::from_yaml(
1114            r#"
1115type: object
1116properties:
1117  name:
1118    type: string
1119"#,
1120        )
1121        .unwrap();
1122
1123        let json = schema.to_json().unwrap();
1124        assert!(json.contains("\"type\""));
1125        assert!(json.contains("\"object\""));
1126        assert!(json.contains("\"properties\""));
1127    }
1128
1129    #[test]
1130    fn test_schema_to_markdown_basic() {
1131        let schema = Schema::from_yaml(
1132            r#"
1133title: Test Configuration
1134description: A test configuration schema
1135type: object
1136required:
1137  - name
1138properties:
1139  name:
1140    type: string
1141    description: The application name
1142  port:
1143    type: integer
1144    description: The server port
1145    default: 8080
1146    minimum: 1
1147    maximum: 65535
1148"#,
1149        )
1150        .unwrap();
1151
1152        let markdown = schema.to_markdown();
1153        assert!(markdown.contains("# Test Configuration"));
1154        assert!(markdown.contains("A test configuration schema"));
1155        assert!(markdown.contains("name"));
1156        assert!(markdown.contains("port"));
1157        assert!(markdown.contains("(required)"));
1158    }
1159
1160    #[test]
1161    fn test_schema_to_markdown_nested() {
1162        let schema = Schema::from_yaml(
1163            r#"
1164title: Nested Config
1165type: object
1166properties:
1167  database:
1168    type: object
1169    description: Database settings
1170    required:
1171      - host
1172    properties:
1173      host:
1174        type: string
1175        description: Database host
1176      port:
1177        type: integer
1178        default: 5432
1179"#,
1180        )
1181        .unwrap();
1182
1183        let markdown = schema.to_markdown();
1184        assert!(markdown.contains("database"));
1185        assert!(markdown.contains("host"));
1186        assert!(markdown.contains("5432"));
1187    }
1188
1189    #[test]
1190    fn test_schema_to_markdown_enum() {
1191        let schema = Schema::from_yaml(
1192            r#"
1193type: object
1194properties:
1195  log_level:
1196    type: string
1197    enum: [debug, info, warn, error]
1198"#,
1199        )
1200        .unwrap();
1201
1202        let markdown = schema.to_markdown();
1203        assert!(markdown.contains("enum:"));
1204    }
1205
1206    #[test]
1207    fn test_schema_to_markdown_constraints() {
1208        let schema = Schema::from_yaml(
1209            r#"
1210type: object
1211properties:
1212  name:
1213    type: string
1214    minLength: 1
1215    maxLength: 100
1216    pattern: "^[a-z]+$"
1217"#,
1218        )
1219        .unwrap();
1220
1221        let markdown = schema.to_markdown();
1222        assert!(markdown.contains("minLength"));
1223        assert!(markdown.contains("maxLength"));
1224        assert!(markdown.contains("pattern"));
1225    }
1226
1227    #[test]
1228    fn test_schema_to_template_basic() {
1229        let schema = Schema::from_yaml(
1230            r#"
1231title: Test Config
1232type: object
1233required:
1234  - name
1235properties:
1236  name:
1237    type: string
1238    description: The application name
1239  port:
1240    type: integer
1241    default: 8080
1242"#,
1243        )
1244        .unwrap();
1245
1246        let template = schema.to_template();
1247        assert!(template.contains("name:"));
1248        assert!(template.contains("port:"));
1249        assert!(template.contains("8080"));
1250        assert!(template.contains("REQUIRED"));
1251    }
1252
1253    #[test]
1254    fn test_schema_to_template_nested() {
1255        let schema = Schema::from_yaml(
1256            r#"
1257type: object
1258properties:
1259  database:
1260    type: object
1261    required:
1262      - host
1263    properties:
1264      host:
1265        type: string
1266        description: Database host
1267      port:
1268        type: integer
1269        default: 5432
1270"#,
1271        )
1272        .unwrap();
1273
1274        let template = schema.to_template();
1275        assert!(template.contains("database:"));
1276        assert!(template.contains("host:"));
1277        assert!(template.contains("port:"));
1278        assert!(template.contains("5432"));
1279    }
1280
1281    #[test]
1282    fn test_schema_to_template_types() {
1283        let schema = Schema::from_yaml(
1284            r#"
1285type: object
1286properties:
1287  string_field:
1288    type: string
1289  int_field:
1290    type: integer
1291  number_field:
1292    type: number
1293  bool_field:
1294    type: boolean
1295  array_field:
1296    type: array
1297  null_field:
1298    type: "null"
1299"#,
1300        )
1301        .unwrap();
1302
1303        let template = schema.to_template();
1304        assert!(template.contains("string_field: \"\""));
1305        assert!(template.contains("int_field: 0"));
1306        assert!(template.contains("number_field: 0.0"));
1307        assert!(template.contains("bool_field: false"));
1308        assert!(template.contains("array_field: []"));
1309        assert!(template.contains("null_field: null"));
1310    }
1311
1312    #[test]
1313    fn test_schema_to_template_enum() {
1314        let schema = Schema::from_yaml(
1315            r#"
1316type: object
1317properties:
1318  log_level:
1319    type: string
1320    enum: [debug, info, warn, error]
1321"#,
1322        )
1323        .unwrap();
1324
1325        let template = schema.to_template();
1326        // Should use first enum value as default
1327        assert!(template.contains("log_level: debug") || template.contains("log_level: \"debug\""));
1328    }
1329
1330    #[test]
1331    fn test_validation_error_display() {
1332        let err = ValidationError {
1333            path: "/database/port".to_string(),
1334            message: "expected integer".to_string(),
1335        };
1336        let display = format!("{}", err);
1337        assert_eq!(display, "/database/port: expected integer");
1338    }
1339
1340    #[test]
1341    fn test_validation_error_display_empty_path() {
1342        let err = ValidationError {
1343            path: "".to_string(),
1344            message: "missing required field".to_string(),
1345        };
1346        let display = format!("{}", err);
1347        assert_eq!(display, "missing required field");
1348    }
1349
1350    #[test]
1351    fn test_value_to_json_null() {
1352        let v = Value::Null;
1353        let json = value_to_json(&v);
1354        assert!(json.is_null());
1355    }
1356
1357    #[test]
1358    fn test_value_to_json_bool() {
1359        let v = Value::Bool(true);
1360        let json = value_to_json(&v);
1361        assert_eq!(json, serde_json::Value::Bool(true));
1362    }
1363
1364    #[test]
1365    fn test_value_to_json_integer() {
1366        let v = Value::Integer(42);
1367        let json = value_to_json(&v);
1368        assert_eq!(json, serde_json::json!(42));
1369    }
1370
1371    #[test]
1372    fn test_value_to_json_float() {
1373        let v = Value::Float(2.71);
1374        let json = value_to_json(&v);
1375        assert!(json.is_number());
1376    }
1377
1378    #[test]
1379    fn test_value_to_json_float_nan() {
1380        // NaN cannot be represented in JSON, should return null
1381        let v = Value::Float(f64::NAN);
1382        let json = value_to_json(&v);
1383        assert!(json.is_null());
1384    }
1385
1386    #[test]
1387    fn test_value_to_json_string() {
1388        let v = Value::String("hello".into());
1389        let json = value_to_json(&v);
1390        assert_eq!(json, serde_json::json!("hello"));
1391    }
1392
1393    #[test]
1394    fn test_value_to_json_bytes() {
1395        let v = Value::Bytes(vec![72, 101, 108, 108, 111]); // "Hello"
1396        let json = value_to_json(&v);
1397        // Should be base64 encoded
1398        assert!(json.is_string());
1399        assert_eq!(json.as_str().unwrap(), "SGVsbG8=");
1400    }
1401
1402    #[test]
1403    fn test_value_to_json_sequence() {
1404        let v = Value::Sequence(vec![Value::Integer(1), Value::Integer(2)]);
1405        let json = value_to_json(&v);
1406        assert!(json.is_array());
1407        assert_eq!(json, serde_json::json!([1, 2]));
1408    }
1409
1410    #[test]
1411    fn test_value_to_json_mapping() {
1412        let mut map = indexmap::IndexMap::new();
1413        map.insert("key".to_string(), Value::String("value".into()));
1414        let v = Value::Mapping(map);
1415        let json = value_to_json(&v);
1416        assert!(json.is_object());
1417        assert_eq!(json["key"], "value");
1418    }
1419
1420    #[test]
1421    fn test_format_json_value_null() {
1422        let v = serde_json::Value::Null;
1423        assert_eq!(format_json_value(&v), "null");
1424    }
1425
1426    #[test]
1427    fn test_format_json_value_bool() {
1428        assert_eq!(format_json_value(&serde_json::json!(true)), "true");
1429        assert_eq!(format_json_value(&serde_json::json!(false)), "false");
1430    }
1431
1432    #[test]
1433    fn test_format_json_value_number() {
1434        assert_eq!(format_json_value(&serde_json::json!(42)), "42");
1435        assert_eq!(format_json_value(&serde_json::json!(2.71)), "2.71");
1436    }
1437
1438    #[test]
1439    fn test_format_json_value_string_simple() {
1440        assert_eq!(format_json_value(&serde_json::json!("hello")), "hello");
1441    }
1442
1443    #[test]
1444    fn test_format_json_value_string_needs_quoting() {
1445        // Empty string needs quotes
1446        assert_eq!(format_json_value(&serde_json::json!("")), "\"\"");
1447        // Contains colon
1448        assert_eq!(
1449            format_json_value(&serde_json::json!("key:value")),
1450            "\"key:value\""
1451        );
1452        // Contains hash
1453        assert_eq!(
1454            format_json_value(&serde_json::json!("has#comment")),
1455            "\"has#comment\""
1456        );
1457        // Starts with space
1458        assert_eq!(
1459            format_json_value(&serde_json::json!(" leading")),
1460            "\" leading\""
1461        );
1462        // Ends with space
1463        assert_eq!(
1464            format_json_value(&serde_json::json!("trailing ")),
1465            "\"trailing \""
1466        );
1467    }
1468
1469    #[test]
1470    fn test_format_json_value_string_with_quotes_needing_escape() {
1471        // String that needs quoting AND contains quotes should escape them
1472        // Empty string triggers quoting, so let's test that
1473        let v = serde_json::Value::String("has:\"quotes\"".to_string());
1474        let formatted = format_json_value(&v);
1475        // The colon triggers quoting, and the quotes get escaped
1476        assert!(formatted.contains("\\\""));
1477        assert!(formatted.starts_with('"'));
1478    }
1479
1480    #[test]
1481    fn test_format_json_value_string_no_quoting_needed() {
1482        // String without special chars doesn't get quoted
1483        let v = serde_json::Value::String("has \"quotes\"".to_string());
1484        let formatted = format_json_value(&v);
1485        // No colon/hash/spaces so it's returned as-is without quoting
1486        assert_eq!(formatted, "has \"quotes\"");
1487    }
1488
1489    #[test]
1490    fn test_format_json_value_array_empty() {
1491        assert_eq!(format_json_value(&serde_json::json!([])), "[]");
1492    }
1493
1494    #[test]
1495    fn test_format_json_value_array_with_items() {
1496        assert_eq!(
1497            format_json_value(&serde_json::json!([1, 2, 3])),
1498            "[1, 2, 3]"
1499        );
1500    }
1501
1502    #[test]
1503    fn test_format_json_value_object() {
1504        assert_eq!(format_json_value(&serde_json::json!({})), "{}");
1505    }
1506
1507    #[test]
1508    fn test_get_type_string_basic() {
1509        let schema = serde_json::json!({"type": "string"});
1510        assert_eq!(get_type_string(&schema), "string");
1511    }
1512
1513    #[test]
1514    fn test_get_type_string_with_constraints() {
1515        let schema = serde_json::json!({
1516            "type": "integer",
1517            "minimum": 1,
1518            "maximum": 100
1519        });
1520        let type_str = get_type_string(&schema);
1521        assert!(type_str.contains("integer"));
1522        assert!(type_str.contains("min: 1"));
1523        assert!(type_str.contains("max: 100"));
1524    }
1525
1526    #[test]
1527    fn test_get_type_string_with_string_constraints() {
1528        let schema = serde_json::json!({
1529            "type": "string",
1530            "minLength": 1,
1531            "maxLength": 50,
1532            "pattern": "^[a-z]+$"
1533        });
1534        let type_str = get_type_string(&schema);
1535        assert!(type_str.contains("string"));
1536        assert!(type_str.contains("minLength: 1"));
1537        assert!(type_str.contains("maxLength: 50"));
1538        assert!(type_str.contains("pattern:"));
1539    }
1540
1541    #[test]
1542    fn test_get_type_string_enum() {
1543        let schema = serde_json::json!({
1544            "enum": ["a", "b", "c"]
1545        });
1546        let type_str = get_type_string(&schema);
1547        assert!(type_str.starts_with("enum:"));
1548        assert!(type_str.contains("\"a\""));
1549        assert!(type_str.contains("\"b\""));
1550    }
1551
1552    #[test]
1553    fn test_get_type_string_enum_numeric() {
1554        let schema = serde_json::json!({
1555            "enum": [1, 2, 3]
1556        });
1557        let type_str = get_type_string(&schema);
1558        assert!(type_str.contains("1"));
1559        assert!(type_str.contains("2"));
1560    }
1561
1562    #[test]
1563    fn test_get_type_string_no_type() {
1564        let schema = serde_json::json!({});
1565        assert_eq!(get_type_string(&schema), "any");
1566    }
1567
1568    #[test]
1569    fn test_schema_default_string_with_default() {
1570        let schema = serde_json::json!({"default": 42});
1571        assert_eq!(schema_default_string(&schema), "42");
1572    }
1573
1574    #[test]
1575    fn test_schema_default_string_with_string_default() {
1576        let schema = serde_json::json!({"default": "hello"});
1577        assert_eq!(schema_default_string(&schema), "\"hello\"");
1578    }
1579
1580    #[test]
1581    fn test_schema_default_string_no_default() {
1582        let schema = serde_json::json!({});
1583        assert_eq!(schema_default_string(&schema), "-");
1584    }
1585
1586    #[test]
1587    fn test_get_template_value_with_default() {
1588        let schema = serde_json::json!({"type": "string", "default": "myvalue"});
1589        assert_eq!(get_template_value(&schema, "string"), "myvalue");
1590    }
1591
1592    #[test]
1593    fn test_get_template_value_with_enum() {
1594        let schema = serde_json::json!({"type": "string", "enum": ["first", "second"]});
1595        assert_eq!(get_template_value(&schema, "string"), "first");
1596    }
1597
1598    #[test]
1599    fn test_get_template_value_placeholders() {
1600        assert_eq!(get_template_value(&serde_json::json!({}), "string"), "\"\"");
1601        assert_eq!(get_template_value(&serde_json::json!({}), "integer"), "0");
1602        assert_eq!(get_template_value(&serde_json::json!({}), "number"), "0.0");
1603        assert_eq!(
1604            get_template_value(&serde_json::json!({}), "boolean"),
1605            "false"
1606        );
1607        assert_eq!(get_template_value(&serde_json::json!({}), "array"), "[]");
1608        assert_eq!(get_template_value(&serde_json::json!({}), "null"), "null");
1609        assert_eq!(
1610            get_template_value(&serde_json::json!({}), "unknown"),
1611            "null"
1612        );
1613    }
1614
1615    #[test]
1616    fn test_schema_to_markdown_no_title() {
1617        // Schema without title should use default
1618        let schema = Schema::from_yaml(
1619            r#"
1620type: object
1621properties:
1622  name:
1623    type: string
1624"#,
1625        )
1626        .unwrap();
1627
1628        let markdown = schema.to_markdown();
1629        assert!(markdown.contains("# Configuration Reference"));
1630    }
1631
1632    #[test]
1633    fn test_schema_to_markdown_non_object_property() {
1634        // Top-level property that is not an object
1635        let schema = Schema::from_yaml(
1636            r#"
1637type: object
1638required:
1639  - port
1640properties:
1641  port:
1642    type: integer
1643    description: Server port
1644"#,
1645        )
1646        .unwrap();
1647
1648        let markdown = schema.to_markdown();
1649        assert!(markdown.contains("port"));
1650        assert!(markdown.contains("(required)"));
1651    }
1652
1653    #[test]
1654    fn test_schema_to_template_no_title() {
1655        let schema = Schema::from_yaml(
1656            r#"
1657type: object
1658properties:
1659  name:
1660    type: string
1661"#,
1662        )
1663        .unwrap();
1664
1665        let template = schema.to_template();
1666        assert!(template.contains("Configuration template generated from schema"));
1667    }
1668
1669    #[test]
1670    fn test_schema_to_template_with_description() {
1671        let schema = Schema::from_yaml(
1672            r#"
1673type: object
1674properties:
1675  name:
1676    type: string
1677    description: The name field
1678"#,
1679        )
1680        .unwrap();
1681
1682        let template = schema.to_template();
1683        assert!(template.contains("The name field"));
1684    }
1685
1686    #[test]
1687    fn test_schema_to_template_with_default_and_description() {
1688        let schema = Schema::from_yaml(
1689            r#"
1690type: object
1691properties:
1692  port:
1693    type: integer
1694    description: Server port
1695    default: 8080
1696"#,
1697        )
1698        .unwrap();
1699
1700        let template = schema.to_template();
1701        assert!(template.contains("8080"));
1702        assert!(template.contains("default:"));
1703    }
1704
1705    // Tests for get_default() and allows_null()
1706
1707    #[test]
1708    fn test_get_default_simple() {
1709        let schema = Schema::from_yaml(
1710            r#"
1711type: object
1712properties:
1713  pool_size:
1714    type: integer
1715    default: 10
1716  timeout:
1717    type: number
1718    default: 30.5
1719  enabled:
1720    type: boolean
1721    default: true
1722  name:
1723    type: string
1724    default: "default_name"
1725"#,
1726        )
1727        .unwrap();
1728
1729        assert_eq!(schema.get_default("pool_size"), Some(Value::Integer(10)));
1730        assert_eq!(schema.get_default("timeout"), Some(Value::Float(30.5)));
1731        assert_eq!(schema.get_default("enabled"), Some(Value::Bool(true)));
1732        assert_eq!(
1733            schema.get_default("name"),
1734            Some(Value::String("default_name".into()))
1735        );
1736        assert_eq!(schema.get_default("nonexistent"), None);
1737    }
1738
1739    #[test]
1740    fn test_get_default_nested() {
1741        let schema = Schema::from_yaml(
1742            r#"
1743type: object
1744properties:
1745  database:
1746    type: object
1747    properties:
1748      host:
1749        type: string
1750        default: localhost
1751      port:
1752        type: integer
1753        default: 5432
1754      pool:
1755        type: object
1756        properties:
1757          size:
1758            type: integer
1759            default: 10
1760"#,
1761        )
1762        .unwrap();
1763
1764        assert_eq!(
1765            schema.get_default("database.host"),
1766            Some(Value::String("localhost".into()))
1767        );
1768        assert_eq!(
1769            schema.get_default("database.port"),
1770            Some(Value::Integer(5432))
1771        );
1772        assert_eq!(
1773            schema.get_default("database.pool.size"),
1774            Some(Value::Integer(10))
1775        );
1776        assert_eq!(schema.get_default("database.nonexistent"), None);
1777    }
1778
1779    #[test]
1780    fn test_get_default_object_level() {
1781        let schema = Schema::from_yaml(
1782            r#"
1783type: object
1784properties:
1785  logging:
1786    type: object
1787    default:
1788      level: info
1789      format: json
1790"#,
1791        )
1792        .unwrap();
1793
1794        let default = schema.get_default("logging").unwrap();
1795        match default {
1796            Value::Mapping(map) => {
1797                assert_eq!(map.get("level"), Some(&Value::String("info".into())));
1798                assert_eq!(map.get("format"), Some(&Value::String("json".into())));
1799            }
1800            _ => panic!("Expected mapping default"),
1801        }
1802    }
1803
1804    #[test]
1805    fn test_get_default_null_default() {
1806        let schema = Schema::from_yaml(
1807            r#"
1808type: object
1809properties:
1810  optional_value:
1811    type:
1812      - string
1813      - "null"
1814    default: null
1815"#,
1816        )
1817        .unwrap();
1818
1819        assert_eq!(schema.get_default("optional_value"), Some(Value::Null));
1820    }
1821
1822    #[test]
1823    fn test_allows_null_single_type() {
1824        let schema = Schema::from_yaml(
1825            r#"
1826type: object
1827properties:
1828  required_string:
1829    type: string
1830  nullable_string:
1831    type: "null"
1832"#,
1833        )
1834        .unwrap();
1835
1836        assert!(!schema.allows_null("required_string"));
1837        assert!(schema.allows_null("nullable_string"));
1838    }
1839
1840    #[test]
1841    fn test_allows_null_array_type() {
1842        let schema = Schema::from_yaml(
1843            r#"
1844type: object
1845properties:
1846  nullable_value:
1847    type:
1848      - string
1849      - "null"
1850  non_nullable:
1851    type:
1852      - string
1853      - integer
1854"#,
1855        )
1856        .unwrap();
1857
1858        assert!(schema.allows_null("nullable_value"));
1859        assert!(!schema.allows_null("non_nullable"));
1860    }
1861
1862    #[test]
1863    fn test_allows_null_nested() {
1864        let schema = Schema::from_yaml(
1865            r#"
1866type: object
1867properties:
1868  database:
1869    type: object
1870    properties:
1871      connection_string:
1872        type:
1873          - string
1874          - "null"
1875        default: null
1876"#,
1877        )
1878        .unwrap();
1879
1880        assert!(schema.allows_null("database.connection_string"));
1881    }
1882
1883    #[test]
1884    fn test_allows_null_no_type_specified() {
1885        // When type is not specified, null is implicitly allowed
1886        let schema = Schema::from_yaml(
1887            r#"
1888type: object
1889properties:
1890  any_value: {}
1891"#,
1892        )
1893        .unwrap();
1894
1895        assert!(schema.allows_null("any_value"));
1896    }
1897}