Skip to main content

rto_graph/
config_keys.rs

1//! Config-file → flat config-key parsing (ADR-0009).
2//!
3//! A deployment/config repo is mostly key/value files. This module flattens
4//! TOML, JSON, `.env`, and **YAML** into dotted **leaf keys**, used two ways: the
5//! extraction pipeline turns them into `config_key` graph nodes (so config keys
6//! are queryable and visible in the graph), and `roteiro links --infer` matches
7//! them across repos. One parser, so the graph and the matcher never disagree.
8//!
9//! YAML gets special handling because a Kubernetes spoke repo is mostly YAML: a
10//! **k8s manifest** (a document with `apiVersion` + `kind`) is *not* flattened
11//! wholesale — that would bury real config under `apiVersion`/`metadata` noise —
12//! but mined for the settings a deployment actually overrides: ConfigMap/Secret
13//! `data`, and each container's `image` and literal `env` vars (Secret values
14//! and secret-looking keys redacted). Any other YAML — a Helm `values.yaml`, a
15//! kustomization, a plain config — is flattened like TOML/JSON.
16//!
17//! Deterministic — TOML/JSON/YAML object iteration is sorted (the caller sorts
18//! emitted nodes); `.env` preserves file order. Parsers (`toml`, `serde_json`,
19//! `yaml-rust2`) are all permissive and `cargo deny`-clean.
20
21/// The `NodeKind::Other` token for a config-key node (`cfgkey:<file>#<dotted>`).
22/// Shared by the extractor that emits them and the store reader that finds them.
23pub(crate) const KIND: &str = "config_key";
24
25/// The placeholder a redacted config value is replaced with, before anything is
26/// persisted. Single-sourced across the two places that *write* it — this module
27/// (a k8s `Secret`'s data, secret whatever the key is called) and
28/// [`crate::extract`] (any secret-*named* key) — and the one that *reads* it back,
29/// [`crate::config_secrets`]. That lens's whole report is "was this redacted",
30/// so it cannot be allowed to drift from the redactor by a spelling.
31pub(crate) const REDACTED: &str = "<redacted>";
32
33/// A single leaf config setting: its dotted key, source file, and value.
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
35pub struct ConfigKey {
36    /// Repo-relative file the key was read from.
37    pub file: String,
38    /// Dotted key path (e.g. `serve.addr`), verbatim from the source.
39    pub key: String,
40    /// The scalar (or compact list/object) value, as a string. String scalars are
41    /// **unquoted** so the same setting compares across TOML / JSON / `.env`. When
42    /// [`value_known`](Self::value_known) is `false` this is a placeholder (empty)
43    /// and carries no meaning — see that field.
44    pub value: String,
45    /// Whether [`value`](Self::value) is a **real** setting read from a source, as
46    /// opposed to *absent*. File-derived keys always carry a value (even a genuine
47    /// empty string), so this is `true`; a **struct-derived** key
48    /// (`meta.source = "struct"`) has no literal value in code, so it is `false` and
49    /// `value` is an empty placeholder. Value-agreement matching
50    /// (`roteiro links --infer`) must gate on this so an *unknown* value never
51    /// false-matches a spoke's genuine empty string. Not serialized — an internal
52    /// matching detail, not part of the reported config-key shape.
53    #[serde(skip)]
54    pub value_known: bool,
55}
56
57/// The lowercased file extension, if any (`config.TOML` → `toml`).
58fn ext_lower(path: &str) -> Option<String> {
59    std::path::Path::new(path)
60        .extension()
61        .and_then(|e| e.to_str())
62        .map(str::to_ascii_lowercase)
63}
64
65/// Whether a repo-relative path is a config file this module understands:
66/// `*.toml`, `*.json`, `*.yaml`, `*.yml`, `*.env`, or a dotenv name (`.env`,
67/// `.env.<x>`).
68///
69/// `.github/` is excluded: CI workflows and repo metadata are YAML but not *app*
70/// config, so mining them would bury a spoke's real overrides under `jobs`/`steps`
71/// noise (and add nothing to a hub repo's graph).
72#[must_use]
73pub fn is_config_path(path: &str) -> bool {
74    if path == ".github" || path.starts_with(".github/") {
75        return false;
76    }
77    let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
78    matches!(
79        ext_lower(path).as_deref(),
80        Some("toml" | "json" | "yaml" | "yml" | "env")
81    ) || base == ".env"
82        || base.starts_with(".env.")
83}
84
85/// Whether a repo-relative config path is **build / tooling / CI** config rather
86/// than an application's own config — a `Cargo.toml`, a `rustfmt.toml`, a CI
87/// workflow, and so on. Used only by *opt-in* filters (`--app-config-only`, the
88/// explorer's "hide tooling config" toggle): the default everywhere is to show
89/// every config key, so this classifier never changes what is extracted or stored.
90///
91/// **Conservative by design** — it returns `true` only for a curated allow-list of
92/// well-known tooling names and directories, so real app config is never hidden by
93/// mistake. A file it doesn't recognise (e.g. `config/app.toml`, `values.yaml`,
94/// `prod.env`) is treated as app config. The list is meant to grow; add new
95/// well-known tooling files to the `match` (basename) or the directory checks.
96///
97/// Matches on the **file-path component** of a `cfgkey:<file>#<dotted>` node, so
98/// callers extract that path (see the CLI's `--app-config-only`) before calling.
99///
100/// Covered today:
101/// - Rust build/tooling: `Cargo.toml`, `Cargo.lock`, `rust-toolchain[.toml]`,
102///   `rustfmt.toml` / `.rustfmt.toml`, `clippy.toml`, `deny.toml`, `release-plz.toml`.
103/// - Cargo's own config: `.cargo/config` / `.cargo/config.toml`.
104/// - Anything under `.config/` (e.g. `.config/nextest.toml`).
105/// - Anything under `.github/` (CI workflows, `dependabot.yml`).
106/// - `.gitlab-ci.yml`.
107#[must_use]
108pub fn is_tooling_config_path(path: &str) -> bool {
109    // Path components, ignoring any leading `./` or empty segments. Repo-relative
110    // paths use `/`, matching the `cfgkey:<file>` ids these are checked against.
111    let segments: Vec<&str> = path
112        .split('/')
113        .filter(|s| !s.is_empty() && *s != ".")
114        .collect();
115    let base = segments.last().copied().unwrap_or(path);
116    let base_lower = base.to_ascii_lowercase();
117
118    // Directory-scoped: a whole directory that is tooling/CI, not app config.
119    // `.github/` — CI workflows + repo metadata. `.config/` — nextest & friends.
120    if segments.iter().any(|s| *s == ".github" || *s == ".config") {
121        return true;
122    }
123
124    // `.cargo/config` or `.cargo/config.toml` — cargo's own build config. Scoped
125    // to that exact file inside `.cargo/`, so an unrelated `.cargo/app.toml` isn't
126    // swept up.
127    if segments.len() >= 2
128        && segments[segments.len() - 2] == ".cargo"
129        && matches!(base_lower.as_str(), "config" | "config.toml")
130    {
131        return true;
132    }
133
134    // Well-known tooling files by basename, anywhere in the tree (a vendored crate
135    // carries its own `Cargo.toml`, and it's tooling there too).
136    matches!(
137        base_lower.as_str(),
138        "cargo.toml"
139            | "cargo.lock"
140            | "rust-toolchain"
141            | "rust-toolchain.toml"
142            | "rustfmt.toml"
143            | ".rustfmt.toml"
144            | "clippy.toml"
145            | "deny.toml"
146            | "release-plz.toml"
147            | ".gitlab-ci.yml"
148    )
149}
150
151/// Flatten a config file's bytes into leaf keys, dispatched by extension. An
152/// unparseable file yields nothing (a config we can't read is not an error here).
153#[must_use]
154pub fn flatten(path: &str, bytes: &[u8]) -> Vec<ConfigKey> {
155    let Ok(text) = std::str::from_utf8(bytes) else {
156        return Vec::new();
157    };
158    let mut out = Vec::new();
159    match ext_lower(path).as_deref() {
160        Some("toml") => {
161            if let Ok(v) = toml::from_str::<toml::Value>(text) {
162                flatten_toml(&v, "", path, &mut out);
163            }
164        }
165        Some("json") => {
166            if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
167                flatten_json(&v, "", path, &mut out);
168            }
169        }
170        Some("yaml" | "yml") => flatten_yaml(text, path, &mut out),
171        // `.env`, `.env.<x>`, `*.env`, or anything else we treat as line-format.
172        _ => flatten_env(text, path, &mut out),
173    }
174    out
175}
176
177fn push(out: &mut Vec<ConfigKey>, file: &str, key: &str, value: String) {
178    if !key.is_empty() {
179        out.push(ConfigKey {
180            file: file.to_owned(),
181            key: key.to_owned(),
182            value,
183            // A flattened file key always has a real value (an empty string is a
184            // genuine empty setting, not an unknown one).
185            value_known: true,
186        });
187    }
188}
189
190fn join(prefix: &str, seg: &str) -> String {
191    if prefix.is_empty() {
192        seg.to_owned()
193    } else {
194        format!("{prefix}.{seg}")
195    }
196}
197
198/// A TOML leaf value as a plain string — strings unquoted, so they compare with
199/// env/JSON; other scalars and arrays keep their canonical rendering.
200fn toml_scalar(v: &toml::Value) -> String {
201    match v {
202        toml::Value::String(s) => s.clone(),
203        other => other.to_string(),
204    }
205}
206
207/// A JSON leaf value as a plain string — strings unquoted, matching [`toml_scalar`].
208fn json_scalar(v: &serde_json::Value) -> String {
209    match v {
210        serde_json::Value::String(s) => s.clone(),
211        other => other.to_string(),
212    }
213}
214
215/// Recurse into TOML tables; every non-table (scalar, array, inline) is a leaf
216/// value keyed by its dotted path — so `serve.models = ["a"]` is one key.
217fn flatten_toml(v: &toml::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
218    match v {
219        toml::Value::Table(t) => {
220            for (k, val) in t {
221                flatten_toml(val, &join(prefix, k), file, out);
222            }
223        }
224        other => push(out, file, prefix, toml_scalar(other)),
225    }
226}
227
228/// Recurse into JSON objects; arrays and scalars are leaves.
229fn flatten_json(v: &serde_json::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
230    match v {
231        serde_json::Value::Object(m) => {
232            for (k, val) in m {
233                flatten_json(val, &join(prefix, k), file, out);
234            }
235        }
236        other => push(out, file, prefix, json_scalar(other)),
237    }
238}
239
240/// Parse `KEY=VALUE` lines (skipping blanks / `#` comments), stripping surrounding
241/// single/double quote characters from the value.
242fn flatten_env(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
243    for line in text.lines() {
244        let line = line.trim();
245        if line.is_empty() || line.starts_with('#') {
246            continue;
247        }
248        if let Some((k, val)) = line.strip_prefix("export ").unwrap_or(line).split_once('=') {
249            let key = k.trim();
250            let val = val.trim().trim_matches('"').trim_matches('\'').to_owned();
251            push(out, file, key, val);
252        }
253    }
254}
255
256use yaml_rust2::Yaml;
257
258/// A YAML scalar as a plain string (strings unquoted, like [`toml_scalar`]);
259/// `None` for containers/aliases/bad values (handled by recursion, not as leaves).
260fn yaml_scalar(v: &Yaml) -> Option<String> {
261    match v {
262        // `Real` already holds its source text, so it renders like a string scalar.
263        Yaml::String(s) | Yaml::Real(s) => Some(s.clone()),
264        Yaml::Integer(i) => Some(i.to_string()),
265        Yaml::Boolean(b) => Some(b.to_string()),
266        Yaml::Null => Some("null".to_owned()),
267        _ => None,
268    }
269}
270
271/// The string value at `key` in a YAML mapping, if present and scalar-stringy.
272fn yaml_get_str<'a>(doc: &'a Yaml, key: &str) -> Option<&'a str> {
273    doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_str()
274}
275
276/// The array at `key` in a YAML mapping, if present.
277fn yaml_get_vec<'a>(doc: &'a Yaml, key: &str) -> Option<&'a Vec<Yaml>> {
278    doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_vec()
279}
280
281/// Parse every YAML document in `text` (multi-document `---` streams included),
282/// dispatching each to k8s-aware mining or a plain flatten. An unparseable stream
283/// yields nothing.
284fn flatten_yaml(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
285    let Ok(docs) = yaml_rust2::YamlLoader::load_from_str(text) else {
286        return;
287    };
288    for doc in &docs {
289        match k8s_kind(doc) {
290            Some(kind) => flatten_k8s(doc, &kind, file, out),
291            None => flatten_yaml_node(doc, "", file, out),
292        }
293    }
294}
295
296/// Flatten an arbitrary YAML document like TOML/JSON: recurse mappings, treat
297/// scalars and arrays as leaves (an array renders as one compact leaf).
298fn flatten_yaml_node(v: &Yaml, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
299    match v {
300        Yaml::Hash(h) => {
301            for (k, val) in h {
302                if let Some(k) = k.as_str() {
303                    flatten_yaml_node(val, &join(prefix, k), file, out);
304                }
305            }
306        }
307        Yaml::Array(items) => {
308            // An all-scalar array renders as one compact leaf; if any element is a
309            // map/array, emit a sentinel rather than silently dropping it to `[]`
310            // (which would mislead the matcher/diff into a false equality).
311            let parts: Vec<Option<String>> = items.iter().map(yaml_scalar).collect();
312            let value = if parts.iter().all(Option::is_some) {
313                let scalars: Vec<String> = parts.into_iter().flatten().collect();
314                format!("[{}]", scalars.join(", "))
315            } else {
316                format!("[<{} items>]", items.len())
317            };
318            push(out, file, prefix, value);
319        }
320        other => {
321            if let Some(s) = yaml_scalar(other) {
322                push(out, file, prefix, s);
323            }
324        }
325    }
326}
327
328/// The `kind` of a document that is a Kubernetes resource (a mapping carrying
329/// both `apiVersion` and `kind`), else `None`.
330fn k8s_kind(doc: &Yaml) -> Option<String> {
331    let h = doc.as_hash()?;
332    let has = |k: &str| h.contains_key(&Yaml::String(k.to_owned()));
333    (has("apiVersion") && has("kind"))
334        .then(|| yaml_get_str(doc, "kind").map(str::to_owned))
335        .flatten()
336}
337
338/// Mine a k8s resource for the settings a deployment actually overrides, rather
339/// than flattening its structural noise.
340fn flatten_k8s(doc: &Yaml, kind: &str, file: &str, out: &mut Vec<ConfigKey>) {
341    match kind {
342        // ConfigMap `data` is literally the app's config; keys stand alone.
343        "ConfigMap" => k8s_data(doc, "data", file, out, false),
344        // A Secret's `data`/`stringData` are secret by definition — always redacted.
345        "Secret" => {
346            k8s_data(doc, "data", file, out, true);
347            k8s_data(doc, "stringData", file, out, true);
348        }
349        // Workload kinds carry a pod template — mine its containers.
350        _ => {
351            if let Some(pod) = k8s_pod_spec(doc, kind) {
352                k8s_containers(pod, file, out);
353            }
354        }
355    }
356}
357
358/// Emit each entry of a k8s `data`/`stringData` mapping as a config key. When
359/// `redact`, the value is replaced with `<redacted>` (a Secret's data is secret
360/// even when the key name isn't); otherwise the caller's secret-key redaction
361/// still applies to secret-looking names.
362fn k8s_data(doc: &Yaml, field: &str, file: &str, out: &mut Vec<ConfigKey>, redact: bool) {
363    let Some(map) = doc
364        .as_hash()
365        .and_then(|h| h.get(&Yaml::String(field.to_owned())))
366        .and_then(Yaml::as_hash)
367    else {
368        return;
369    };
370    for (k, v) in map {
371        let Some(k) = k.as_str() else { continue };
372        if redact {
373            // A Secret's value is secret whatever its shape — always redact.
374            push(out, file, k, REDACTED.to_owned());
375        } else if let Some(value) = yaml_scalar(v) {
376            push(out, file, k, value);
377        }
378        // A non-scalar ConfigMap value is skipped rather than emitted as `""`,
379        // which would mislead the matcher/diff.
380    }
381}
382
383/// Navigate to the pod spec (the mapping that holds `containers`) for a workload
384/// `kind`, or `None` for kinds that carry no pod template.
385fn k8s_pod_spec<'a>(doc: &'a Yaml, kind: &str) -> Option<&'a Yaml> {
386    let path: &[&str] = match kind {
387        "Pod" => &["spec"],
388        "Deployment" | "StatefulSet" | "DaemonSet" | "ReplicaSet" | "Job" => {
389            &["spec", "template", "spec"]
390        }
391        "CronJob" => &["spec", "jobTemplate", "spec", "template", "spec"],
392        _ => return None,
393    };
394    let mut cur = doc;
395    for seg in path {
396        cur = cur.as_hash()?.get(&Yaml::String((*seg).to_owned()))?;
397    }
398    Some(cur)
399}
400
401/// Mine each container (and init container) in a pod spec for its `image` (keyed
402/// `container.<name>.image`) and each literal `env` var (keyed by the env name,
403/// so it matches a hub `.env`/config setting). Env vars sourced from `valueFrom`
404/// carry no literal value here and are skipped.
405fn k8s_containers(pod: &Yaml, file: &str, out: &mut Vec<ConfigKey>) {
406    for field in ["containers", "initContainers"] {
407        let Some(list) = yaml_get_vec(pod, field) else {
408            continue;
409        };
410        for c in list {
411            let cname = yaml_get_str(c, "name").unwrap_or("container");
412            if let Some(image) = yaml_get_str(c, "image") {
413                push(
414                    out,
415                    file,
416                    &format!("container.{cname}.image"),
417                    image.to_owned(),
418                );
419            }
420            if let Some(env) = yaml_get_vec(c, "env") {
421                for e in env {
422                    if let (Some(name), Some(value)) =
423                        (yaml_get_str(e, "name"), yaml_get_str(e, "value"))
424                    {
425                        push(out, file, name, value.to_owned());
426                    }
427                }
428            }
429        }
430    }
431}
432
433/// Whether a config key's *name* looks like it holds a secret (token, password,
434/// credential, …). Extraction **redacts the value** of such keys so secrets from
435/// `.env`/config files are never persisted into the graph store (which is
436/// queryable and exportable). Matched against the key with separators removed, so
437/// `API_KEY`, `apiKey`, and `api-key` all count.
438#[must_use]
439pub fn is_secret_key(key: &str) -> bool {
440    const NEEDLES: &[&str] = &[
441        "secret",
442        "password",
443        "passwd",
444        "passphrase",
445        "token",
446        "apikey",
447        "credential",
448        "privatekey",
449        "accesskey",
450        "pwd",
451    ];
452    let flat: String = key
453        .chars()
454        .filter(char::is_ascii_alphanumeric)
455        .map(|c| c.to_ascii_lowercase())
456        .collect();
457    NEEDLES.iter().any(|n| flat.contains(n))
458}
459
460/// Normalise a dotted key for matching: lowercase, split on any non-alphanumeric
461/// run, join with `.`. So `SERVE_ADDR`, `serve.addr`, and `serve-addr` all become
462/// `serve.addr` and match across TOML / env / JSON conventions.
463#[must_use]
464pub fn normalize(key: &str) -> String {
465    key.split(|c: char| !c.is_ascii_alphanumeric())
466        .filter(|s| !s.is_empty())
467        .map(str::to_ascii_lowercase)
468        .collect::<Vec<_>>()
469        .join(".")
470}
471
472/// Canonicalise a dotted key for cross-**naming-convention** matching: keep the
473/// dotted structure, but collapse each `.`-delimited segment to its lowercased
474/// ASCII-alphanumerics only — dropping `_`, `-`, and any other punctuation within
475/// the segment. So within a segment `serverEndpoint`, `server_endpoint`, and
476/// `server-endpoint` all become `serverendpoint`, letting a Kubernetes YAML
477/// `zerobus.serverEndpoint` (`camelCase`) match an app TOML `zerobus.server_endpoint`
478/// (`snake_case`) that [`normalize`] keeps apart — `normalize` splits on *any* run
479/// of non-ASCII-alphanumeric chars, so `_` becomes a boundary
480/// (`zerobus.server.endpoint`) and a compound leaf never lines up with its
481/// `camelCase` spelling. The dotted structure is preserved here (segments are split
482/// on `.` only) so `a.b` and `ab` stay distinct.
483#[must_use]
484pub fn canonicalize(key: &str) -> String {
485    key.split('.')
486        .map(|seg| {
487            seg.chars()
488                .filter(char::is_ascii_alphanumeric)
489                .map(|c| c.to_ascii_lowercase())
490                .collect::<String>()
491        })
492        .filter(|s| !s.is_empty())
493        .collect::<Vec<_>>()
494        .join(".")
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn flatten_unquotes_strings_and_treats_arrays_as_one_leaf() {
503        let toml = flatten(
504            "a.toml",
505            b"[serve]\naddr = \"0.0.0.0:8443\"\nmodels = [\"q8\"]\n",
506        );
507        assert!(
508            toml.iter()
509                .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
510        );
511        assert!(toml.iter().any(|k| k.key == "serve.models"));
512        let json = flatten(
513            "a.json",
514            br#"{"serve":{"addr":"0.0.0.0:8443","tools":false}}"#,
515        );
516        assert!(
517            json.iter()
518                .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
519        );
520        assert!(
521            json.iter()
522                .any(|k| k.key == "serve.tools" && k.value == "false")
523        );
524        let env = flatten(".env", b"# c\nexport SERVE_ADDR=127.0.0.1:8017\n");
525        assert!(
526            env.iter()
527                .any(|k| k.key == "SERVE_ADDR" && k.value == "127.0.0.1:8017")
528        );
529    }
530
531    #[test]
532    fn is_config_path_matches_toml_json_yaml_env() {
533        assert!(is_config_path("values.prod.yaml")); // YAML is in scope (k8s spokes)
534        assert!(is_config_path("deploy.yml"));
535        assert!(is_config_path("config.toml"));
536        assert!(is_config_path("a/b.json"));
537        assert!(is_config_path(".env"));
538        assert!(is_config_path(".env.local"));
539        assert!(is_config_path("prod.env"));
540        assert!(!is_config_path("src/main.rs"));
541        // CI workflows are YAML but not app config — excluded.
542        assert!(!is_config_path(".github/workflows/ci.yml"));
543        assert!(!is_config_path(".github/dependabot.yml"));
544    }
545
546    #[test]
547    fn tooling_config_paths_are_flagged_conservatively() {
548        // Well-known build/tooling/CI files → tooling (hidden by the opt-in filter).
549        for p in [
550            "Cargo.toml",
551            "Cargo.lock",
552            "crates/rto-graph/Cargo.toml", // a workspace member's manifest, too
553            "vendor/some-crate/Cargo.toml", // a vendored crate's manifest
554            "rust-toolchain",
555            "rust-toolchain.toml",
556            "rustfmt.toml",
557            ".rustfmt.toml",
558            "clippy.toml",
559            "deny.toml",
560            "release-plz.toml",
561            ".config/nextest.toml",
562            ".cargo/config",
563            ".cargo/config.toml",
564            ".github/workflows/ci.yml",
565            ".github/dependabot.yml",
566            ".gitlab-ci.yml",
567        ] {
568            assert!(is_tooling_config_path(p), "{p} should be tooling config");
569        }
570
571        // Ordinary application config → NOT tooling (always shown; never misclassified).
572        for p in [
573            "config/app.toml",
574            "values.yaml",
575            "values.prod.yaml",
576            "prod.env",
577            ".env",
578            ".env.local",
579            "zerobus-example.toml",
580            "deploy/service.json",
581            "settings/config.toml", // a plain `config.toml`, not under `.cargo/`
582            ".cargo/app.toml",      // an unrelated file that merely lives under `.cargo/`
583        ] {
584            assert!(!is_tooling_config_path(p), "{p} should be app config");
585        }
586    }
587
588    #[test]
589    fn yaml_helm_values_flatten_like_toml() {
590        let ks = flatten(
591            "values.yaml",
592            b"service:\n  addr: 0.0.0.0:8443\n  tools: false\nreplicas: 3\nmodels:\n  - a\n  - b\n",
593        );
594        assert!(
595            ks.iter()
596                .any(|k| k.key == "service.addr" && k.value == "0.0.0.0:8443"),
597            "{ks:?}"
598        );
599        assert!(
600            ks.iter()
601                .any(|k| k.key == "service.tools" && k.value == "false")
602        );
603        assert!(ks.iter().any(|k| k.key == "replicas" && k.value == "3"));
604        // An array is one compact leaf (as for TOML/JSON).
605        assert!(ks.iter().any(|k| k.key == "models" && k.value == "[a, b]"));
606    }
607
608    #[test]
609    fn yaml_array_of_objects_emits_a_sentinel_not_empty() {
610        // An array whose elements are maps must not silently render as `[]` (which
611        // would false-match another empty list) — a sentinel signals the structure.
612        let ks = flatten(
613            "values.yaml",
614            b"ingress:\n  hosts:\n    - host: a.example\n      paths: [/]\n    - host: b.example\n",
615        );
616        let hosts = ks
617            .iter()
618            .find(|k| k.key == "ingress.hosts")
619            .expect("hosts leaf");
620        assert_eq!(hosts.value, "[<2 items>]", "non-scalar array is a sentinel");
621    }
622
623    #[test]
624    fn k8s_configmap_skips_non_scalar_values() {
625        // A ConfigMap whose value is itself a map must be skipped, not emitted as "".
626        let cm = b"apiVersion: v1\nkind: ConfigMap\ndata:\n  flat: ok\n  nested:\n    a: 1\n";
627        let ks = flatten("cm.yaml", cm);
628        assert!(ks.iter().any(|k| k.key == "flat" && k.value == "ok"));
629        assert!(
630            !ks.iter().any(|k| k.key == "nested"),
631            "non-scalar ConfigMap value skipped, not emitted empty: {ks:?}"
632        );
633    }
634
635    #[test]
636    fn k8s_manifest_mines_config_not_structural_noise() {
637        // A Deployment: env + image are mined; apiVersion/metadata are not.
638        let dep = b"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: api\nspec:\n  template:\n    spec:\n      containers:\n        - name: api\n          image: registry/app:1.2\n          env:\n            - name: SERVE_ADDR\n              value: 0.0.0.0:8443\n            - name: DB_HOST\n              valueFrom:\n                secretKeyRef:\n                  name: db\n";
639        let ks = flatten("deploy.yaml", dep);
640        assert!(
641            ks.iter()
642                .any(|k| k.key == "SERVE_ADDR" && k.value == "0.0.0.0:8443"),
643            "env var mined as a bare key so it matches a hub .env: {ks:?}"
644        );
645        assert!(
646            ks.iter()
647                .any(|k| k.key == "container.api.image" && k.value == "registry/app:1.2"),
648            "{ks:?}"
649        );
650        // valueFrom env has no literal value → skipped; structural noise absent.
651        assert!(!ks.iter().any(|k| k.key == "DB_HOST"));
652        assert!(
653            !ks.iter()
654                .any(|k| k.key.starts_with("apiVersion") || k.key.contains("metadata"))
655        );
656    }
657
658    #[test]
659    fn k8s_configmap_and_secret_data_with_redaction() {
660        let cm = b"apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: c\ndata:\n  serve.addr: 127.0.0.1:8017\n  log_level: info\n";
661        let ks = flatten("cm.yaml", cm);
662        assert!(
663            ks.iter()
664                .any(|k| k.key == "serve.addr" && k.value == "127.0.0.1:8017")
665        );
666        assert!(ks.iter().any(|k| k.key == "log_level" && k.value == "info"));
667
668        // A Secret's data is always redacted — even a non-secret-looking key name.
669        let sec = b"apiVersion: v1\nkind: Secret\ndata:\n  database-url: aHR0cA==\n";
670        let ks = flatten("secret.yaml", sec);
671        assert!(
672            ks.iter()
673                .any(|k| k.key == "database-url" && k.value == "<redacted>"),
674            "secret data must be redacted regardless of key name: {ks:?}"
675        );
676    }
677
678    #[test]
679    fn yaml_multi_document_stream_mines_each_doc() {
680        // One file, two docs (`---`): a ConfigMap and a Deployment.
681        let stream = b"apiVersion: v1\nkind: ConfigMap\ndata:\n  port: \"8443\"\n---\napiVersion: apps/v1\nkind: Deployment\nspec:\n  template:\n    spec:\n      containers:\n        - name: web\n          image: app:2.0\n";
682        let ks = flatten("bundle.yaml", stream);
683        assert!(ks.iter().any(|k| k.key == "port" && k.value == "8443"));
684        assert!(
685            ks.iter()
686                .any(|k| k.key == "container.web.image" && k.value == "app:2.0")
687        );
688    }
689
690    #[test]
691    fn normalize_bridges_conventions() {
692        assert_eq!(normalize("SERVE_ADDR"), "serve.addr");
693        assert_eq!(normalize("serve-addr"), "serve.addr");
694    }
695
696    #[test]
697    fn canonicalize_bridges_camel_snake_kebab_within_a_segment() {
698        // The three spellings of a compound leaf collapse to one canonical form,
699        // which `normalize` (separator-as-boundary) keeps apart.
700        assert_eq!(
701            canonicalize("zerobus.serverEndpoint"),
702            "zerobus.serverendpoint"
703        );
704        assert_eq!(
705            canonicalize("zerobus.server_endpoint"),
706            "zerobus.serverendpoint"
707        );
708        assert_eq!(
709            canonicalize("zerobus.server-endpoint"),
710            "zerobus.serverendpoint"
711        );
712        assert_ne!(
713            normalize("zerobus.server_endpoint"),
714            normalize("zerobus.serverEndpoint"),
715            "normalize splits snake_case on `_`, so it cannot bridge camelCase"
716        );
717        // Dotted structure is preserved: `a.b` must not collapse into `ab`.
718        assert_ne!(canonicalize("a.b"), canonicalize("ab"));
719    }
720
721    #[test]
722    fn secret_keys_are_flagged_across_conventions() {
723        for k in [
724            "API_TOKEN",
725            "apiKey",
726            "db.password",
727            "AWS_SECRET_ACCESS_KEY",
728            "PWD",
729        ] {
730            assert!(is_secret_key(k), "{k} should be secret");
731        }
732        for k in ["serve.addr", "models.generative", "port", "workspace.roots"] {
733            assert!(!is_secret_key(k), "{k} should not be secret");
734        }
735    }
736}