Skip to main content

oxihuman_core/
config_schema.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed config schema with validation.
5
6#[allow(dead_code)]
7#[derive(Debug, Clone)]
8pub enum SchemaType {
9    Bool,
10    Int {
11        min: i64,
12        max: i64,
13    },
14    Float {
15        min: f64,
16        max: f64,
17    },
18    String {
19        max_len: usize,
20    },
21    Enum {
22        variants: Vec<std::string::String>,
23    },
24    Array {
25        item_type: Box<SchemaType>,
26        max_items: usize,
27    },
28}
29
30#[allow(dead_code)]
31#[derive(Debug, Clone)]
32pub struct SchemaField {
33    pub name: std::string::String,
34    pub schema_type: SchemaType,
35    pub required: bool,
36    pub default_value: Option<std::string::String>,
37    pub description: std::string::String,
38}
39
40#[allow(dead_code)]
41#[derive(Debug, Clone)]
42pub struct ConfigSchema {
43    pub name: std::string::String,
44    pub version: std::string::String,
45    pub fields: Vec<SchemaField>,
46}
47
48#[allow(dead_code)]
49#[derive(Debug, Clone)]
50pub struct ConfigValue {
51    pub fields: std::collections::HashMap<std::string::String, std::string::String>,
52}
53
54/// Create a new empty config schema.
55#[allow(dead_code)]
56pub fn new_config_schema(name: &str, version: &str) -> ConfigSchema {
57    ConfigSchema {
58        name: name.to_string(),
59        version: version.to_string(),
60        fields: Vec::new(),
61    }
62}
63
64/// Add a field definition to the schema.
65#[allow(dead_code)]
66pub fn add_field(schema: &mut ConfigSchema, field: SchemaField) {
67    schema.fields.push(field);
68}
69
70/// Validate a config value against the schema. Returns a list of error messages.
71#[allow(dead_code)]
72pub fn validate_value(schema: &ConfigSchema, value: &ConfigValue) -> Vec<std::string::String> {
73    let mut errors = Vec::new();
74
75    for field in &schema.fields {
76        match value.fields.get(&field.name) {
77            Some(v) => {
78                if let Some(err) = validate_field_value(field, v) {
79                    errors.push(err);
80                }
81            }
82            None => {
83                if field.required && field.default_value.is_none() {
84                    errors.push(format!("required field '{}' is missing", field.name));
85                }
86            }
87        }
88    }
89
90    errors
91}
92
93/// Validate a single field value string against its schema. Returns an error message or None.
94#[allow(dead_code)]
95pub fn validate_field_value(field: &SchemaField, value: &str) -> Option<std::string::String> {
96    match &field.schema_type {
97        SchemaType::Bool => {
98            if value != "true" && value != "false" {
99                return Some(format!(
100                    "field '{}': expected bool (true/false), got '{value}'",
101                    field.name
102                ));
103            }
104        }
105        SchemaType::Int { min, max } => match value.parse::<i64>() {
106            Ok(n) => {
107                if n < *min || n > *max {
108                    return Some(format!(
109                        "field '{}': int {n} out of range [{min}, {max}]",
110                        field.name
111                    ));
112                }
113            }
114            Err(_) => {
115                return Some(format!(
116                    "field '{}': cannot parse '{value}' as int",
117                    field.name
118                ));
119            }
120        },
121        SchemaType::Float { min, max } => match value.parse::<f64>() {
122            Ok(f) => {
123                if f < *min || f > *max {
124                    return Some(format!(
125                        "field '{}': float {f} out of range [{min}, {max}]",
126                        field.name
127                    ));
128                }
129            }
130            Err(_) => {
131                return Some(format!(
132                    "field '{}': cannot parse '{value}' as float",
133                    field.name
134                ));
135            }
136        },
137        SchemaType::String { max_len } => {
138            // Strip surrounding quotes if JSON-encoded
139            let s = strip_json_string(value);
140            if s.len() > *max_len {
141                return Some(format!(
142                    "field '{}': string length {} exceeds max {max_len}",
143                    field.name,
144                    s.len()
145                ));
146            }
147        }
148        SchemaType::Enum { variants } => {
149            let s = strip_json_string(value);
150            if !variants.iter().any(|v| v == &s) {
151                return Some(format!(
152                    "field '{}': '{}' is not a valid variant (expected one of: {})",
153                    field.name,
154                    s,
155                    variants.join(", ")
156                ));
157            }
158        }
159        SchemaType::Array {
160            item_type: _,
161            max_items,
162        } => {
163            // Simple heuristic: count commas + 1 as item estimate
164            let trimmed = value.trim();
165            if trimmed == "[]" {
166                // empty array is fine
167            } else if trimmed.starts_with('[') && trimmed.ends_with(']') {
168                let inner = &trimmed[1..trimmed.len() - 1];
169                let count = if inner.trim().is_empty() {
170                    0
171                } else {
172                    inner.split(',').count()
173                };
174                if count > *max_items {
175                    return Some(format!(
176                        "field '{}': array has {count} items, max is {max_items}",
177                        field.name
178                    ));
179                }
180            } else {
181                return Some(format!(
182                    "field '{}': expected JSON array, got '{value}'",
183                    field.name
184                ));
185            }
186        }
187    }
188    None
189}
190
191/// Fill in missing fields with their default values.
192#[allow(dead_code)]
193pub fn apply_defaults(schema: &ConfigSchema, value: &mut ConfigValue) {
194    for field in &schema.fields {
195        if !value.fields.contains_key(&field.name) {
196            if let Some(default) = &field.default_value {
197                value.fields.insert(field.name.clone(), default.clone());
198            }
199        }
200    }
201}
202
203/// Get a bool value from a ConfigValue.
204#[allow(dead_code)]
205pub fn config_value_get_bool(value: &ConfigValue, key: &str) -> Option<bool> {
206    value.fields.get(key).and_then(|v| match v.as_str() {
207        "true" => Some(true),
208        "false" => Some(false),
209        _ => None,
210    })
211}
212
213/// Get an integer value from a ConfigValue.
214#[allow(dead_code)]
215pub fn config_value_get_int(value: &ConfigValue, key: &str) -> Option<i64> {
216    value.fields.get(key)?.parse::<i64>().ok()
217}
218
219/// Get a float value from a ConfigValue.
220#[allow(dead_code)]
221pub fn config_value_get_float(value: &ConfigValue, key: &str) -> Option<f64> {
222    value.fields.get(key)?.parse::<f64>().ok()
223}
224
225/// Get a string value from a ConfigValue.
226#[allow(dead_code)]
227pub fn config_value_get_str<'a>(value: &'a ConfigValue, key: &str) -> Option<&'a str> {
228    value.fields.get(key).map(|s| s.as_str())
229}
230
231/// Serialize the schema to JSON.
232#[allow(dead_code)]
233pub fn schema_to_json(schema: &ConfigSchema) -> std::string::String {
234    let fields_json: Vec<std::string::String> = schema
235        .fields
236        .iter()
237        .map(|f| {
238            let type_str = schema_type_to_json(&f.schema_type);
239            let required = if f.required { "true" } else { "false" };
240            let default = match &f.default_value {
241                Some(d) => format!("{d:?}"),
242                None => "null".to_string(),
243            };
244            format!(
245                r#"{{"name":{:?},"type":{},"required":{},"default":{},"description":{:?}}}"#,
246                f.name, type_str, required, default, f.description
247            )
248        })
249        .collect();
250
251    format!(
252        r#"{{"name":{:?},"version":{:?},"fields":[{}]}}"#,
253        schema.name,
254        schema.version,
255        fields_json.join(",")
256    )
257}
258
259/// Create the default render config schema.
260#[allow(dead_code)]
261pub fn default_render_schema() -> ConfigSchema {
262    let mut schema = new_config_schema("render", "1.0");
263
264    add_field(
265        &mut schema,
266        SchemaField {
267            name: "width".to_string(),
268            schema_type: SchemaType::Int { min: 1, max: 16384 },
269            required: true,
270            default_value: Some("1920".to_string()),
271            description: "Output image width in pixels".to_string(),
272        },
273    );
274    add_field(
275        &mut schema,
276        SchemaField {
277            name: "height".to_string(),
278            schema_type: SchemaType::Int { min: 1, max: 16384 },
279            required: true,
280            default_value: Some("1080".to_string()),
281            description: "Output image height in pixels".to_string(),
282        },
283    );
284    add_field(
285        &mut schema,
286        SchemaField {
287            name: "quality".to_string(),
288            schema_type: SchemaType::Float { min: 0.0, max: 1.0 },
289            required: false,
290            default_value: Some("0.9".to_string()),
291            description: "Render quality 0..1".to_string(),
292        },
293    );
294    add_field(
295        &mut schema,
296        SchemaField {
297            name: "format".to_string(),
298            schema_type: SchemaType::Enum {
299                variants: vec!["png".to_string(), "jpg".to_string(), "webp".to_string()],
300            },
301            required: false,
302            default_value: Some("png".to_string()),
303            description: "Output image format".to_string(),
304        },
305    );
306    add_field(
307        &mut schema,
308        SchemaField {
309            name: "antialiasing".to_string(),
310            schema_type: SchemaType::Bool,
311            required: false,
312            default_value: Some("true".to_string()),
313            description: "Enable antialiasing".to_string(),
314        },
315    );
316    add_field(
317        &mut schema,
318        SchemaField {
319            name: "output_path".to_string(),
320            schema_type: SchemaType::String { max_len: 512 },
321            required: false,
322            default_value: Some("\"output.png\"".to_string()),
323            description: "Output file path".to_string(),
324        },
325    );
326
327    schema
328}
329
330/// Merge two configs; override_ values take priority over base.
331#[allow(dead_code)]
332pub fn merge_configs(base: &ConfigValue, override_: &ConfigValue) -> ConfigValue {
333    let mut merged = base.fields.clone();
334    for (k, v) in &override_.fields {
335        merged.insert(k.clone(), v.clone());
336    }
337    ConfigValue { fields: merged }
338}
339
340// Internal helper: strip JSON string quotes
341fn strip_json_string(s: &str) -> std::string::String {
342    let trimmed = s.trim();
343    if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
344        trimmed[1..trimmed.len() - 1].to_string()
345    } else {
346        trimmed.to_string()
347    }
348}
349
350fn schema_type_to_json(t: &SchemaType) -> std::string::String {
351    match t {
352        SchemaType::Bool => r#""bool""#.to_string(),
353        SchemaType::Int { min, max } => format!(r#"{{"int":{{"min":{min},"max":{max}}}}}"#),
354        SchemaType::Float { min, max } => format!(r#"{{"float":{{"min":{min},"max":{max}}}}}"#),
355        SchemaType::String { max_len } => format!(r#"{{"string":{{"max_len":{max_len}}}}}"#),
356        SchemaType::Enum { variants } => {
357            let vs: Vec<std::string::String> = variants.iter().map(|v| format!("{v:?}")).collect();
358            format!(r#"{{"enum":{{"variants":[{}]}}}}"#, vs.join(","))
359        }
360        SchemaType::Array {
361            item_type,
362            max_items,
363        } => {
364            format!(
365                r#"{{"array":{{"item_type":{},"max_items":{max_items}}}}}"#,
366                schema_type_to_json(item_type)
367            )
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn make_simple_schema() -> ConfigSchema {
377        let mut schema = new_config_schema("test", "1.0");
378        add_field(
379            &mut schema,
380            SchemaField {
381                name: "count".to_string(),
382                schema_type: SchemaType::Int { min: 0, max: 100 },
383                required: true,
384                default_value: None,
385                description: "A count".to_string(),
386            },
387        );
388        schema
389    }
390
391    fn make_value(pairs: &[(&str, &str)]) -> ConfigValue {
392        ConfigValue {
393            fields: pairs
394                .iter()
395                .map(|(k, v)| (k.to_string(), v.to_string()))
396                .collect(),
397        }
398    }
399
400    #[test]
401    fn test_new_config_schema() {
402        let schema = new_config_schema("test", "2.0");
403        assert_eq!(schema.name, "test");
404        assert_eq!(schema.version, "2.0");
405        assert!(schema.fields.is_empty());
406    }
407
408    #[test]
409    fn test_add_field() {
410        let mut schema = new_config_schema("s", "1");
411        add_field(
412            &mut schema,
413            SchemaField {
414                name: "x".to_string(),
415                schema_type: SchemaType::Bool,
416                required: false,
417                default_value: None,
418                description: "".to_string(),
419            },
420        );
421        assert_eq!(schema.fields.len(), 1);
422    }
423
424    #[test]
425    fn test_validate_valid_int() {
426        let schema = make_simple_schema();
427        let val = make_value(&[("count", "42")]);
428        let errs = validate_value(&schema, &val);
429        assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
430    }
431
432    #[test]
433    fn test_validate_int_out_of_range() {
434        let schema = make_simple_schema();
435        let val = make_value(&[("count", "200")]);
436        let errs = validate_value(&schema, &val);
437        assert!(!errs.is_empty());
438        assert!(errs[0].contains("out of range"));
439    }
440
441    #[test]
442    fn test_validate_required_missing() {
443        let schema = make_simple_schema();
444        let val = ConfigValue {
445            fields: std::collections::HashMap::new(),
446        };
447        let errs = validate_value(&schema, &val);
448        assert!(!errs.is_empty());
449        assert!(errs[0].contains("missing"));
450    }
451
452    #[test]
453    fn test_validate_bool_field() {
454        let mut schema = new_config_schema("s", "1");
455        add_field(
456            &mut schema,
457            SchemaField {
458                name: "flag".to_string(),
459                schema_type: SchemaType::Bool,
460                required: true,
461                default_value: None,
462                description: "".to_string(),
463            },
464        );
465        let valid = make_value(&[("flag", "true")]);
466        assert!(validate_value(&schema, &valid).is_empty());
467
468        let invalid = make_value(&[("flag", "yes")]);
469        assert!(!validate_value(&schema, &invalid).is_empty());
470    }
471
472    #[test]
473    fn test_validate_float_field() {
474        let mut schema = new_config_schema("s", "1");
475        add_field(
476            &mut schema,
477            SchemaField {
478                name: "ratio".to_string(),
479                schema_type: SchemaType::Float { min: 0.0, max: 1.0 },
480                required: true,
481                default_value: None,
482                description: "".to_string(),
483            },
484        );
485        let valid = make_value(&[("ratio", "0.5")]);
486        assert!(validate_value(&schema, &valid).is_empty());
487
488        let invalid = make_value(&[("ratio", "2.0")]);
489        assert!(!validate_value(&schema, &invalid).is_empty());
490    }
491
492    #[test]
493    fn test_validate_enum_field() {
494        let mut schema = new_config_schema("s", "1");
495        add_field(
496            &mut schema,
497            SchemaField {
498                name: "mode".to_string(),
499                schema_type: SchemaType::Enum {
500                    variants: vec!["a".to_string(), "b".to_string()],
501                },
502                required: true,
503                default_value: None,
504                description: "".to_string(),
505            },
506        );
507        let valid = make_value(&[("mode", "a")]);
508        assert!(validate_value(&schema, &valid).is_empty());
509
510        let invalid = make_value(&[("mode", "c")]);
511        assert!(!validate_value(&schema, &invalid).is_empty());
512    }
513
514    #[test]
515    fn test_apply_defaults() {
516        let mut schema = new_config_schema("s", "1");
517        add_field(
518            &mut schema,
519            SchemaField {
520                name: "x".to_string(),
521                schema_type: SchemaType::Int { min: 0, max: 100 },
522                required: false,
523                default_value: Some("42".to_string()),
524                description: "".to_string(),
525            },
526        );
527        let mut val = ConfigValue {
528            fields: std::collections::HashMap::new(),
529        };
530        apply_defaults(&schema, &mut val);
531        assert_eq!(val.fields.get("x").map(|s| s.as_str()), Some("42"));
532    }
533
534    #[test]
535    fn test_config_value_get_bool() {
536        let val = make_value(&[("flag", "true")]);
537        assert_eq!(config_value_get_bool(&val, "flag"), Some(true));
538        assert_eq!(config_value_get_bool(&val, "missing"), None);
539    }
540
541    #[test]
542    fn test_config_value_get_int() {
543        let val = make_value(&[("n", "77")]);
544        assert_eq!(config_value_get_int(&val, "n"), Some(77));
545    }
546
547    #[test]
548    fn test_config_value_get_float() {
549        let val = make_value(&[("f", "2.71")]);
550        let result = config_value_get_float(&val, "f").expect("should succeed");
551        assert!((result - 2.71).abs() < 1e-4);
552    }
553
554    #[test]
555    fn test_config_value_get_str() {
556        let val = make_value(&[("key", "hello")]);
557        assert_eq!(config_value_get_str(&val, "key"), Some("hello"));
558        assert_eq!(config_value_get_str(&val, "missing"), None);
559    }
560
561    #[test]
562    fn test_schema_to_json_contains_name() {
563        let schema = default_render_schema();
564        let json = schema_to_json(&schema);
565        assert!(json.contains("render"));
566        assert!(json.contains("width"));
567        assert!(json.contains("quality"));
568    }
569
570    #[test]
571    fn test_default_render_schema_validates() {
572        let schema = default_render_schema();
573        let val = make_value(&[("width", "1920"), ("height", "1080")]);
574        let errs = validate_value(&schema, &val);
575        assert!(errs.is_empty(), "errors: {errs:?}");
576    }
577
578    #[test]
579    fn test_merge_configs_override_wins() {
580        let base = make_value(&[("a", "1"), ("b", "2")]);
581        let over = make_value(&[("b", "99"), ("c", "3")]);
582        let merged = merge_configs(&base, &over);
583        assert_eq!(merged.fields.get("a").map(|s| s.as_str()), Some("1"));
584        assert_eq!(merged.fields.get("b").map(|s| s.as_str()), Some("99"));
585        assert_eq!(merged.fields.get("c").map(|s| s.as_str()), Some("3"));
586    }
587}