Skip to main content

kranz_cli/
config_cmd.rs

1//! `kranz config` — inspect and edit the layered configuration.
2//!
3//! Configuration resolves from three layers, later layers winning (see
4//! `kranz_engine::config`): compiled-in defaults ← `~/.kranz/config.json`
5//! (the GLOBAL layer) ← `<repo>/.kranz/config.json` (the PROJECT layer).
6//!
7//! Four verbs:
8//! - `show` — the effective merged config (or one layer with
9//!   `--global`/`--project`).
10//! - `set <path> <value>` — set ONE dotted key in a layer file. The dotted
11//!   path is checked against the config SCHEMA first (a typo'd key would
12//!   deep-merge, validate green — unknown keys are ignored on deserialize —
13//!   and be a silent no-op). The edit then happens on the raw JSON tree
14//!   (never a `MissionConfig` round-trip), so every other key already IN the
15//!   file — including keys kranz doesn't know about — survives untouched.
16//!   The candidate merge is validated BEFORE anything is written; an invalid
17//!   value leaves the file byte-identical. A `--global` write is validated
18//!   TWICE: standalone (defaults + candidate global — what every OTHER repo
19//!   sees) and merged with this repo's project layer; either failure refuses
20//!   the write, so a project override can never mask a global value that
21//!   would poison other repos.
22//! - `unset <path>` — remove one dotted key from a layer file, pruning parent
23//!   objects the removal left empty. Removing the last key leaves an empty
24//!   `{}` file (the file is never deleted). The dotted path gets the same
25//!   schema check as `set` (a typo'd key should say "unknown key", not "not
26//!   set"). Unset is NOT validity-gated: removal converges toward the
27//!   defaults, and gating it could deadlock (an invalid VALUE in one layer
28//!   would block its own removal). The schema-path gate cannot deadlock: an
29//!   unknown key never affects validation in the first place.
30//! - `role <role> <model> [effort]` — the MID-MISSION path: enqueue a
31//!   `config-change` control command on a running mission (the CLI twin of
32//!   Slack's `/kranz config`). File edits only shape FUTURE missions; `role`
33//!   reshapes the one that is running, at its next spawn of that role.
34
35use anyhow::{anyhow, bail, Context, Result};
36use clap::Subcommand;
37use kranz_engine::config;
38use kranz_engine::control;
39use kranz_engine::paths::{self, MissionPaths};
40use kranz_engine::types::{ControlCommand, MissionConfig};
41use serde_json::Value;
42use std::path::{Path, PathBuf};
43
44/// Subcommands under `kranz config` — the configuration surface.
45#[derive(Subcommand, Debug)]
46pub enum ConfigCommand {
47    /// Print the effective merged config (defaults <- global <- project).
48    ///
49    /// The merged layer files (and whether each exists) are listed on stderr,
50    /// so stdout stays clean JSON for piping. --global / --project print just
51    /// that one layer file instead ("{}" plus a stderr note when missing).
52    ///
53    /// Single-layer output is REDACTED: the raw layer file carries keys
54    /// `MissionConfig` never sees (`slack.botToken`, `slack.appToken`,
55    /// `hooks.secret`), and stdout ends up in CI logs, screen shares and
56    /// shell transcripts. `--show-secrets` prints them verbatim.
57    Show {
58        /// Print only ~/.kranz/config.json (the global layer)
59        #[arg(long, conflicts_with = "project")]
60        global: bool,
61
62        /// Print only `<repo>/.kranz/config.json` (the project layer)
63        #[arg(long)]
64        project: bool,
65
66        /// Print secret-shaped values verbatim instead of `[REDACTED]`
67        #[arg(long)]
68        show_secrets: bool,
69    },
70
71    /// Set one key in a config layer file (validated before writing).
72    ///
73    /// PATH is a dotted path into the config (worker.model,
74    /// orchestrator.reasoningEffort, maxParallelWorkers, skipScrutiny, ...).
75    /// VALUE parses as JSON first (2, true, ["x"]) and falls back to a plain
76    /// string, so `kranz config set worker.model opus` works unquoted. Only
77    /// that key changes — every other key in the file (known or not) is
78    /// preserved. The merged result of all layers is validated first; on
79    /// failure nothing is written.
80    Set {
81        /// Dotted path into the config (e.g. worker.model)
82        path: String,
83
84        /// The value (JSON if it parses, plain string otherwise)
85        value: String,
86
87        /// Write to `~/.kranz/config.json` instead of `<repo>/.kranz/config.json`
88        #[arg(long)]
89        global: bool,
90    },
91
92    /// Remove one key from a config layer file.
93    ///
94    /// Parent objects left empty by the removal are pruned; removing the last
95    /// key leaves an empty "{}" file (the file itself is never deleted).
96    Unset {
97        /// Dotted path into the config (e.g. worker.model)
98        path: String,
99
100        /// Edit `~/.kranz/config.json` instead of `<repo>/.kranz/config.json`
101        #[arg(long)]
102        global: bool,
103    },
104
105    /// Change a role's model/effort on a RUNNING mission (mid-mission path).
106    ///
107    /// Enqueues a config-change control command on the target mission's
108    /// control inbox — the CLI twin of Slack's `/kranz config`. The target is
109    /// the global --mission id (which must be active), or, without one, the
110    /// repo's single active mission; with several active this refuses and
111    /// lists them. The change applies at the next spawn of that role; the
112    /// layer files are not touched.
113    Role {
114        /// orchestrator | worker | scrutiny | functional
115        role: String,
116
117        /// Model alias or full id passed to `claude --model` (e.g. opus)
118        model: String,
119
120        /// low | medium | high | xhigh | max (unchanged when omitted)
121        effort: Option<String>,
122    },
123}
124
125/// Role names accepted by `kranz config role` — the same friendly spellings
126/// (and the same case-insensitive matching) Slack's `/kranz config` accepts.
127const ROLES: [&str; 4] = ["orchestrator", "worker", "scrutiny", "functional"];
128
129/// Reasoning-effort values accepted by `claude --effort` (mirrors the engine).
130const EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
131
132/// Dispatch a `kranz config …` subcommand. `mission` is the global --mission
133/// flag, consumed only by `role`.
134pub fn cmd_config(repo: &Path, command: ConfigCommand, mission: Option<&str>) -> Result<i32> {
135    let layers = Layers::resolve(repo);
136    match command {
137        ConfigCommand::Show {
138            global,
139            project,
140            show_secrets,
141        } => {
142            let single = if global {
143                Some(layers.target(true)?.to_path_buf())
144            } else if project {
145                Some(layers.project.clone())
146            } else {
147                None
148            };
149            match single {
150                Some(path) => {
151                    let (json, exists) = if show_secrets {
152                        render_layer_file(&path)?
153                    } else {
154                        render_layer_file_redacted(&path)?
155                    };
156                    if !exists {
157                        eprintln!("# {} does not exist", path.display());
158                    }
159                    print!("{json}");
160                }
161                None => {
162                    for path in layers.merge_order() {
163                        let status = if path.is_file() { "merged" } else { "absent" };
164                        eprintln!("# layer {status}: {}", path.display());
165                    }
166                    print!("{}", render_effective(&layers.merge_order_with_roles())?);
167                }
168            }
169            Ok(0)
170        }
171        ConfigCommand::Set {
172            path,
173            value,
174            global,
175        } => {
176            let file = set_key(&layers, global, &path, &value)?;
177            println!("set {path} in {}", file.display());
178            Ok(0)
179        }
180        ConfigCommand::Unset { path, global } => {
181            let file = unset_key(&layers, global, &path)?;
182            println!("removed {path} from {}", file.display());
183            Ok(0)
184        }
185        ConfigCommand::Role {
186            role,
187            model,
188            effort,
189        } => {
190            let role = parse_role(&role)?;
191            let effort = effort.as_deref().map(parse_effort).transpose()?;
192            let applied_to = role_change(repo, mission, role, &model, effort)?;
193            let effort_note = effort.map(|e| format!(", effort {e}")).unwrap_or_default();
194            println!(
195                "config change queued for mission {applied_to}: {role} -> model {model}\
196                 {effort_note} (applies at the next {role} spawn; the layer files are unchanged)"
197            );
198            Ok(0)
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Layer files (the file-choice is explicit-path so tests never touch $HOME)
205// ---------------------------------------------------------------------------
206
207/// The two editable config layer files, resolved once so every verb — and
208/// every test — operates on explicit paths instead of re-deriving `$HOME`.
209pub struct Layers {
210    /// `~/.kranz/config.json`; `None` when no home directory resolves.
211    pub global: Option<PathBuf>,
212    /// `<repo>/.kranz/config.json`.
213    pub project: PathBuf,
214}
215
216impl Layers {
217    /// The real layer paths for `repo` (global from the home directory).
218    pub fn resolve(repo: &Path) -> Self {
219        Layers {
220            global: paths::global_config(),
221            project: paths::project_config(repo),
222        }
223    }
224
225    /// Layer paths in merge order: global first, project last (later wins) —
226    /// the same order `config::load` uses.
227    pub fn merge_order(&self) -> Vec<PathBuf> {
228        self.merge_order_with_roles()
229            .into_iter()
230            .map(|(path, _)| path)
231            .collect()
232    }
233
234    /// [`Self::merge_order`] with each layer's provenance, so a render goes
235    /// through the same operator-only key rule the engine's `config::load`
236    /// applies — otherwise `kranz config show` would print an effective
237    /// config the engine refuses to load.
238    pub fn merge_order_with_roles(&self) -> Vec<(PathBuf, config::Layer)> {
239        let mut order = Vec::new();
240        if let Some(global) = &self.global {
241            order.push((global.clone(), config::Layer::Global));
242        }
243        order.push((self.project.clone(), config::Layer::Project));
244        order
245    }
246
247    /// The file a `--global` / default (project) edit targets.
248    pub fn target(&self, global: bool) -> Result<&Path> {
249        if global {
250            self.global.as_deref().ok_or_else(|| {
251                anyhow!("cannot resolve the home directory for ~/.kranz/config.json")
252            })
253        } else {
254            Ok(&self.project)
255        }
256    }
257}
258
259/// Pretty-print the effective config merged from explicit layer paths over
260/// the compiled-in defaults (missing files are skipped, like `config::load`),
261/// with each layer's provenance so the operator-only key rule applies here
262/// exactly as it does at load time.
263pub fn render_effective(layers: &[(PathBuf, config::Layer)]) -> Result<String> {
264    let cfg = config::load_layers_with_roles(layers)?;
265    Ok(format!("{}\n", serde_json::to_string_pretty(&cfg)?))
266}
267
268/// Pretty-print one layer file. A missing file renders as `{}` with
269/// `exists = false` so the caller can add a note without polluting stdout.
270///
271/// VERBATIM: the raw tree, secrets included. Only the `--show-secrets` path
272/// and the write path (which must round-trip the file) may use it; the
273/// default `show` path uses [`render_layer_file_redacted`].
274pub fn render_layer_file(path: &Path) -> Result<(String, bool)> {
275    match std::fs::read_to_string(path) {
276        Ok(text) => {
277            let v: Value = serde_json::from_str(&text)
278                .with_context(|| format!("invalid JSON in {}", path.display()))?;
279            Ok((format!("{}\n", serde_json::to_string_pretty(&v)?), true))
280        }
281        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(("{}\n".to_string(), false)),
282        Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
283    }
284}
285
286/// [`render_layer_file`] with secret-shaped values replaced by `[REDACTED]`.
287///
288/// A single-layer render prints the RAW file, not a `MissionConfig`
289/// round-trip, so it carries keys the engine's type never sees: the global
290/// layer is the documented home for `slack.botToken`, `slack.appToken` and
291/// `hooks.secret`, and the sandbox profiles call the file out as carrying
292/// exactly that material. stdout reaches CI logs, screen shares, shell
293/// transcripts and any agent that can run `kranz`, none of which is scrubbed
294/// (audit 2026-09-01, MEDIUM `kranz config show --global`).
295///
296/// Two rules, belt and braces: a key whose name ends in `token`, `secret`,
297/// `key` or `password` is redacted whatever its value (this is what catches
298/// Slack's `xapp-…` app tokens, which no value-shape rule matches), and every
299/// surviving string still goes through [`kranz_engine::scrub`], which catches
300/// a credential parked under an innocuous key.
301pub fn render_layer_file_redacted(path: &Path) -> Result<(String, bool)> {
302    let (text, exists) = render_layer_file(path)?;
303    let mut value: Value = serde_json::from_str(&text)?;
304    redact_secret_shaped(&mut value);
305    Ok((
306        format!("{}\n", serde_json::to_string_pretty(&value)?),
307        exists,
308    ))
309}
310
311/// Key-name suffixes whose values are credentials by convention.
312const SECRET_KEY_SUFFIXES: [&str; 4] = ["token", "secret", "key", "password"];
313
314/// True when `key` names a credential by convention (case-insensitive
315/// suffix match, so `botToken`, `appToken`, `secret`, `apiKey` and
316/// `dbPassword` all hit).
317fn is_secret_key(key: &str) -> bool {
318    let lower = key.to_ascii_lowercase();
319    SECRET_KEY_SUFFIXES
320        .iter()
321        .any(|suffix| lower.ends_with(suffix))
322}
323
324fn redact_secret_shaped(value: &mut Value) {
325    match value {
326        Value::Object(map) => {
327            for (key, slot) in map.iter_mut() {
328                if is_secret_key(key) && !slot.is_object() && !slot.is_array() {
329                    *slot = Value::String("[REDACTED]".to_string());
330                } else {
331                    redact_secret_shaped(slot);
332                }
333            }
334        }
335        Value::Array(items) => {
336            for item in items.iter_mut() {
337                redact_secret_shaped(item);
338            }
339        }
340        Value::String(text) => {
341            let scrubbed = kranz_engine::scrub::scrub(text);
342            if &scrubbed != text {
343                *text = scrubbed;
344            }
345        }
346        _ => {}
347    }
348}
349
350// ---------------------------------------------------------------------------
351// set / unset (raw JSON tree edits; never a MissionConfig round-trip)
352// ---------------------------------------------------------------------------
353
354/// Set `dotted` to `raw` (JSON-parsed, string fallback) in the chosen layer
355/// file. The dotted path must exist in the config schema (a typo'd key would
356/// otherwise merge, validate green, and be a silent no-op). The candidate
357/// state is validated BEFORE writing — for `--global` both standalone and
358/// merged with this repo's project layer; on any failure the file is left
359/// byte-identical. Returns the file written.
360pub fn set_key(layers: &Layers, global: bool, dotted: &str, raw: &str) -> Result<PathBuf> {
361    check_schema_path(dotted)?;
362    let target = layers.target(global)?.to_path_buf();
363    let mut tree = read_layer(&target)?;
364    set_dotted(&mut tree, dotted, parse_value(raw))?;
365    validate_candidate(layers, &target, &tree)
366        .with_context(|| format!("refusing to write {}", target.display()))?;
367    write_layer(&target, &tree)?;
368    Ok(target)
369}
370
371/// Remove `dotted` from the chosen layer file, pruning parents the removal
372/// left empty. The dotted path gets the same schema check as `set` — a typo'd
373/// key errors as "unknown key", not "not set". A schema-valid key that is not
374/// set is an error too (and nothing is written). Removing the last key leaves
375/// `{}` — the file is never deleted. Returns the file written.
376pub fn unset_key(layers: &Layers, global: bool, dotted: &str) -> Result<PathBuf> {
377    check_schema_path(dotted)?;
378    let target = layers.target(global)?.to_path_buf();
379    let mut tree = read_layer(&target)?;
380    if !remove_dotted(&mut tree, dotted) {
381        bail!("{dotted} is not set in {}", target.display());
382    }
383    write_layer(&target, &tree)?;
384    Ok(target)
385}
386
387/// Parse a CLI value: JSON first (`2`, `true`, `["x"]`, `"quoted"`), falling
388/// back to a plain string so `set worker.model opus` works unquoted.
389fn parse_value(raw: &str) -> Value {
390    serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
391}
392
393/// Read a layer file as a top-level JSON object. Missing file = empty object
394/// (creating a key in a not-yet-existing layer is fine); invalid JSON or a
395/// non-object top level is an error naming the file.
396fn read_layer(path: &Path) -> Result<serde_json::Map<String, Value>> {
397    match std::fs::read_to_string(path) {
398        Ok(text) => {
399            let v: Value = serde_json::from_str(&text)
400                .with_context(|| format!("invalid JSON in {}", path.display()))?;
401            match v {
402                Value::Object(map) => Ok(map),
403                _ => bail!(
404                    "{} must contain a JSON object at the top level",
405                    path.display()
406                ),
407            }
408        }
409        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()),
410        Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
411    }
412}
413
414/// Pretty-print the tree back to the layer file (creating parent dirs).
415///
416/// ATOMIC: written to a sibling tmp file (same directory, so the rename never
417/// crosses a filesystem), flushed + synced, then renamed over the target —
418/// the same pattern as `reducer::write_snapshot` / `control::enqueue`. A
419/// concurrent `config::load` (serve create, the `kranz work` dispatcher, the
420/// Slack bridge) therefore only ever sees the old bytes or the new bytes,
421/// never a torn/empty file, and a crash mid-write cannot lose the config.
422/// An existing target's permissions are carried over to the replacement.
423fn write_layer(path: &Path, tree: &serde_json::Map<String, Value>) -> Result<()> {
424    if let Some(parent) = path.parent() {
425        std::fs::create_dir_all(parent)
426            .with_context(|| format!("creating {}", parent.display()))?;
427    }
428    let file_name = path
429        .file_name()
430        .ok_or_else(|| anyhow!("config path {} has no file name", path.display()))?;
431    let tmp = path.with_file_name(format!("{}.tmp", file_name.to_string_lossy()));
432    let text = format!(
433        "{}\n",
434        serde_json::to_string_pretty(&Value::Object(tree.clone()))?
435    );
436    {
437        use std::io::Write as _;
438        let mut file =
439            std::fs::File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
440        file.write_all(text.as_bytes())
441            .with_context(|| format!("writing {}", tmp.display()))?;
442        file.sync_data()
443            .with_context(|| format!("syncing {}", tmp.display()))?;
444    }
445    // Keep the operator's permissions (e.g. a chmod 600 config) across the
446    // replacement; best-effort — the rename below is the load-bearing part.
447    if let Ok(meta) = std::fs::metadata(path) {
448        let _ = std::fs::set_permissions(&tmp, meta.permissions());
449    }
450    std::fs::rename(&tmp, path)
451        .with_context(|| format!("renaming {} over {}", tmp.display(), path.display()))?;
452    Ok(())
453}
454
455/// Set `dotted` inside the object tree, creating intermediate objects as
456/// needed. A segment that exists as a non-object is an error — `set` must
457/// never silently clobber a scalar with an object on the way down.
458fn set_dotted(root: &mut serde_json::Map<String, Value>, dotted: &str, value: Value) -> Result<()> {
459    let segments: Vec<&str> = dotted.split('.').collect();
460    if segments.iter().any(|s| s.is_empty()) {
461        bail!("invalid config path {dotted:?} (empty segment)");
462    }
463    let mut cur = root;
464    for seg in &segments[..segments.len() - 1] {
465        let slot = cur
466            .entry(seg.to_string())
467            .or_insert_with(|| Value::Object(serde_json::Map::new()));
468        cur = match slot {
469            Value::Object(map) => map,
470            other => bail!(
471                "config path {dotted:?}: {seg:?} holds {other} (not an object); \
472                 unset it first if you mean to replace it"
473            ),
474        };
475    }
476    cur.insert(segments.last().expect("non-empty split").to_string(), value);
477    Ok(())
478}
479
480/// Remove `dotted` from the tree; `true` iff it was present. Parent objects
481/// the removal left empty are pruned on the way back up.
482fn remove_dotted(root: &mut serde_json::Map<String, Value>, dotted: &str) -> bool {
483    fn recurse(map: &mut serde_json::Map<String, Value>, segs: &[&str]) -> bool {
484        match segs {
485            [] => false,
486            [leaf] => map.remove(*leaf).is_some(),
487            [head, rest @ ..] => {
488                let Some(Value::Object(child)) = map.get_mut(*head) else {
489                    return false;
490                };
491                let removed = recurse(child, rest);
492                if removed && child.is_empty() {
493                    map.remove(*head);
494                }
495                removed
496            }
497        }
498    }
499    let segs: Vec<&str> = dotted.split('.').collect();
500    recurse(root, &segs)
501}
502
503/// Validate the WOULD-BE state; an error here means `set` writes nothing.
504///
505/// A PROJECT write is validated as this repo's full merge (defaults <- global
506/// <- candidate project) — exactly the load path the engine takes here.
507///
508/// A GLOBAL write is validated TWICE:
509/// 1. standalone (defaults <- candidate global) — what every OTHER repo,
510///    without this repo's project overrides, would load. Skipping this let a
511///    project override mask an invalid global value: the write "succeeded"
512///    here and poisoned every repo lacking the override.
513/// 2. merged with this repo's project layer, like any other write.
514///
515/// Either failure refuses the write, with the failing combination named.
516fn validate_candidate(
517    layers: &Layers,
518    target: &Path,
519    candidate: &serde_json::Map<String, Value>,
520) -> Result<()> {
521    if layers.global.as_deref() == Some(target) {
522        validate_merged(&[target.to_path_buf()], target, candidate).context(
523            "the global config would be invalid on its own (defaults + global): every \
524             repo without this repo's project overrides would fail to load it",
525        )?;
526    } else {
527        // A PROJECT write is held to the same operator-only key rule the
528        // engine applies at load time. Without this the write would succeed
529        // and then wedge the repo: every later `config::load` would refuse
530        // the file this command just produced.
531        let mut base = serde_json::to_value(MissionConfig::default())?;
532        if let Some(global) = &layers.global {
533            config::deep_merge(&mut base, &Value::Object(read_layer(global)?));
534        }
535        config::check_project_layer_keys(&Value::Object(candidate.clone()), &base, target)?;
536    }
537    validate_merged(&layers.merge_order(), target, candidate).context(
538        "the merged config for this repo (defaults + global + project) would be invalid",
539    )?;
540    Ok(())
541}
542
543/// Deep-merge defaults <- the given layers (the target layer's on-disk
544/// content replaced by `candidate`), then deserialize and run
545/// `config::validate` — exactly the load path the engine takes.
546fn validate_merged(
547    layer_paths: &[PathBuf],
548    target: &Path,
549    candidate: &serde_json::Map<String, Value>,
550) -> Result<()> {
551    let mut merged = serde_json::to_value(MissionConfig::default())?;
552    for path in layer_paths {
553        let layer = if path.as_path() == target {
554            Value::Object(candidate.clone())
555        } else {
556            Value::Object(read_layer(path)?)
557        };
558        config::deep_merge(&mut merged, &layer);
559    }
560    let cfg: MissionConfig = serde_json::from_value(merged)
561        .map_err(|e| anyhow!("merged configuration does not deserialize: {e}"))?;
562    config::validate(&cfg)?;
563    Ok(())
564}
565
566// ---------------------------------------------------------------------------
567// Schema-path validation (a typo'd key must never be a silent no-op)
568// ---------------------------------------------------------------------------
569
570/// The config schema as a JSON tree: a FULLY-POPULATED `MissionConfig`
571/// serialized out. A dotted path is legal iff every segment (the final one
572/// too) exists in this tree — unknown keys are ignored on deserialization,
573/// so without this gate `set worker.mdoel` merges, validates green, prints
574/// success, and changes nothing.
575///
576/// Full population is load-bearing: `default()` leaves the
577/// `skip_serializing_if` options out of its serialization (`claudeBinary`,
578/// `orchestrator.maxTurns`, `maxBudgetUsd`, …) and those are LEGAL keys that
579/// must not be false-rejected — so every `Option` is forced to `Some` first.
580fn schema_tree() -> Value {
581    let mut cfg = MissionConfig {
582        claude_binary: Some(String::new()),
583        ..MissionConfig::default()
584    };
585    for role in [
586        &mut cfg.orchestrator,
587        &mut cfg.worker,
588        &mut cfg.validator_scrutiny,
589        &mut cfg.validator_functional,
590    ] {
591        role.max_turns = Some(0);
592        role.max_budget_usd = Some(0.0);
593    }
594    serde_json::to_value(cfg).expect("MissionConfig always serializes")
595}
596
597/// Refuse a dotted path that does not exist in the config schema.
598///
599/// - An unknown segment names the unknown part and lists the valid keys at
600///   that level (so `max_parallel_workers` points at `maxParallelWorkers`).
601/// - Array-valued keys (`denyPatterns`, `allowValidatorCommands`) are
602///   settable only as a whole: a path THROUGH one (`denyPatterns.0`) is a
603///   clean error, not an attempted merge.
604/// - A path through a scalar (`worker.model.x`) is a clean error too.
605fn check_schema_path(dotted: &str) -> Result<()> {
606    let segments: Vec<&str> = dotted.split('.').collect();
607    if segments.iter().any(|s| s.is_empty()) {
608        bail!("invalid config path {dotted:?} (empty segment)");
609    }
610    let schema = schema_tree();
611    let mut cur = &schema;
612    let mut walked: Vec<&str> = Vec::with_capacity(segments.len());
613    for seg in segments {
614        match cur {
615            Value::Object(map) => match map.get(seg) {
616                Some(next) => {
617                    walked.push(seg);
618                    cur = next;
619                }
620                None => {
621                    let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
622                    keys.sort_unstable();
623                    let level = if walked.is_empty() {
624                        "the top level".to_string()
625                    } else {
626                        format!("`{}`", walked.join("."))
627                    };
628                    bail!(
629                        "unknown config key `{seg}` at {level}; valid keys here: {}",
630                        keys.join(", ")
631                    );
632                }
633            },
634            Value::Array(_) => bail!(
635                "config path `{dotted}`: `{walked}` is an array and can only be set as a \
636                 whole (e.g. `kranz config set {walked} '[\"…\"]'`); its elements are not \
637                 individually addressable",
638                walked = walked.join(".")
639            ),
640            _ => bail!(
641                "config path `{dotted}`: `{}` is a plain value; nothing nests beneath it",
642                walked.join(".")
643            ),
644        }
645    }
646    Ok(())
647}
648
649// ---------------------------------------------------------------------------
650// role (mid-mission config change via the control inbox)
651// ---------------------------------------------------------------------------
652
653/// Parse a role token (case-insensitively) to its canonical spelling — the
654/// same names (and aliases) Slack's `/kranz config` accepts.
655fn parse_role(token: &str) -> Result<&'static str> {
656    ROLES
657        .iter()
658        .copied()
659        .find(|r| r.eq_ignore_ascii_case(token))
660        .ok_or_else(|| {
661            anyhow!(
662                "unknown role {token:?}; expected one of {}",
663                ROLES.join("|")
664            )
665        })
666}
667
668/// Parse an effort token (case-insensitively) to its canonical spelling.
669fn parse_effort(token: &str) -> Result<&'static str> {
670    EFFORTS
671        .iter()
672        .copied()
673        .find(|e| e.eq_ignore_ascii_case(token))
674        .ok_or_else(|| {
675            anyhow!(
676                "invalid effort {token:?}; expected one of {}",
677                EFFORTS.join("|")
678            )
679        })
680}
681
682/// Resolve the target ACTIVE mission (shared engine resolver — the same policy
683/// as Slack: an explicit id must exist and be non-terminal; a bare command
684/// needs exactly one active mission) and enqueue ONE `config-change` control
685/// command carrying the same camelCase patch shape Slack produces
686/// ([`kranz_slack::inbound::config_patch`]). A refused target enqueues
687/// NOTHING. Returns the mission id the change was applied to.
688pub fn role_change(
689    repo: &Path,
690    explicit_mission: Option<&str>,
691    role: &str,
692    model: &str,
693    effort: Option<&str>,
694) -> Result<String> {
695    let mission_id = control::resolve_active_mission(repo, explicit_mission)?;
696    let patch = kranz_slack::inbound::config_patch(role, model, effort)
697        .ok_or_else(|| anyhow!("unknown role `{role}`"))?;
698    let paths = MissionPaths::new(repo, &mission_id);
699    control::enqueue(&paths, &ControlCommand::ConfigChange { patch })
700        .context("enqueue config change")?;
701    Ok(mission_id)
702}
703
704#[cfg(test)]
705mod tests {
706    /// A Slack-bot-token-shaped string assembled at runtime; see the tests
707    /// that use it.
708    fn slack_bot_shaped(tail: &str) -> String {
709        format!("xox{}-{tail}", "b")
710    }
711
712    use super::*;
713    use chrono::Utc;
714    use clap::Parser;
715    use kranz_engine::events::{Event, EventKind};
716    use kranz_engine::types::MissionStatus;
717    use serde_json::json;
718    use tempfile::TempDir;
719
720    // --- helpers ------------------------------------------------------------
721
722    /// Layers rooted in a tempdir: global at `<tmp>/home-config.json`,
723    /// project at `<tmp>/repo/.kranz/config.json`. Nothing touches $HOME.
724    fn temp_layers(tmp: &TempDir) -> Layers {
725        Layers {
726            global: Some(tmp.path().join("home-config.json")),
727            project: tmp.path().join("repo").join(".kranz").join("config.json"),
728        }
729    }
730
731    fn write_json(path: &Path, v: &Value) {
732        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
733        std::fs::write(path, serde_json::to_string_pretty(v).unwrap()).unwrap();
734    }
735
736    fn read_json(path: &Path) -> Value {
737        serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
738    }
739
740    /// Seed a mission's events.jsonl (optionally driven to Complete) so the
741    /// resolver and control inbox behave like a real mission — no backend.
742    fn seed_mission(repo_root: &Path, mission_id: &str, completed: bool) {
743        let paths = MissionPaths::new(repo_root, mission_id);
744        std::fs::create_dir_all(paths.mission_dir()).unwrap();
745        let mut lines = String::new();
746        let created = Event {
747            seq: 1,
748            ts: Utc::now(),
749            mission_id: mission_id.to_string(),
750            kind: EventKind::MissionCreated {
751                goal: "goal".into(),
752                base_branch: "main".into(),
753                mission_branch: format!("kranz/mission-{mission_id}"),
754                config: MissionConfig::default(),
755            },
756        };
757        lines.push_str(&serde_json::to_string(&created).unwrap());
758        lines.push('\n');
759        if completed {
760            let done = Event {
761                seq: 2,
762                ts: Utc::now(),
763                mission_id: mission_id.to_string(),
764                kind: EventKind::MissionCompleted {},
765            };
766            lines.push_str(&serde_json::to_string(&done).unwrap());
767            lines.push('\n');
768        }
769        std::fs::write(paths.events_file(), lines).unwrap();
770    }
771
772    fn drained(repo: &Path, mission: &str) -> Vec<ControlCommand> {
773        control::drain(&MissionPaths::new(repo, mission))
774            .unwrap()
775            .into_iter()
776            .map(|(_, cmd)| cmd)
777            .collect()
778    }
779
780    // --- argument parsing ----------------------------------------------------
781
782    #[test]
783    fn parses_config_subcommands() {
784        use crate::cli::{Cli, Command};
785
786        let cli = Cli::try_parse_from(["kranz", "config", "show"]).unwrap();
787        let Command::Config {
788            command:
789                ConfigCommand::Show {
790                    global,
791                    project,
792                    show_secrets,
793                },
794        } = cli.command
795        else {
796            panic!("expected config show");
797        };
798        // Redaction is the DEFAULT: printing secrets takes an explicit flag.
799        assert!(!global && !project && !show_secrets);
800
801        let cli =
802            Cli::try_parse_from(["kranz", "config", "show", "--global", "--show-secrets"]).unwrap();
803        let Command::Config {
804            command: ConfigCommand::Show { show_secrets, .. },
805        } = cli.command
806        else {
807            panic!("expected config show");
808        };
809        assert!(show_secrets);
810
811        let cli =
812            Cli::try_parse_from(["kranz", "config", "set", "worker.model", "opus", "--global"])
813                .unwrap();
814        let Command::Config {
815            command:
816                ConfigCommand::Set {
817                    path,
818                    value,
819                    global,
820                },
821        } = cli.command
822        else {
823            panic!("expected config set");
824        };
825        assert_eq!(
826            (path.as_str(), value.as_str(), global),
827            ("worker.model", "opus", true)
828        );
829
830        let cli = Cli::try_parse_from(["kranz", "config", "unset", "worker.model"]).unwrap();
831        assert!(matches!(
832            cli.command,
833            Command::Config { command: ConfigCommand::Unset { ref path, global: false } }
834                if path == "worker.model"
835        ));
836
837        // `role` targets a mission via the GLOBAL --mission flag.
838        let cli = Cli::try_parse_from([
839            "kranz",
840            "config",
841            "role",
842            "worker",
843            "opus",
844            "high",
845            "--mission",
846            "m-1",
847        ])
848        .unwrap();
849        assert_eq!(cli.mission.as_deref(), Some("m-1"));
850        let Command::Config {
851            command:
852                ConfigCommand::Role {
853                    role,
854                    model,
855                    effort,
856                },
857        } = cli.command
858        else {
859            panic!("expected config role");
860        };
861        assert_eq!(
862            (role.as_str(), model.as_str(), effort.as_deref()),
863            ("worker", "opus", Some("high"))
864        );
865
866        // --global and --project are mutually exclusive on show.
867        assert!(Cli::try_parse_from(["kranz", "config", "show", "--global", "--project"]).is_err());
868    }
869
870    // --- show -----------------------------------------------------------------
871
872    #[test]
873    fn show_effective_reflects_a_project_override() {
874        let tmp = TempDir::new().unwrap();
875        let layers = temp_layers(&tmp);
876        write_json(
877            &layers.project,
878            &json!({ "worker": { "model": "my-custom-model" } }),
879        );
880
881        let rendered = render_effective(&layers.merge_order_with_roles()).unwrap();
882        let effective: Value = serde_json::from_str(&rendered).unwrap();
883        assert_eq!(
884            effective["worker"]["model"], "my-custom-model",
885            "override applied"
886        );
887        // Untouched keys come from the compiled-in defaults.
888        assert_eq!(effective["orchestrator"]["model"], "opus");
889        assert_eq!(effective["worker"]["reasoningEffort"], "medium");
890    }
891
892    // --- single-layer redaction (audit 2026-09-01, MEDIUM `config show --global`) ---
893
894    /// The global layer is the documented home for real Slack credentials and
895    /// the github webhook secret, and a single-layer render prints the RAW
896    /// file rather than a `MissionConfig` round-trip — so the keys the engine
897    /// type never sees went to stdout verbatim, into CI logs, screen shares
898    /// and shell transcripts. Redaction is the default; `--show-secrets` is
899    /// the opt-in.
900    #[test]
901    fn show_single_layer_redacts_secret_shaped_values_by_default() {
902        // The fixture is assembled at runtime, key names included, so the
903        // repository's own secret scanner does not trip on a token-shaped
904        // literal or a `<secret-ish key>: <value>` pair in a test about
905        // redacting exactly those.
906        let bot_value = slack_bot_shaped("real-token");
907        let app_value = format!("xapp-{}", "1-real");
908        let webhook_hmac = format!("webhook-{}", "hmac-key");
909        let tmp = TempDir::new().unwrap();
910        let layers = temp_layers(&tmp);
911        let global = layers.global.as_deref().unwrap();
912        let layer_text = format!(
913            r#"{{"slack":{{"bot{k}":"{bot_value}","app{k}":"{app_value}"}},"hooks":{{"{h}":"{webhook_hmac}"}},"worker":{{"model":"opus"}}}}"#,
914            k = "Token",
915            h = "secret",
916        );
917        std::fs::write(global, layer_text).unwrap();
918
919        let (rendered, exists) = render_layer_file_redacted(global).unwrap();
920        assert!(exists);
921        let value: Value = serde_json::from_str(&rendered).unwrap();
922        assert_eq!(value["slack"]["botToken"], "[REDACTED]");
923        // `xapp-…` app-level tokens match no value-shape rule, which is why
924        // the key-name rule carries this one.
925        assert_eq!(value["slack"]["appToken"], "[REDACTED]");
926        assert_eq!(value["hooks"]["secret"], "[REDACTED]");
927        // Non-secret keys are untouched, so the output stays useful.
928        assert_eq!(value["worker"]["model"], "opus");
929        assert!(
930            !rendered.contains(&bot_value) && !rendered.contains(&webhook_hmac),
931            "no secret byte may survive: {rendered}"
932        );
933
934        // The opt-in prints them verbatim.
935        let (raw, _) = render_layer_file(global).unwrap();
936        assert!(raw.contains(&bot_value));
937    }
938
939    /// A credential parked under an innocuous key still goes through the
940    /// engine's scrubber, so the key-name rule is a floor and not the whole
941    /// defence.
942    #[test]
943    fn show_single_layer_scrubs_secret_shaped_values_under_innocuous_keys() {
944        let tmp = TempDir::new().unwrap();
945        let layers = temp_layers(&tmp);
946        let global = layers.global.as_deref().unwrap();
947        write_json(
948            global,
949            &json!({ "note": format!("deploy with {}", slack_bot_shaped("1234567890-abcdef")) }),
950        );
951
952        let (rendered, _) = render_layer_file_redacted(global).unwrap();
953        assert!(
954            !rendered.contains(&slack_bot_shaped("1234567890-abcdef")),
955            "the scrubber must catch a credential under a non-secret key: {rendered}"
956        );
957    }
958
959    #[test]
960    fn secret_key_names_are_matched_by_suffix_case_insensitively() {
961        for name in [
962            "botToken",
963            "appToken",
964            "secret",
965            "Secret",
966            "apiKey",
967            "API_KEY",
968            "password",
969            "dbPassword",
970        ] {
971            assert!(is_secret_key(name), "{name} must be treated as a secret");
972        }
973        for name in ["model", "packDir", "maxTurns", "enforce"] {
974            assert!(!is_secret_key(name), "{name} must not be redacted");
975        }
976    }
977
978    // --- operator-only keys are refused at the project-layer write (audit H1) ---
979
980    /// Writing an operator-only key into the project layer would succeed and
981    /// then wedge the repo: every later `config::load` refuses the file this
982    /// command just produced. Refuse at the write instead, and leave the file
983    /// untouched.
984    #[test]
985    fn set_refuses_an_operator_only_key_in_the_project_layer() {
986        let tmp = TempDir::new().unwrap();
987        let layers = temp_layers(&tmp);
988
989        let err = format!(
990            "{:#}",
991            set_key(&layers, false, "claudeBinary", "/usr/local/bin/claude").unwrap_err()
992        );
993        assert!(err.contains("claudeBinary"), "key named: {err}");
994        assert!(err.contains("project config layer"), "layer named: {err}");
995        assert!(
996            !layers.project.exists(),
997            "a refused write must not materialize the file"
998        );
999
1000        // The same key in the operator's own layer is fine.
1001        set_key(&layers, true, "claudeBinary", "/usr/local/bin/claude").unwrap();
1002    }
1003
1004    #[test]
1005    fn show_single_layer_renders_the_file_or_empty_object() {
1006        let tmp = TempDir::new().unwrap();
1007        let layers = temp_layers(&tmp);
1008        write_json(&layers.project, &json!({ "skipScrutiny": true }));
1009
1010        let (json_text, exists) = render_layer_file(&layers.project).unwrap();
1011        assert!(exists);
1012        assert_eq!(
1013            serde_json::from_str::<Value>(&json_text).unwrap(),
1014            json!({ "skipScrutiny": true })
1015        );
1016
1017        let (json_text, exists) = render_layer_file(layers.global.as_deref().unwrap()).unwrap();
1018        assert!(!exists, "absent layer reported as missing");
1019        assert_eq!(json_text, "{}\n");
1020    }
1021
1022    // --- set --------------------------------------------------------------------
1023
1024    #[test]
1025    fn set_changes_only_the_dotted_key_and_preserves_unknown_keys() {
1026        let tmp = TempDir::new().unwrap();
1027        let layers = temp_layers(&tmp);
1028        write_json(
1029            &layers.project,
1030            &json!({
1031                "worker": { "model": "opus", "maxTurns": 30 },
1032                "unknownTopLevel": { "nested": [1, 2, 3] }
1033            }),
1034        );
1035
1036        let written = set_key(&layers, false, "worker.model", "sonnet").unwrap();
1037        assert_eq!(written, layers.project);
1038        assert_eq!(
1039            read_json(&layers.project),
1040            json!({
1041                "worker": { "model": "sonnet", "maxTurns": 30 },
1042                "unknownTopLevel": { "nested": [1, 2, 3] }
1043            }),
1044            "only worker.model changed; siblings and unknown keys intact"
1045        );
1046    }
1047
1048    /// A consent-bearing key written into the repository's own layer would be
1049    /// refused at load (`config::PROJECT_LAYER_REFUSED`), so `set` refuses it
1050    /// up front and names the key; the global layer takes it.
1051    #[test]
1052    fn set_refuses_operator_only_keys_on_the_project_layer() {
1053        let tmp = TempDir::new().unwrap();
1054        let layers = temp_layers(&tmp);
1055        let err = format!(
1056            "{:#}",
1057            set_key(&layers, false, "skipScrutiny", "true").unwrap_err()
1058        );
1059        assert!(err.contains("skipScrutiny"), "{err}");
1060        assert!(!layers.project.exists(), "nothing written on refusal");
1061        set_key(&layers, true, "skipScrutiny", "true").unwrap();
1062    }
1063
1064    #[test]
1065    fn set_parses_json_values_and_falls_back_to_string() {
1066        let tmp = TempDir::new().unwrap();
1067        let layers = temp_layers(&tmp);
1068
1069        set_key(&layers, false, "worker.maxTurns", "12").unwrap();
1070        set_key(&layers, false, "autoWork", "true").unwrap();
1071        set_key(&layers, false, "worker.model", "opus").unwrap();
1072        assert_eq!(
1073            read_json(&layers.project),
1074            json!({ "worker": { "maxTurns": 12, "model": "opus" }, "autoWork": true }),
1075            "12 is a number, true a bool, opus a bare string"
1076        );
1077    }
1078
1079    #[test]
1080    fn set_invalid_effort_writes_nothing_and_leaves_the_file_byte_identical() {
1081        let tmp = TempDir::new().unwrap();
1082        let layers = temp_layers(&tmp);
1083        write_json(
1084            &layers.project,
1085            &json!({ "worker": { "model": "opus" }, "keep": 1 }),
1086        );
1087        let before = std::fs::read(&layers.project).unwrap();
1088
1089        let err = set_key(&layers, false, "worker.reasoningEffort", "turbo")
1090            .unwrap_err()
1091            .to_string();
1092        assert!(err.contains("refusing to write"), "{err}");
1093        assert_eq!(
1094            std::fs::read(&layers.project).unwrap(),
1095            before,
1096            "file byte-identical"
1097        );
1098    }
1099
1100    #[test]
1101    fn set_non_numeric_max_parallel_workers_is_rejected_without_creating_the_file() {
1102        let tmp = TempDir::new().unwrap();
1103        let layers = temp_layers(&tmp);
1104
1105        // "abc" fails deserialization; 99 deserializes but fails validation.
1106        assert!(set_key(&layers, false, "maxParallelWorkers", "abc").is_err());
1107        assert!(set_key(&layers, false, "maxParallelWorkers", "99").is_err());
1108        assert!(!layers.project.exists(), "no file materialized on failure");
1109
1110        // The valid twin lands.
1111        set_key(&layers, false, "maxParallelWorkers", "4").unwrap();
1112        assert_eq!(
1113            read_json(&layers.project),
1114            json!({ "maxParallelWorkers": 4 })
1115        );
1116    }
1117
1118    #[test]
1119    fn set_global_targets_the_global_file_and_project_stays_untouched() {
1120        let tmp = TempDir::new().unwrap();
1121        let layers = temp_layers(&tmp);
1122
1123        set_key(&layers, true, "worker.model", "sonnet").unwrap();
1124        assert_eq!(
1125            read_json(layers.global.as_deref().unwrap()),
1126            json!({ "worker": { "model": "sonnet" } })
1127        );
1128        assert!(
1129            !layers.project.exists(),
1130            "project layer untouched by --global"
1131        );
1132
1133        // No resolvable home directory → --global is an honest error.
1134        let no_home = Layers {
1135            global: None,
1136            project: layers.project.clone(),
1137        };
1138        assert!(set_key(&no_home, true, "worker.model", "opus").is_err());
1139    }
1140
1141    #[test]
1142    fn set_validates_across_layers_not_just_the_edited_file() {
1143        // The project layer sets an invalid effort; fixing an UNRELATED key in
1144        // the project file still merges the bad value → refused. The same key
1145        // set in the GLOBAL layer is masked by the project override → allowed.
1146        let tmp = TempDir::new().unwrap();
1147        let layers = temp_layers(&tmp);
1148        write_json(
1149            &layers.project,
1150            &json!({ "worker": { "reasoningEffort": "warp" } }),
1151        );
1152
1153        assert!(set_key(&layers, false, "autoWork", "true").is_err());
1154
1155        write_json(
1156            &layers.project,
1157            &json!({ "worker": { "reasoningEffort": "high" } }),
1158        );
1159        // Global carries a bad effort, but the project layer wins the merge.
1160        write_json(
1161            layers.global.as_deref().unwrap(),
1162            &json!({ "worker": { "reasoningEffort": "warp" } }),
1163        );
1164        set_key(&layers, false, "autoWork", "true").unwrap();
1165    }
1166
1167    // --- set --global (finding C: both standalone and merged must hold) ----------
1168
1169    #[test]
1170    fn set_global_invalid_value_masked_by_a_project_override_is_refused() {
1171        // This repo's project layer masks the bad value, so a merged-only
1172        // validation passes — and the write would poison every OTHER repo
1173        // that lacks the override. The global layer must also validate
1174        // standalone (defaults + candidate global).
1175        let tmp = TempDir::new().unwrap();
1176        let layers = temp_layers(&tmp);
1177        write_json(
1178            &layers.project,
1179            &json!({ "worker": { "reasoningEffort": "high" } }),
1180        );
1181
1182        let err = format!(
1183            "{:#}",
1184            set_key(&layers, true, "worker.reasoningEffort", "warp").unwrap_err()
1185        );
1186        assert!(err.contains("on its own"), "standalone gate named: {err}");
1187        assert!(
1188            !layers.global.as_deref().unwrap().exists(),
1189            "global file never materialized"
1190        );
1191
1192        // The valid twin passes both gates and lands in the global file.
1193        set_key(&layers, true, "worker.reasoningEffort", "low").unwrap();
1194        assert_eq!(
1195            read_json(layers.global.as_deref().unwrap()),
1196            json!({ "worker": { "reasoningEffort": "low" } })
1197        );
1198    }
1199
1200    #[test]
1201    fn set_global_names_the_merged_gate_when_this_repo_is_what_breaks() {
1202        // The candidate global is fine standalone, but this repo's project
1203        // layer is broken: the refusal must blame the merged combination.
1204        let tmp = TempDir::new().unwrap();
1205        let layers = temp_layers(&tmp);
1206        write_json(
1207            &layers.project,
1208            &json!({ "worker": { "reasoningEffort": "warp" } }),
1209        );
1210
1211        let err = format!(
1212            "{:#}",
1213            set_key(&layers, true, "skipScrutiny", "true").unwrap_err()
1214        );
1215        assert!(err.contains("this repo"), "merged gate named: {err}");
1216        assert!(!err.contains("on its own"), "standalone gate passed: {err}");
1217        assert!(!layers.global.as_deref().unwrap().exists());
1218    }
1219
1220    // --- write_layer atomicity (finding D) ----------------------------------------
1221
1222    #[cfg(unix)]
1223    #[test]
1224    fn set_replaces_the_file_via_rename_and_preserves_permissions() {
1225        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1226        let tmp = TempDir::new().unwrap();
1227        let layers = temp_layers(&tmp);
1228        write_json(&layers.project, &json!({ "worker": { "model": "opus" } }));
1229        std::fs::set_permissions(&layers.project, std::fs::Permissions::from_mode(0o600)).unwrap();
1230        let before_ino = std::fs::metadata(&layers.project).unwrap().ino();
1231
1232        set_key(&layers, false, "worker.model", "sonnet").unwrap();
1233
1234        let meta = std::fs::metadata(&layers.project).unwrap();
1235        assert_ne!(
1236            meta.ino(),
1237            before_ino,
1238            "the file must be REPLACED by rename, never truncated in place — a \
1239             concurrent config::load (serve create, kranz work, slack bridge) must \
1240             never observe a torn/empty file"
1241        );
1242        assert_eq!(
1243            meta.permissions().mode() & 0o777,
1244            0o600,
1245            "permissions carried over"
1246        );
1247        assert_eq!(
1248            read_json(&layers.project),
1249            json!({ "worker": { "model": "sonnet" } })
1250        );
1251
1252        // No tmp litter left beside the target.
1253        let leftovers: Vec<String> = std::fs::read_dir(layers.project.parent().unwrap())
1254            .unwrap()
1255            .flatten()
1256            .map(|e| e.file_name().to_string_lossy().into_owned())
1257            .filter(|n| n.ends_with(".tmp"))
1258            .collect();
1259        assert!(leftovers.is_empty(), "tmp litter: {leftovers:?}");
1260    }
1261
1262    // --- schema path check (finding E: typos must never be silent no-ops) --------
1263
1264    #[test]
1265    fn set_typo_key_is_refused_naming_the_unknown_part_and_that_levels_keys() {
1266        let tmp = TempDir::new().unwrap();
1267        let layers = temp_layers(&tmp);
1268
1269        // Nested typo: names the segment, the level, and its valid keys.
1270        let err = format!(
1271            "{:#}",
1272            set_key(&layers, false, "worker.mdoel", "opus").unwrap_err()
1273        );
1274        assert!(err.contains("`mdoel`"), "unknown part named: {err}");
1275        assert!(err.contains("`worker`"), "level named: {err}");
1276        assert!(
1277            err.contains("model") && err.contains("reasoningEffort"),
1278            "valid keys at that level listed: {err}"
1279        );
1280
1281        // Top-level snake_case typo: the camelCase twin is in the listing.
1282        let err = format!(
1283            "{:#}",
1284            set_key(&layers, false, "max_parallel_workers", "4").unwrap_err()
1285        );
1286        assert!(err.contains("`max_parallel_workers`"), "{err}");
1287        assert!(err.contains("top level"), "{err}");
1288        assert!(
1289            err.contains("maxParallelWorkers"),
1290            "camelCase twin listed: {err}"
1291        );
1292
1293        // Wrong case is an unknown key, not a match.
1294        assert!(set_key(&layers, false, "Worker.model", "opus").is_err());
1295        assert!(set_key(&layers, false, "worker.Model", "opus").is_err());
1296
1297        assert!(
1298            !layers.project.exists(),
1299            "no typo'd set ever materialized the file"
1300        );
1301    }
1302
1303    #[test]
1304    fn set_accepts_optional_keys_that_default_serialization_omits() {
1305        // claudeBinary and orchestrator.maxTurns are None in default() and so
1306        // absent from a default() serialization — they are legal keys and
1307        // must not be false-rejected by the schema gate. claudeBinary is
1308        // OPERATOR-ONLY (audit H1), so its write goes to the global layer.
1309        let tmp = TempDir::new().unwrap();
1310        let layers = temp_layers(&tmp);
1311
1312        set_key(&layers, true, "claudeBinary", "/usr/local/bin/claude").unwrap();
1313        set_key(&layers, false, "orchestrator.maxTurns", "33").unwrap();
1314        set_key(&layers, false, "orchestrator.maxBudgetUsd", "12.5").unwrap();
1315        assert_eq!(
1316            read_json(layers.global.as_deref().unwrap()),
1317            json!({ "claudeBinary": "/usr/local/bin/claude" })
1318        );
1319        assert_eq!(
1320            read_json(&layers.project),
1321            json!({ "orchestrator": { "maxTurns": 33, "maxBudgetUsd": 12.5 } })
1322        );
1323    }
1324
1325    #[test]
1326    fn set_array_keys_work_as_a_whole_but_paths_through_them_are_refused() {
1327        let tmp = TempDir::new().unwrap();
1328        let layers = temp_layers(&tmp);
1329
1330        // `denyPatterns` is operator-only since the 2026-09-01 audit, so the
1331        // array-handling contract is exercised on the global layer.
1332        set_key(&layers, true, "denyPatterns", r#"["rm -rf"]"#).unwrap();
1333        assert_eq!(
1334            read_json(layers.global.as_deref().unwrap()),
1335            json!({ "denyPatterns": ["rm -rf"] })
1336        );
1337
1338        let err = format!(
1339            "{:#}",
1340            set_key(&layers, true, "denyPatterns.0", "x").unwrap_err()
1341        );
1342        assert!(err.contains("array") && err.contains("as a whole"), "{err}");
1343        let err = format!(
1344            "{:#}",
1345            set_key(&layers, false, "allowValidatorCommands.2.cmd", "x").unwrap_err()
1346        );
1347        assert!(
1348            err.contains("array"),
1349            "deep paths through arrays refused too: {err}"
1350        );
1351    }
1352
1353    #[test]
1354    fn set_paths_through_scalars_are_refused_cleanly() {
1355        let tmp = TempDir::new().unwrap();
1356        let layers = temp_layers(&tmp);
1357
1358        let err = format!(
1359            "{:#}",
1360            set_key(&layers, false, "worker.model.x", "y").unwrap_err()
1361        );
1362        assert!(
1363            err.contains("worker.model") && err.contains("plain value"),
1364            "clean error, no object-over-scalar clobber: {err}"
1365        );
1366        assert!(!layers.project.exists());
1367    }
1368
1369    #[test]
1370    fn unset_typo_key_gets_the_same_schema_error_not_a_not_set_one() {
1371        let tmp = TempDir::new().unwrap();
1372        let layers = temp_layers(&tmp);
1373        write_json(&layers.project, &json!({ "worker": { "model": "opus" } }));
1374        let before = std::fs::read(&layers.project).unwrap();
1375
1376        let err = format!(
1377            "{:#}",
1378            unset_key(&layers, false, "worker.mdoel").unwrap_err()
1379        );
1380        assert!(
1381            err.contains("unknown config key"),
1382            "schema error, not 'not set': {err}"
1383        );
1384        assert!(err.contains("`mdoel`"), "{err}");
1385        assert_eq!(
1386            std::fs::read(&layers.project).unwrap(),
1387            before,
1388            "file untouched"
1389        );
1390    }
1391
1392    // --- unset ------------------------------------------------------------------
1393
1394    #[test]
1395    fn unset_removes_the_key_and_preserves_siblings() {
1396        let tmp = TempDir::new().unwrap();
1397        let layers = temp_layers(&tmp);
1398        write_json(
1399            &layers.project,
1400            &json!({ "worker": { "model": "opus", "maxTurns": 9 }, "skipScrutiny": true }),
1401        );
1402
1403        unset_key(&layers, false, "worker.model").unwrap();
1404        assert_eq!(
1405            read_json(&layers.project),
1406            json!({ "worker": { "maxTurns": 9 }, "skipScrutiny": true })
1407        );
1408    }
1409
1410    #[test]
1411    fn unset_prunes_parents_left_empty_and_last_key_leaves_empty_object() {
1412        let tmp = TempDir::new().unwrap();
1413        let layers = temp_layers(&tmp);
1414        write_json(
1415            &layers.project,
1416            &json!({ "worker": { "model": "opus" }, "skipScrutiny": true }),
1417        );
1418
1419        unset_key(&layers, false, "worker.model").unwrap();
1420        assert_eq!(
1421            read_json(&layers.project),
1422            json!({ "skipScrutiny": true }),
1423            "empty worker object pruned"
1424        );
1425
1426        unset_key(&layers, false, "skipScrutiny").unwrap();
1427        assert_eq!(
1428            std::fs::read_to_string(&layers.project).unwrap(),
1429            "{}\n",
1430            "last key removed leaves an empty object; the file stays"
1431        );
1432    }
1433
1434    #[test]
1435    fn unset_missing_key_is_an_error_and_writes_nothing() {
1436        let tmp = TempDir::new().unwrap();
1437        let layers = temp_layers(&tmp);
1438        write_json(&layers.project, &json!({ "skipScrutiny": true }));
1439        let before = std::fs::read(&layers.project).unwrap();
1440
1441        let err = unset_key(&layers, false, "worker.model")
1442            .unwrap_err()
1443            .to_string();
1444        assert!(err.contains("worker.model"), "{err}");
1445        assert_eq!(std::fs::read(&layers.project).unwrap(), before);
1446    }
1447
1448    // --- role (mid-mission control command) --------------------------------------
1449
1450    #[test]
1451    fn role_enqueues_exactly_one_config_change_with_the_slack_patch_shape() {
1452        let tmp = TempDir::new().unwrap();
1453        seed_mission(tmp.path(), "m-cfg", false);
1454
1455        let applied =
1456            role_change(tmp.path(), Some("m-cfg"), "scrutiny", "opus", Some("high")).unwrap();
1457        assert_eq!(applied, "m-cfg");
1458
1459        let cmds = drained(tmp.path(), "m-cfg");
1460        assert_eq!(cmds.len(), 1, "exactly one control command enqueued");
1461        match &cmds[0] {
1462            ControlCommand::ConfigChange { patch } => {
1463                assert_eq!(
1464                    *patch,
1465                    json!({ "validatorScrutiny": { "model": "opus", "reasoningEffort": "high" } }),
1466                    "camelCase patch shape"
1467                );
1468                // Same shape Slack produces, by construction AND by assertion.
1469                assert_eq!(
1470                    *patch,
1471                    kranz_slack::inbound::config_patch("scrutiny", "opus", Some("high")).unwrap()
1472                );
1473            }
1474            other => panic!("expected ConfigChange, got {other:?}"),
1475        }
1476    }
1477
1478    #[test]
1479    fn role_bare_targets_the_single_active_mission_and_omits_effort() {
1480        let tmp = TempDir::new().unwrap();
1481        seed_mission(tmp.path(), "m-only", false);
1482
1483        let applied = role_change(tmp.path(), None, "worker", "sonnet", None).unwrap();
1484        assert_eq!(applied, "m-only");
1485        match &drained(tmp.path(), "m-only")[0] {
1486            ControlCommand::ConfigChange { patch } => {
1487                assert_eq!(*patch, json!({ "worker": { "model": "sonnet" } }));
1488            }
1489            other => panic!("expected ConfigChange, got {other:?}"),
1490        }
1491    }
1492
1493    #[test]
1494    fn role_refuses_an_ambiguous_target_and_enqueues_nothing() {
1495        let tmp = TempDir::new().unwrap();
1496        seed_mission(tmp.path(), "m-a", false);
1497        seed_mission(tmp.path(), "m-b", false);
1498
1499        let err = role_change(tmp.path(), None, "worker", "opus", None)
1500            .unwrap_err()
1501            .to_string();
1502        assert!(err.contains("several active missions"), "{err}");
1503        for id in ["m-a", "m-b"] {
1504            assert!(
1505                drained(tmp.path(), id).is_empty(),
1506                "nothing enqueued on {id}"
1507            );
1508        }
1509    }
1510
1511    #[test]
1512    fn role_refuses_a_terminal_mission_and_enqueues_nothing() {
1513        let tmp = TempDir::new().unwrap();
1514        seed_mission(tmp.path(), "m-done", true);
1515
1516        let err = role_change(tmp.path(), Some("m-done"), "worker", "opus", None)
1517            .unwrap_err()
1518            .to_string();
1519        assert!(
1520            err.contains("active missions"),
1521            "honest error, not false success: {err}"
1522        );
1523        assert!(
1524            drained(tmp.path(), "m-done").is_empty(),
1525            "no control file leaked"
1526        );
1527    }
1528
1529    #[test]
1530    fn role_unknown_role_or_effort_is_refused_before_anything_happens() {
1531        assert!(parse_role("manager").is_err());
1532        assert!(parse_effort("turbo").is_err());
1533        // Case-insensitive canonicalization, mirroring Slack's parser.
1534        assert_eq!(parse_role("WORKER").unwrap(), "worker");
1535        assert_eq!(parse_role("Functional").unwrap(), "functional");
1536        assert_eq!(parse_effort("XHIGH").unwrap(), "xhigh");
1537    }
1538
1539    #[test]
1540    fn role_unknown_mission_is_an_error() {
1541        let tmp = TempDir::new().unwrap();
1542        let err = role_change(tmp.path(), Some("m-nope"), "worker", "opus", None)
1543            .unwrap_err()
1544            .to_string();
1545        assert!(err.contains("m-nope"), "{err}");
1546    }
1547
1548    // --- resolver sanity (the hoisted engine function is what role uses) ---------
1549
1550    #[test]
1551    fn seeded_missions_fold_to_the_expected_statuses() {
1552        // Guards the seed helpers: if these drift, the resolver tests above
1553        // would silently test the wrong thing.
1554        let tmp = TempDir::new().unwrap();
1555        seed_mission(tmp.path(), "m-live", false);
1556        seed_mission(tmp.path(), "m-done", true);
1557        let fold = |id: &str| {
1558            let events = kranz_engine::event_log::EventLog::read_events(
1559                &MissionPaths::new(tmp.path(), id).events_file(),
1560            )
1561            .unwrap();
1562            kranz_engine::reducer::fold(&events).unwrap().mission.status
1563        };
1564        assert_eq!(fold("m-live"), MissionStatus::Planning);
1565        assert_eq!(fold("m-done"), MissionStatus::Complete);
1566    }
1567}