Skip to main content

active_core/convert/
export.rs

1//! Export: Active value trees to YAML and TOML (C-046,
2//! spec/converters.md). JSON export lives in the parent module and is
3//! the canonical mapping both formats derive from, so all three
4//! agree on shapes and on secret redaction (`***SECRET***`).
5
6use crate::diag::{Diagnostic, Severity};
7use crate::ir::Value;
8
9/// Render the evaluated tree as YAML 1.2 (block style) through the
10/// canonical JSON model: one mapping, two syntaxes.
11pub fn value_to_yaml_string(v: &Value) -> Result<String, String> {
12    serde_yaml::to_string(&super::value_to_json(v))
13        .map_err(|e| format!("YAML serialization failed: {e}"))
14}
15
16/// Render the evaluated tree as a TOML document, with export notes
17/// for lossy corners (spec/converters.md §3). Returns the document
18/// plus diagnostics (currently only W006 null omissions), or a hard
19/// error for shapes TOML cannot hold at all (mixed-type arrays —
20/// announced with a path, never silently dropped).
21pub fn value_to_toml_string(v: &Value) -> Result<(String, Vec<Diagnostic>), String> {
22    check_toml_homogeneous(v, "")?;
23    let mut diags = Vec::new();
24    let doc = match build_toml_value(v, "", &mut diags) {
25        // The common case: a table at the root.
26        Some(toml::Value::Table(map)) => toml::Value::Table(map),
27        // Defensive: a scalar/array root wraps as `value = …`
28        // (TOML documents are key-value sequences).
29        Some(other) => {
30            let mut map = toml::map::Map::new();
31            map.insert("value".to_string(), other);
32            toml::Value::Table(map)
33        }
34        None => toml::Value::Table(toml::map::Map::new()),
35    };
36    toml::to_string(&doc).map(|s| (s, diags)).map_err(|e| {
37        // Unreachable after the homogeneity check, but never silent.
38        format!("TOML serialization failed: {e}")
39    })
40}
41
42/// TOML arrays hold one type: reject mixed shapes up front with
43/// their path (Nulls omit, so they never count). Durations,
44/// quantities, secrets, and strings all map to TOML strings.
45fn check_toml_homogeneous(v: &Value, path: &str) -> Result<(), String> {
46    let label = if path.is_empty() { "(root)" } else { path };
47    match v {
48        Value::Array(items) => {
49            let mut kinds = std::collections::HashSet::new();
50            for (i, item) in items.iter().enumerate() {
51                let item_path = format!("{path}[{i}]");
52                check_toml_homogeneous(item, &item_path)?;
53                if !matches!(item, Value::Null) {
54                    kinds.insert(toml_kind(item));
55                }
56            }
57            if kinds.len() > 1 {
58                return Err(format!(
59                    "mixed-type array at '{label}' cannot be represented in TOML"
60                ));
61            }
62            Ok(())
63        }
64        Value::Table(map) => {
65            for (key, val) in map {
66                let child = if path.is_empty() {
67                    key.clone()
68                } else {
69                    format!("{path}.{key}")
70                };
71                check_toml_homogeneous(val, &child)?;
72            }
73            Ok(())
74        }
75        _ => Ok(()),
76    }
77}
78
79fn toml_kind(v: &Value) -> u8 {
80    match v {
81        Value::Null => 0,
82        Value::Bool(_) => 1,
83        Value::Int(_) => 2,
84        Value::Float(_) => 3,
85        Value::String(_) | Value::DurationMs(_) | Value::Quantity(_, _) | Value::Secret(_) => 4,
86        Value::Array(_) => 5,
87        Value::Table(_) => 6,
88    }
89}
90
91/// TOML node for a value, or None for omitted Nulls (with a W006
92/// record each — announced, never silent).
93fn build_toml_value(v: &Value, path: &str, diags: &mut Vec<Diagnostic>) -> Option<toml::Value> {
94    let at = |key: &str| {
95        if path.is_empty() {
96            key.to_string()
97        } else {
98            format!("{path}.{key}")
99        }
100    };
101    match v {
102        // TOML has no null: omit the key and say so.
103        Value::Null => {
104            diags.push(null_omitted(if path.is_empty() { "(root)" } else { path }));
105            None
106        }
107        Value::Bool(b) => Some(toml::Value::Boolean(*b)),
108        Value::Int(i) => Some(toml::Value::Integer(*i)),
109        Value::Float(f) => Some(toml::Value::Float(*f)),
110        Value::String(s) => Some(toml::Value::String(s.clone())),
111        // TOML has no duration/quantity types: explicit normalized
112        // magnitude strings (the original unit is already lost at
113        // evaluation — spec/converters.md §3).
114        Value::DurationMs(ms) => Some(toml::Value::String(format!("{ms}ms"))),
115        Value::Quantity(n, unit) => Some(toml::Value::String(unit.format_amount(*n))),
116        Value::Secret(_) => Some(toml::Value::String("***SECRET***".to_string())),
117        Value::Array(items) => {
118            let mut out = Vec::with_capacity(items.len());
119            for (i, item) in items.iter().enumerate() {
120                let item_path = format!("{path}[{i}]");
121                if let Some(toml_item) = build_toml_value(item, &item_path, diags) {
122                    out.push(toml_item);
123                }
124            }
125            Some(toml::Value::Array(out))
126        }
127        Value::Table(map) => {
128            let mut out = toml::map::Map::with_capacity(map.len());
129            for (key, val) in map {
130                if let Some(toml_val) = build_toml_value(val, &at(key), diags) {
131                    out.insert(key.clone(), toml_val);
132                }
133            }
134            Some(toml::Value::Table(out))
135        }
136    }
137}
138
139fn null_omitted(path: &str) -> Diagnostic {
140    Diagnostic {
141        severity: Severity::Warning,
142        code: "W006".to_string(),
143        message: format!("null has no TOML representation; omitted '{path}'"),
144        path: Some(path.to_string()),
145        span: None,
146        repair: None,
147        confidence: 1.0,
148        file: None,
149    }
150}