Skip to main content

active_core/convert/
import.rs

1//! Import: JSON / YAML / TOML into Active source (C-046,
2//! spec/converters.md §4). Best-effort by design: every lossy corner
3//! carries a diagnostic, and the emitted source always parses.
4//! Redacted `"***SECRET***"` markers import as plain strings —
5//! exports are one-way, never resurrected into live secrets.
6
7use crate::diag::{Diagnostic, Severity};
8use serde_json::Value as JsonValue;
9use std::collections::HashSet;
10
11/// Active source plus import notes for JSON text. Syntax failures are
12/// hard errors (the source format itself is broken).
13pub fn json_text_to_active(text: &str) -> Result<(String, Vec<Diagnostic>), String> {
14    let value: JsonValue =
15        serde_json::from_str(text).map_err(|e| hard_error("invalid JSON", &e.to_string()))?;
16    Ok(json_value_to_active(&value))
17}
18
19/// Active source plus import notes for YAML text.
20pub fn yaml_text_to_active(text: &str) -> Result<(String, Vec<Diagnostic>), String> {
21    let yaml: serde_yaml::Value =
22        serde_yaml::from_str(text).map_err(|e| hard_error("invalid YAML", &e.to_string()))?;
23    let mut emitter = Emitter::new();
24    let value = emitter.yaml_to_json(&yaml, "");
25    let (source, mut diags) = emitter.finish(&value);
26    diags.sort_by(|a, b| a.code.cmp(&b.code));
27    Ok((source, diags))
28}
29
30/// Active source plus import notes for TOML text.
31pub fn toml_text_to_active(text: &str) -> Result<(String, Vec<Diagnostic>), String> {
32    let toml: toml::Value =
33        toml::from_str(text).map_err(|e| hard_error("invalid TOML", &e.to_string()))?;
34    let mut emitter = Emitter::new();
35    let value = emitter.toml_to_json(&toml, "");
36    let (source, mut diags) = emitter.finish(&value);
37    diags.sort_by(|a, b| a.code.cmp(&b.code));
38    Ok((source, diags))
39}
40
41/// Hard import failure without source excerpts: format parsers echo
42/// the offending line, which may contain a secret — keep the reason
43/// and location (first line), withhold the rest.
44fn hard_error(prefix: &str, detail: &str) -> String {
45    let first = detail.lines().next().unwrap_or("").trim();
46    format!("{prefix}: {first} (source excerpt withheld)")
47}
48
49/// Active source plus import notes for an already-parsed JSON value.
50/// Objects become nested brace blocks; anything else wraps as
51/// `value = …` with a W009 note. Always parses; lossy corners are
52/// diagnosed, never silent.
53pub fn json_value_to_active(v: &JsonValue) -> (String, Vec<Diagnostic>) {
54    Emitter::new().finish(v)
55}
56
57/// Shared emission state: import notes in document order.
58struct Emitter {
59    diags: Vec<Diagnostic>,
60}
61
62impl Emitter {
63    fn new() -> Self {
64        Self { diags: Vec::new() }
65    }
66
67    fn finish(mut self, v: &JsonValue) -> (String, Vec<Diagnostic>) {
68        let mut out = String::new();
69        match v {
70            JsonValue::Object(map) => {
71                self.emit_block(map, &mut out, 0, "");
72            }
73            other => {
74                self.note(
75                    "W009",
76                    format!("top-level {} wrapped as 'value'", Self::kind_name(other)),
77                );
78                let inline = self.inline(other);
79                out.push_str(&format!("value = {inline}\n"));
80            }
81        }
82        (out, self.diags)
83    }
84
85    fn kind_name(v: &JsonValue) -> &'static str {
86        match v {
87            JsonValue::Null => "null",
88            JsonValue::Bool(_) => "boolean",
89            JsonValue::Number(_) => "number",
90            JsonValue::String(_) => "string",
91            JsonValue::Array(_) => "array",
92            JsonValue::Object(_) => "object",
93        }
94    }
95
96    /// A document block: scalars/arrays as `key = …` lines, objects
97    /// as nested `key { … }` blocks. `path` is the dotted scope for
98    /// messages; duplicate sanitized keys are last-wins with a note.
99    fn emit_block(
100        &mut self,
101        map: &serde_json::Map<String, JsonValue>,
102        out: &mut String,
103        indent: usize,
104        path: &str,
105    ) {
106        let pad = "  ".repeat(indent);
107        let mut seen = HashSet::new();
108        for (key, val) in map {
109            let (clean, renamed) = self.block_key(key, val);
110            if renamed {
111                self.note("W007", format!("imported key '{key}' renamed to '{clean}'"));
112            }
113            if !seen.insert(clean.clone()) {
114                self.note(
115                    "W007",
116                    format!("duplicate key '{clean}' after sanitization; last wins"),
117                );
118            }
119            let child = if path.is_empty() {
120                clean.clone()
121            } else {
122                format!("{path}.{clean}")
123            };
124            match val {
125                JsonValue::Object(nested) => {
126                    out.push_str(&format!("{pad}{clean} {{\n"));
127                    self.emit_block(nested, out, indent + 1, &child);
128                    out.push_str(&format!("{pad}}}\n"));
129                }
130                _ => {
131                    let inline = self.inline(val);
132                    out.push_str(&format!("{pad}{clean} = {inline}\n"));
133                }
134            }
135        }
136    }
137
138    /// Cleaned key plus whether it changed: identifier sanitizing
139    /// first, then the contextual renames (`schema` objects and
140    /// `depends_on` arrays would be consumed as language metadata).
141    fn block_key(&self, key: &str, val: &JsonValue) -> (String, bool) {
142        let clean = sanitize_key(key);
143        if let Some(renamed) = contextual_rename(&clean, val) {
144            return (renamed, true);
145        }
146        let changed = clean != key;
147        (clean, changed)
148    }
149
150    /// Inline literal for any value. Objects nest inline as
151    /// `{ k = v, … }` (comma-separated, matching the dual-syntax
152    /// surface). Numbers prefer Int; u64 beyond i64::MAX falls back
153    /// to Float with a W010 note (precision documented in
154    /// spec/converters.md §4).
155    fn inline(&mut self, val: &JsonValue) -> String {
156        match val {
157            JsonValue::Null => "null".to_string(),
158            JsonValue::Bool(b) => b.to_string(),
159            JsonValue::Number(n) => {
160                if let Some(i) = n.as_i64() {
161                    i.to_string()
162                } else if let Some(u) = n.as_u64() {
163                    self.note(
164                        "W010",
165                        format!("integer {u} exceeds i64 range; emitted as float"),
166                    );
167                    // Display, never Debug: Debug switches to
168                    // scientific notation (unparseable as Active),
169                    // Display prints full digits; `.0` forces the
170                    // Float token (precision documented in
171                    // spec/converters.md §4).
172                    format!("{}.0", u as f64)
173                } else {
174                    // Display, never Debug: Debug switches to scientific
175                    // notation (`5e22`) for large/small magnitudes, which
176                    // Active cannot parse (grammar has no exponents) —
177                    // violating the always-parses contract. Display always
178                    // emits plain decimals and round-trips. A bare `300`
179                    // would lex as Int, so integers-valued floats keep
180                    // their `.0`; non-finite stays null (W010, §4).
181                    n.as_f64()
182                        .map(|f| {
183                            if !f.is_finite() {
184                                "null".to_string()
185                            } else {
186                                let s = format!("{f}");
187                                if s.contains('.') {
188                                    s
189                                } else {
190                                    format!("{s}.0")
191                                }
192                            }
193                        })
194                        .unwrap_or("null".to_string())
195                }
196            }
197            JsonValue::String(s) => Self::quote(s),
198            JsonValue::Array(items) => {
199                let parts: Vec<String> = items.iter().map(|item| self.inline(item)).collect();
200                format!("[{}]", parts.join(", "))
201            }
202            JsonValue::Object(map) => {
203                let mut parts = Vec::with_capacity(map.len());
204                let mut seen = HashSet::new();
205                for (key, item) in map {
206                    let clean = sanitize_key(key);
207                    let clean = contextual_rename(&clean, item).unwrap_or(clean);
208                    if clean != *key {
209                        self.note("W007", format!("imported key '{key}' renamed to '{clean}'"));
210                    }
211                    if !seen.insert(clean.clone()) {
212                        self.note(
213                            "W007",
214                            format!("duplicate key '{clean}' after sanitization; last wins"),
215                        );
216                    }
217                    let inline = self.inline(item);
218                    parts.push(format!("{clean} = {inline}"));
219                }
220                format!("{{ {} }}", parts.join(", "))
221            }
222        }
223    }
224
225    /// Double-quoted Active string with the language's escapes
226    /// (`\n \t \r \\ \" \0 \$`; anything else verbatim).
227    fn quote(s: &str) -> String {
228        let mut out = String::with_capacity(s.len() + 2);
229        out.push('"');
230        for c in s.chars() {
231            match c {
232                '\n' => out.push_str("\\n"),
233                '\t' => out.push_str("\\t"),
234                '\r' => out.push_str("\\r"),
235                '\\' => out.push_str("\\\\"),
236                '"' => out.push_str("\\\""),
237                '\0' => out.push_str("\\0"),
238                '$' => out.push_str("\\$"),
239                _ => out.push(c),
240            }
241        }
242        out.push('"');
243        out
244    }
245
246    fn note(&mut self, code: &str, message: String) {
247        self.diags.push(Self::note_static(code, message));
248    }
249
250    fn note_static(code: &str, message: String) -> Diagnostic {
251        Diagnostic {
252            severity: Severity::Warning,
253            code: code.into(),
254            message,
255            path: None,
256            span: None,
257            repair: None,
258            confidence: 1.0,
259            file: None,
260        }
261    }
262
263    /// YAML → JSON model: non-string keys stringify with a W007 note
264    /// each; timestamps already arrive as strings.
265    fn yaml_to_json(&mut self, v: &serde_yaml::Value, path: &str) -> JsonValue {
266        match v {
267            serde_yaml::Value::Null => JsonValue::Null,
268            serde_yaml::Value::Bool(b) => JsonValue::Bool(*b),
269            serde_yaml::Value::Number(n) => {
270                if let Some(i) = n.as_i64() {
271                    JsonValue::from(i)
272                } else if let Some(u) = n.as_u64() {
273                    JsonValue::from(u)
274                } else {
275                    match n.as_f64() {
276                        Some(f) if f.is_finite() => JsonValue::from(f),
277                        // serde_json maps NaN/inf to null silently;
278                        // announce the loss instead.
279                        _ => {
280                            self.note(
281                                "W010",
282                                format!(
283                                    "non-finite YAML number at '{}' converted to null",
284                                    display_path(path)
285                                ),
286                            );
287                            JsonValue::Null
288                        }
289                    }
290                }
291            }
292            serde_yaml::Value::String(s) => JsonValue::String(s.clone()),
293            serde_yaml::Value::Sequence(items) => JsonValue::Array(
294                items
295                    .iter()
296                    .enumerate()
297                    .map(|(i, item)| self.yaml_to_json(item, &format!("{path}[{i}]")))
298                    .collect(),
299            ),
300            serde_yaml::Value::Mapping(map) => {
301                let mut out = serde_json::Map::new();
302                for (key, val) in map {
303                    let key_str = match key {
304                        serde_yaml::Value::String(s) => s.clone(),
305                        serde_yaml::Value::Null => {
306                            self.note("W007", "YAML null key stringified to 'null'".to_string());
307                            "null".to_string()
308                        }
309                        other => {
310                            let rendered = match other {
311                                serde_yaml::Value::Bool(b) => b.to_string(),
312                                serde_yaml::Value::Number(n) => n.to_string(),
313                                _ => "key".to_string(),
314                            };
315                            self.note(
316                                "W007",
317                                format!("YAML non-string key stringified to '{rendered}'"),
318                            );
319                            rendered
320                        }
321                    };
322                    let child = if path.is_empty() {
323                        key_str.clone()
324                    } else {
325                        format!("{path}.{key_str}")
326                    };
327                    out.insert(key_str, self.yaml_to_json(val, &child));
328                }
329                JsonValue::Object(out)
330            }
331            serde_yaml::Value::Tagged(tagged) => self.yaml_to_json(&tagged.value, path),
332        }
333    }
334
335    /// TOML → JSON model: datetimes stringify with a W008 note each
336    /// (no Active equivalent).
337    fn toml_to_json(&mut self, v: &toml::Value, path: &str) -> JsonValue {
338        match v {
339            toml::Value::String(s) => JsonValue::String(s.clone()),
340            toml::Value::Integer(i) => JsonValue::from(*i),
341            toml::Value::Float(f) => {
342                if f.is_finite() {
343                    JsonValue::from(*f)
344                } else {
345                    self.note(
346                        "W010",
347                        format!(
348                            "non-finite TOML number at '{}' converted to null",
349                            display_path(path)
350                        ),
351                    );
352                    JsonValue::Null
353                }
354            }
355            toml::Value::Boolean(b) => JsonValue::Bool(*b),
356            toml::Value::Datetime(dt) => {
357                self.note(
358                    "W008",
359                    format!(
360                        "TOML datetime at '{}' imported as string",
361                        display_path(path)
362                    ),
363                );
364                JsonValue::String(dt.to_string())
365            }
366            toml::Value::Array(items) => JsonValue::Array(
367                items
368                    .iter()
369                    .enumerate()
370                    .map(|(i, item)| self.toml_to_json(item, &format!("{path}[{i}]")))
371                    .collect(),
372            ),
373            toml::Value::Table(map) => {
374                let mut out = serde_json::Map::new();
375                for (key, val) in map {
376                    let child = if path.is_empty() {
377                        key.clone()
378                    } else {
379                        format!("{path}.{key}")
380                    };
381                    out.insert(key.clone(), self.toml_to_json(val, &child));
382                }
383                JsonValue::Object(out)
384            }
385        }
386    }
387}
388
389/// Identifier-sanitize an imported key: `[A-Za-z0-9_]` kept (leading
390/// digit prefixed), anything else becomes `_`; empty becomes `key`;
391/// hard keyword spellings (which never parse as keys) gain a
392/// trailing underscore. Contextual words (`include`, `profile`,
393/// …) are left alone here — they parse as ordinary data in every
394/// shape except the two `contextual_rename` covers. Returns the
395/// clean name (equality with the input means untouched).
396fn sanitize_key(key: &str) -> String {
397    const RESERVED: &[&str] = &["let", "env", "secret", "true", "false", "null"];
398    let mut clean = String::with_capacity(key.len());
399    for c in key.chars() {
400        if c.is_ascii_alphanumeric() || c == '_' {
401            clean.push(c);
402        } else {
403            clean.push('_');
404        }
405    }
406    if clean.is_empty() {
407        return "key".to_string();
408    }
409    if clean.starts_with(|c: char| c.is_ascii_digit()) {
410        clean.insert(0, '_');
411    }
412    if RESERVED.contains(&clean.as_str()) {
413        clean.push('_');
414    }
415    clean
416}
417
418/// Contextual renames, applied after `sanitize_key` wherever a value
419/// is emitted: a `schema` object would be consumed as a schema
420/// declaration (E020) and a `depends_on` array as dependency
421/// metadata (vanishing from the value) — each gains a suffix exactly
422/// in those shapes. Every other shape under those names parses as
423/// ordinary data (probed: `include`, `as`, `private`, `profile`,
424/// scalars, and non-array `depends_on` need no renaming).
425fn contextual_rename(clean: &str, val: &JsonValue) -> Option<String> {
426    match (clean, val) {
427        ("schema", JsonValue::Object(_)) => Some("schema_".to_string()),
428        ("depends_on", JsonValue::Array(_)) => Some("depends_on_".to_string()),
429        _ => None,
430    }
431}
432
433fn display_path(path: &str) -> &str {
434    if path.is_empty() {
435        "(root)"
436    } else {
437        path
438    }
439}