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    /// Quote for schema-emitted strings (patterns, defaults, enum members):
247    /// identical to `quote` except `$` escapes only before `{`. A bare `$`
248    /// is already literal in Active strings, and blanket `\$` would corrupt
249    /// regex anchors (`$`) and literal dollars in defaults (found by the
250    /// L-021 probe: `^[0-9]+$` arrived as `^[0-9]+\$`).
251    fn quote_schema(s: &str) -> String {
252        let mut out = String::with_capacity(s.len() + 2);
253        out.push('"');
254        let mut chars = s.chars().peekable();
255        while let Some(c) = chars.next() {
256            match c {
257                '\n' => out.push_str("\\n"),
258                '\t' => out.push_str("\\t"),
259                '\r' => out.push_str("\\r"),
260                '\\' => out.push_str("\\\\"),
261                '"' => out.push_str("\\\""),
262                '\0' => out.push_str("\\0"),
263                '$' if chars.peek() == Some(&'{') => out.push_str("\\$"),
264                _ => out.push(c),
265            }
266        }
267        out.push('"');
268        out
269    }
270
271    fn note(&mut self, code: &str, message: String) {
272        self.diags.push(Self::note_static(code, message));
273    }
274
275    fn note_static(code: &str, message: String) -> Diagnostic {
276        Diagnostic {
277            severity: Severity::Warning,
278            code: code.into(),
279            message,
280            path: None,
281            span: None,
282            repair: None,
283            confidence: 1.0,
284            file: None,
285        }
286    }
287
288    /// YAML → JSON model: non-string keys stringify with a W007 note
289    /// each; timestamps already arrive as strings.
290    fn yaml_to_json(&mut self, v: &serde_yaml::Value, path: &str) -> JsonValue {
291        match v {
292            serde_yaml::Value::Null => JsonValue::Null,
293            serde_yaml::Value::Bool(b) => JsonValue::Bool(*b),
294            serde_yaml::Value::Number(n) => {
295                if let Some(i) = n.as_i64() {
296                    JsonValue::from(i)
297                } else if let Some(u) = n.as_u64() {
298                    JsonValue::from(u)
299                } else {
300                    match n.as_f64() {
301                        Some(f) if f.is_finite() => JsonValue::from(f),
302                        // serde_json maps NaN/inf to null silently;
303                        // announce the loss instead.
304                        _ => {
305                            self.note(
306                                "W010",
307                                format!(
308                                    "non-finite YAML number at '{}' converted to null",
309                                    display_path(path)
310                                ),
311                            );
312                            JsonValue::Null
313                        }
314                    }
315                }
316            }
317            serde_yaml::Value::String(s) => JsonValue::String(s.clone()),
318            serde_yaml::Value::Sequence(items) => JsonValue::Array(
319                items
320                    .iter()
321                    .enumerate()
322                    .map(|(i, item)| self.yaml_to_json(item, &format!("{path}[{i}]")))
323                    .collect(),
324            ),
325            serde_yaml::Value::Mapping(map) => {
326                let mut out = serde_json::Map::new();
327                for (key, val) in map {
328                    let key_str = match key {
329                        serde_yaml::Value::String(s) => s.clone(),
330                        serde_yaml::Value::Null => {
331                            self.note("W007", "YAML null key stringified to 'null'".to_string());
332                            "null".to_string()
333                        }
334                        other => {
335                            let rendered = match other {
336                                serde_yaml::Value::Bool(b) => b.to_string(),
337                                serde_yaml::Value::Number(n) => n.to_string(),
338                                _ => "key".to_string(),
339                            };
340                            self.note(
341                                "W007",
342                                format!("YAML non-string key stringified to '{rendered}'"),
343                            );
344                            rendered
345                        }
346                    };
347                    let child = if path.is_empty() {
348                        key_str.clone()
349                    } else {
350                        format!("{path}.{key_str}")
351                    };
352                    out.insert(key_str, self.yaml_to_json(val, &child));
353                }
354                JsonValue::Object(out)
355            }
356            serde_yaml::Value::Tagged(tagged) => self.yaml_to_json(&tagged.value, path),
357        }
358    }
359
360    /// TOML → JSON model: datetimes stringify with a W008 note each
361    /// (no Active equivalent).
362    fn toml_to_json(&mut self, v: &toml::Value, path: &str) -> JsonValue {
363        match v {
364            toml::Value::String(s) => JsonValue::String(s.clone()),
365            toml::Value::Integer(i) => JsonValue::from(*i),
366            toml::Value::Float(f) => {
367                if f.is_finite() {
368                    JsonValue::from(*f)
369                } else {
370                    self.note(
371                        "W010",
372                        format!(
373                            "non-finite TOML number at '{}' converted to null",
374                            display_path(path)
375                        ),
376                    );
377                    JsonValue::Null
378                }
379            }
380            toml::Value::Boolean(b) => JsonValue::Bool(*b),
381            toml::Value::Datetime(dt) => {
382                self.note(
383                    "W008",
384                    format!(
385                        "TOML datetime at '{}' imported as string",
386                        display_path(path)
387                    ),
388                );
389                JsonValue::String(dt.to_string())
390            }
391            toml::Value::Array(items) => JsonValue::Array(
392                items
393                    .iter()
394                    .enumerate()
395                    .map(|(i, item)| self.toml_to_json(item, &format!("{path}[{i}]")))
396                    .collect(),
397            ),
398            toml::Value::Table(map) => {
399                let mut out = serde_json::Map::new();
400                for (key, val) in map {
401                    let child = if path.is_empty() {
402                        key.clone()
403                    } else {
404                        format!("{path}.{key}")
405                    };
406                    out.insert(key.clone(), self.toml_to_json(val, &child));
407                }
408                JsonValue::Object(out)
409            }
410        }
411    }
412}
413
414/// Identifier-sanitize an imported key: `[A-Za-z0-9_]` kept (leading
415/// digit prefixed), anything else becomes `_`; empty becomes `key`;
416/// hard keyword spellings (which never parse as keys) gain a
417/// trailing underscore. Contextual words (`include`, `profile`,
418/// …) are left alone here — they parse as ordinary data in every
419/// shape except the two `contextual_rename` covers. Returns the
420/// clean name (equality with the input means untouched).
421fn sanitize_key(key: &str) -> String {
422    const RESERVED: &[&str] = &["let", "env", "secret", "true", "false", "null"];
423    let mut clean = String::with_capacity(key.len());
424    for c in key.chars() {
425        if c.is_ascii_alphanumeric() || c == '_' {
426            clean.push(c);
427        } else {
428            clean.push('_');
429        }
430    }
431    if clean.is_empty() {
432        return "key".to_string();
433    }
434    if clean.starts_with(|c: char| c.is_ascii_digit()) {
435        clean.insert(0, '_');
436    }
437    if RESERVED.contains(&clean.as_str()) {
438        clean.push('_');
439    }
440    clean
441}
442
443/// Contextual renames, applied after `sanitize_key` wherever a value
444/// is emitted: a `schema` object would be consumed as a schema
445/// declaration (E020) and a `depends_on` array as dependency
446/// metadata (vanishing from the value) — each gains a suffix exactly
447/// in those shapes. Every other shape under those names parses as
448/// ordinary data (probed: `include`, `as`, `private`, `profile`,
449/// scalars, and non-array `depends_on` need no renaming).
450fn contextual_rename(clean: &str, val: &JsonValue) -> Option<String> {
451    match (clean, val) {
452        ("schema", JsonValue::Object(_)) => Some("schema_".to_string()),
453        ("depends_on", JsonValue::Array(_)) => Some("depends_on_".to_string()),
454        _ => None,
455    }
456}
457
458fn display_path(path: &str) -> &str {
459    if path.is_empty() {
460        "(root)"
461    } else {
462        path
463    }
464}
465
466// ------------------------------------------------------------------
467// JSON Schema to Active schema blocks (L-021, spec/converters.md §4).
468// Best-effort onboarding: every unmappable construct carries a W012
469// diagnostic, and the emitted source always parses. Optional properties
470// without defaults tighten to required (loud, never silent); combinators,
471// references, and conditionals have no Active counterpart and are skipped
472// with notes. `true` accepts everything; `false` has no counterpart and
473// is refused loudly.
474// ------------------------------------------------------------------
475
476/// Active source (a `schema` block) plus import notes for a JSON Schema
477/// document. Syntax failures are hard errors; semantic gaps are W012.
478pub fn jsonschema_to_active(text: &str) -> Result<(String, Vec<Diagnostic>), String> {
479    let schema: JsonValue = serde_json::from_str(text)
480        .map_err(|e| hard_error("invalid JSON Schema", &e.to_string()))?;
481    let mut conv = SchemaConverter::new();
482    let out = conv.convert_root(&schema);
483    conv.diags.sort_by(|a, b| a.code.cmp(&b.code));
484    Ok((out, conv.diags))
485}
486
487struct SchemaConverter {
488    diags: Vec<Diagnostic>,
489}
490
491impl SchemaConverter {
492    fn new() -> Self {
493        Self { diags: Vec::new() }
494    }
495
496    fn note(&mut self, message: String) {
497        self.note_code("W012", message);
498    }
499
500    fn note_code(&mut self, code: &str, message: String) {
501        self.diags.push(Diagnostic {
502            severity: Severity::Warning,
503            code: code.into(),
504            message,
505            path: None,
506            span: None,
507            repair: None,
508            confidence: 1.0,
509            file: None,
510        });
511    }
512
513    fn convert_root(&mut self, schema: &JsonValue) -> String {
514        let mut out = String::from("# Imported from JSON Schema (L-021): constructs without an Active counterpart, if any, are reported as W012 notes.\n");
515        match schema {
516            JsonValue::Object(_) => {
517                out.push_str("schema = ");
518                out.push_str(&self.convert_root_object(schema));
519                out.push('\n');
520            }
521            JsonValue::Bool(true) => {
522                out.push_str("schema = {}\n");
523            }
524            JsonValue::Bool(false) => {
525                self.note("boolean schema `false` (accept-nothing) has no Active counterpart; emitted open schema".to_string());
526                out.push_str("schema = {}\n");
527            }
528            other => {
529                self.note(format!(
530                    "top-level schema must be an object or boolean, got {}; emitted open schema",
531                    kind_word(other)
532                ));
533                out.push_str("schema = {}\n");
534            }
535        }
536        out
537    }
538
539    /// Top-level object schema emits the schema BLOCK form (`schema = {
540    /// ... }`); a `Table {...}` type constructor is only valid nested
541    /// inside a block (probed: top-level `Table` parses as an inline-table
542    /// value, not a type). Nested objects keep the `Table` form.
543    fn convert_root_object(&mut self, schema: &JsonValue) -> String {
544        let Some(obj) = schema.as_object() else {
545            return "Table".to_string();
546        };
547        let props = obj.get("properties").and_then(|v| v.as_object());
548        let required: Vec<&str> = obj
549            .get("required")
550            .and_then(|v| v.as_array())
551            .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
552            .unwrap_or_default();
553        // Loud skips for top-level combinators/refs, same list as nodes.
554        for key in [
555            "allOf",
556            "anyOf",
557            "oneOf",
558            "not",
559            "$ref",
560            "$defs",
561            "definitions",
562            "$anchor",
563            "$dynamicRef",
564            "$dynamicAnchor",
565            "if",
566            "then",
567            "else",
568            "dependentRequired",
569            "dependentSchemas",
570            "contains",
571            "prefixItems",
572            "unevaluatedProperties",
573            "unevaluatedItems",
574            "contentMediaType",
575            "contentEncoding",
576            "multipleOf",
577            "const",
578        ] {
579            if obj.contains_key(key) {
580                self.note(format!(
581                    "JSON Schema '{key}' at '(root)' has no Active counterpart; skipped"
582                ));
583            }
584        }
585        let empty = empty_map();
586        let mut fields = self.object_fields(obj, props.unwrap_or(&empty), &required, "");
587        if obj.get("additionalProperties").and_then(|v| v.as_bool()) == Some(false) {
588            if !fields.is_empty() {
589                fields.push_str(", ");
590            }
591            fields.push_str("closed = true");
592        }
593        format!("{{ {fields} }}")
594    }
595
596    fn field_name(&mut self, key: &str) -> String {
597        let clean = sanitize_key(key);
598        if clean != key {
599            // Same code and message shape as value imports (W007): the
600            // sanitizer is one rule everywhere.
601            self.note_code("W007", format!("imported key '{key}' renamed to '{clean}'"));
602        }
603        clean
604    }
605
606    fn convert_node(&mut self, schema: &JsonValue, path: &str) -> String {
607        let obj = match schema.as_object() {
608            Some(o) => o,
609            None => return "Any".to_string(),
610        };
611        // Loud skips first: combinators, references, conditionals.
612        for key in [
613            "allOf",
614            "anyOf",
615            "oneOf",
616            "not",
617            "$ref",
618            "$defs",
619            "definitions",
620            "$anchor",
621            "$dynamicRef",
622            "$dynamicAnchor",
623            "if",
624            "then",
625            "else",
626            "dependentRequired",
627            "dependentSchemas",
628            "contains",
629            "prefixItems",
630            "unevaluatedProperties",
631            "unevaluatedItems",
632            "contentMediaType",
633            "contentEncoding",
634        ] {
635            if obj.contains_key(key) {
636                self.note(format!(
637                    "JSON Schema '{key}' at '{}' has no Active counterpart; skipped",
638                    display_path(path)
639                ));
640            }
641        }
642        // `title`/`description` are documentation metadata, not constraints:
643        // nothing to emit, nothing lost.
644        let props = obj.get("properties").and_then(|v| v.as_object());
645        let required: Vec<&str> = obj
646            .get("required")
647            .and_then(|v| v.as_array())
648            .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
649            .unwrap_or_default();
650        let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
651        // Properties win over a contradictory scalar type (JSON Schema
652        // applies them to objects; flag the contradiction loudly).
653        if props.is_some() && !matches!(typ, "" | "object") {
654            self.note(format!(
655                "properties at '{}' contradict type '{typ}'; properties win",
656                display_path(path)
657            ));
658        }
659        match typ {
660            "object" => self.convert_object(obj, props.unwrap_or(&empty_map()), &required, path),
661            "" if props.is_some() => {
662                self.convert_object(obj, props.unwrap_or(&empty_map()), &required, path)
663            }
664            // `{}` or metadata-only: open `Any` accepts everything, which
665            // is what an unconstrained schema means.
666            "" => "Any".to_string(),
667            "array" => self.convert_array(obj, path),
668            "string" => self.convert_string(obj, path),
669            "integer" => self.convert_int(obj, path),
670            "number" => self.convert_number(obj, path),
671            "boolean" => "Bool".to_string(),
672            "null" => "Null".to_string(),
673            other => {
674                self.note(format!(
675                    "unknown type '{other}' at '{}'; emitted Any",
676                    display_path(path)
677                ));
678                "Any".to_string()
679            }
680        }
681    }
682
683    fn convert_object(
684        &mut self,
685        obj: &serde_json::Map<String, JsonValue>,
686        props: &serde_json::Map<String, JsonValue>,
687        required: &[&str],
688        path: &str,
689    ) -> String {
690        let fields = self.object_fields(obj, props, required, path);
691        let closed = obj.get("additionalProperties").and_then(|v| v.as_bool()) == Some(false);
692        let mut out = String::from("Table { ");
693        out.push_str(&fields);
694        if closed {
695            if !fields.is_empty() {
696                out.push_str(", ");
697            }
698            out.push_str("closed = true");
699        }
700        out.push_str(" }");
701        out
702    }
703
704    fn object_fields(
705        &mut self,
706        _obj: &serde_json::Map<String, JsonValue>,
707        props: &serde_json::Map<String, JsonValue>,
708        required: &[&str],
709        path: &str,
710    ) -> String {
711        let mut parts = Vec::new();
712        for (key, subschema) in props {
713            let name = self.field_name(key);
714            let child = if path.is_empty() {
715                name.clone()
716            } else {
717                format!("{path}.{name}")
718            };
719            let rendered = self.convert_node(subschema, &child);
720            let is_required = required.contains(&key.as_str());
721            let has_default = subschema
722                .as_object()
723                .is_some_and(|o| o.contains_key("default"));
724            if !is_required && !has_default {
725                self.note(format!(
726                    "optional property '{child}' without default tightens to required (Active has no optional-without-default)"
727                ));
728            }
729            // A property literally named `schema` holding a table would be
730            // consumed as a schema declaration: suffix exactly then.
731            let name = if name == "schema" && rendered.starts_with("Table") {
732                self.note("property 'schema' holding a table renamed to 'schema_' (would consume as declaration)".to_string());
733                "schema_".to_string()
734            } else {
735                name
736            };
737            parts.push(format!("{name} = {rendered}"));
738        }
739        // Defaults ride on the type args via convert_* below (they read
740        // `default` from their own node); nothing extra to emit here.
741        parts.join(", ")
742    }
743
744    fn default_arg(&self, obj: &serde_json::Map<String, JsonValue>) -> Option<String> {
745        match obj.get("default") {
746            Some(JsonValue::String(s)) => Some(format!("default = {}", Emitter::quote_schema(s))),
747            Some(JsonValue::Bool(b)) => Some(format!("default = {b}")),
748            Some(JsonValue::Number(n)) => {
749                if let Some(i) = n.as_i64() {
750                    Some(format!("default = {i}"))
751                } else {
752                    n.as_f64().map(|f| format!("default = {f}"))
753                }
754            }
755            _ => None,
756        }
757    }
758
759    fn convert_string(&mut self, obj: &serde_json::Map<String, JsonValue>, path: &str) -> String {
760        let mut args = Vec::new();
761        if let Some(n) = obj.get("minLength").and_then(|v| v.as_u64()) {
762            args.push(format!("min_len = {n}"));
763        }
764        if let Some(n) = obj.get("maxLength").and_then(|v| v.as_u64()) {
765            args.push(format!("max_len = {n}"));
766        }
767        if let Some(p) = obj.get("pattern").and_then(|v| v.as_str()) {
768            args.push(format!("pattern = {}", Emitter::quote_schema(p)));
769        }
770        if let Some(members) = obj.get("enum").and_then(|v| v.as_array()) {
771            let mut strs = Vec::new();
772            let mut ok = true;
773            for m in members {
774                match m.as_str() {
775                    Some(s) => strs.push(Emitter::quote_schema(s)),
776                    None => {
777                        ok = false;
778                    }
779                }
780            }
781            if ok && !strs.is_empty() {
782                if obj.contains_key("default") {
783                    // Enum takes members only: a default has nowhere to
784                    // attach, so it drops loudly instead of smuggling into
785                    // the member list (which E020 rejects).
786                    self.note(format!(
787                        "default at '{}' dropped: Enum members must be bare string literals",
788                        display_path(path)
789                    ));
790                }
791                return format!("Enum({})", strs.join(", "));
792            }
793            self.note(format!(
794                "non-string enum at '{}' has no Active counterpart; emitted String",
795                display_path(path)
796            ));
797        }
798        if obj.contains_key("const") {
799            self.note(format!(
800                "const at '{}' has no Active counterpart; emitted String",
801                display_path(path)
802            ));
803        }
804        let mut all = args;
805        all.extend(self.default_arg(obj));
806        if all.is_empty() {
807            "String".to_string()
808        } else {
809            format!("String({})", all.join(", "))
810        }
811    }
812
813    fn int_bound(
814        &mut self,
815        obj: &serde_json::Map<String, JsonValue>,
816        path: &str,
817        key: &str,
818        out_key: &str,
819    ) -> Option<String> {
820        if let Some(n) = obj.get(key).and_then(|v| v.as_i64()) {
821            return Some(format!("{out_key} = {n}"));
822        }
823        if obj.contains_key(key) {
824            self.note(format!(
825                "non-integer '{key}' at '{}' skipped (only integer bounds map)",
826                display_path(path)
827            ));
828        }
829        None
830    }
831
832    fn convert_int(&mut self, obj: &serde_json::Map<String, JsonValue>, path: &str) -> String {
833        let mut args = Vec::new();
834        if let Some(b) = self.int_bound(obj, path, "minimum", "min") {
835            args.push(b);
836        }
837        if let Some(b) = self.int_bound(obj, path, "maximum", "max") {
838            args.push(b);
839        }
840        for key in ["exclusiveMinimum", "exclusiveMaximum"] {
841            if obj.contains_key(key) {
842                self.note(format!(
843                    "'{key}' at '{}' imported as inclusive bound (direction loosened, announced)",
844                    display_path(path)
845                ));
846                if let Some(n) = obj.get(key).and_then(|v| v.as_i64()) {
847                    args.push(format!(
848                        "{} = {n}",
849                        if key == "exclusiveMinimum" {
850                            "min"
851                        } else {
852                            "max"
853                        }
854                    ));
855                }
856            }
857        }
858        if obj.contains_key("multipleOf") {
859            self.note(format!(
860                "'multipleOf' at '{}' has no Active counterpart; skipped",
861                display_path(path)
862            ));
863        }
864        if obj.contains_key("const") {
865            self.note(format!(
866                "const at '{}' has no Active counterpart; emitted Int",
867                display_path(path)
868            ));
869        }
870        let mut all = args;
871        all.extend(self.default_arg(obj));
872        if all.is_empty() {
873            "Int".to_string()
874        } else {
875            format!("Int({})", all.join(", "))
876        }
877    }
878
879    fn convert_number(&mut self, obj: &serde_json::Map<String, JsonValue>, path: &str) -> String {
880        let mut args = Vec::new();
881        for (key, out_key) in [("minimum", "min"), ("maximum", "max")] {
882            if let Some(n) = obj.get(key).and_then(|v| v.as_f64()) {
883                args.push(format!("{out_key} = {n}"));
884            }
885        }
886        for key in ["exclusiveMinimum", "exclusiveMaximum"] {
887            if obj.contains_key(key) {
888                self.note(format!(
889                    "'{key}' at '{}' imported as inclusive bound (direction loosened, announced)",
890                    display_path(path)
891                ));
892                if let Some(n) = obj.get(key).and_then(|v| v.as_f64()) {
893                    args.push(format!(
894                        "{} = {n}",
895                        if key == "exclusiveMinimum" {
896                            "min"
897                        } else {
898                            "max"
899                        }
900                    ));
901                }
902            }
903        }
904        if obj.contains_key("multipleOf") {
905            self.note(format!(
906                "'multipleOf' at '{}' has no Active counterpart; skipped",
907                display_path(path)
908            ));
909        }
910        let mut all = args;
911        all.extend(self.default_arg(obj));
912        if all.is_empty() {
913            "Float".to_string()
914        } else {
915            format!("Float({})", all.join(", "))
916        }
917    }
918
919    fn convert_array(&mut self, obj: &serde_json::Map<String, JsonValue>, path: &str) -> String {
920        for key in ["minItems", "maxItems"] {
921            if obj.contains_key(key) {
922                self.note(format!(
923                    "'{key}' at '{}' has no Active counterpart; skipped",
924                    display_path(path)
925                ));
926            }
927        }
928        match obj.get("items") {
929            Some(items) => format!("Array<{}>", self.convert_node(items, &format!("{path}[]"))),
930            None => "Array<Any>".to_string(),
931        }
932    }
933}
934
935fn kind_word(v: &JsonValue) -> &'static str {
936    match v {
937        JsonValue::Null => "null",
938        JsonValue::Bool(_) => "boolean",
939        JsonValue::Number(_) => "number",
940        JsonValue::String(_) => "string",
941        JsonValue::Array(_) => "array",
942        JsonValue::Object(_) => "object",
943    }
944}
945
946fn empty_map() -> serde_json::Map<String, JsonValue> {
947    serde_json::Map::new()
948}