1use 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#[derive(Subcommand, Debug)]
46pub enum ConfigCommand {
47 Show {
58 #[arg(long, conflicts_with = "project")]
60 global: bool,
61
62 #[arg(long)]
64 project: bool,
65
66 #[arg(long)]
68 show_secrets: bool,
69 },
70
71 Set {
81 path: String,
83
84 value: String,
86
87 #[arg(long)]
89 global: bool,
90 },
91
92 Unset {
97 path: String,
99
100 #[arg(long)]
102 global: bool,
103 },
104
105 Role {
114 role: String,
116
117 model: String,
119
120 effort: Option<String>,
122 },
123}
124
125const ROLES: [&str; 4] = ["orchestrator", "worker", "scrutiny", "functional"];
128
129const EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
131
132pub 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
203pub struct Layers {
210 pub global: Option<PathBuf>,
212 pub project: PathBuf,
214}
215
216impl Layers {
217 pub fn resolve(repo: &Path) -> Self {
219 Layers {
220 global: paths::global_config(),
221 project: paths::project_config(repo),
222 }
223 }
224
225 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 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 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
259pub 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
268pub 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
286pub 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
311const SECRET_KEY_SUFFIXES: [&str; 4] = ["token", "secret", "key", "password"];
313
314fn 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
350pub 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
371pub 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
387fn parse_value(raw: &str) -> Value {
390 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
391}
392
393fn 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
414fn 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 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
455fn 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
480fn 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
503fn 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 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
543fn 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
566fn 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
597fn 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
649fn 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
668fn 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
682pub 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 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 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 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 #[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 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 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 assert!(Cli::try_parse_from(["kranz", "config", "show", "--global", "--project"]).is_err());
868 }
869
870 #[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 assert_eq!(effective["orchestrator"]["model"], "opus");
889 assert_eq!(effective["worker"]["reasoningEffort"], "medium");
890 }
891
892 #[test]
901 fn show_single_layer_redacts_secret_shaped_values_by_default() {
902 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 assert_eq!(value["slack"]["appToken"], "[REDACTED]");
926 assert_eq!(value["hooks"]["secret"], "[REDACTED]");
927 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 let (raw, _) = render_layer_file(global).unwrap();
936 assert!(raw.contains(&bot_value));
937 }
938
939 #[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 #[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 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 #[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 #[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 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 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 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 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 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 #[test]
1170 fn set_global_invalid_value_masked_by_a_project_override_is_refused() {
1171 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 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 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 #[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 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 #[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 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 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 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 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 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 #[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 #[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 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 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 #[test]
1551 fn seeded_missions_fold_to_the_expected_statuses() {
1552 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}