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