mur_common/config/ops.rs
1use super::*;
2
3/// Pre-dispatch triage settings (`triage:` in `~/.mur/config.yaml`).
4///
5/// Both knobs default to the passive posture, and `enforce` in particular is
6/// off for a reason that is not caution: `triage_calibration` cannot score a
7/// decision that prevented a run. Enforcing from the start produces only
8/// unfalsifiable `held_back` records, so there would never be evidence that
9/// enforcing was correct. Shadow mode records the same predictions AND lets
10/// the run happen, which is what makes `Calibration::shadow_precision`
11/// computable — turn `enforce` on once that number says triage is right often
12/// enough to be worth the refusals.
13#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
14pub struct TriageConfig {
15 /// Run triage before dispatch at all. Off = not even the free prefilter
16 /// runs, and no verdicts are recorded.
17 #[serde(default)]
18 pub enabled: bool,
19 /// Let a triage decision actually stop a dispatch. Requires `enabled`.
20 /// See the type docs before turning this on.
21 #[serde(default)]
22 pub enforce: bool,
23}
24
25impl TriageConfig {
26 /// Does triage actually bind here? `enforce` alone is inert — with
27 /// `enabled: false` nothing runs to enforce — so the two are resolved in
28 /// one place rather than at each call site, where the pair would
29 /// eventually be got wrong in one of them.
30 pub fn enforces(&self) -> bool {
31 self.enabled && self.enforce
32 }
33}
34
35/// Rotation for `~/.mur/queue/events.jsonl`, in the shape FreeBSD's
36/// `newsyslog(8)` uses: rotate past a size, keep a bounded number of
37/// generations, compress all but the newest, drop the oldest.
38///
39/// The point of generations is that nobody has to decide to delete anything.
40/// A 934 MB queue becomes `.0`, then `.1.gz`, and ages out on a policy the
41/// user set rather than on a judgement call someone makes once.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct CaptureConfig {
44 /// Rotate once the live file passes this. 64 MB keeps `mur hook stats`
45 /// responsive — parsing is O(file), and 934 MB took minutes.
46 #[serde(default = "default_rotate_at_mb")]
47 pub rotate_at_mb: u64,
48 /// How many rotated generations to keep. `.0` stays uncompressed like
49 /// newsyslog's, the rest are gzipped.
50 #[serde(default = "default_keep_generations")]
51 pub keep_generations: u32,
52}
53
54pub(super) fn default_rotate_at_mb() -> u64 {
55 64
56}
57
58fn default_keep_generations() -> u32 {
59 5
60}
61
62impl Default for CaptureConfig {
63 fn default() -> Self {
64 Self {
65 rotate_at_mb: default_rotate_at_mb(),
66 keep_generations: default_keep_generations(),
67 }
68 }
69}
70
71/// Post-upgrade settings for `mur update`. Stored under `update:` in
72/// `~/.mur/config.yaml`.
73#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
74pub struct UpdateConfig {
75 /// macOS code-signing identity used to re-sign installed binaries after an
76 /// upgrade. Keychain grants bind to the signing identity; fresh installs
77 /// are ad-hoc (new CDHash per build), so without a stable identity every
78 /// upgrade kills the grants and service-launched agents fail silently
79 /// (#849/#866). Fallback: the `MUR_CODESIGN_IDENTITY` env var.
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub codesign_identity: Option<String>,
82 /// Agent names `mur update --restart-agents` must never touch.
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub restart_exclude: Vec<String>,
85}
86
87/// Which notification channels the durable monitor may use. `log` is not
88/// listed: it is always on and cannot be disabled, because a notable event
89/// must leave a trace somewhere even when a user has turned everything off.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct NotificationsConfig {
92 /// OS desktop notification. Opt-in: a background daemon that starts
93 /// popping banners on upgrade is a hostile default.
94 #[serde(default)]
95 pub desktop: bool,
96}
97
98/// Whether the durable monitor may ask a model what to do about a terminal
99/// failure the structured rules did not settle.
100///
101/// Off by default, and not merely as a courtesy: enabling it lets a
102/// background daemon send monitor context to a model on its own schedule,
103/// with no one watching. Nobody watching is not permission, so this is a
104/// decision a user makes once, explicitly, rather than something an upgrade
105/// makes for them.
106///
107/// The bound on how often it may ask is NOT here: one proposal per
108/// observation cycle is an invariant of the design, not a knob (see
109/// `mur_core::monitor::resolver`). A tunable would let a user turn a bounded
110/// feature into an unbounded one.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct MonitorResolverConfig {
113 /// Opt-in. While false, nothing in the resolver path runs and no request
114 /// leaves the machine.
115 #[serde(default)]
116 pub enabled: bool,
117
118 /// Which model to ask. `None` uses the same backend `mur chat` resolves,
119 /// so a user who has already configured one does not configure it twice.
120 #[serde(default)]
121 pub model: Option<String>,
122}
123
124/// Run-status heartbeat tuning. Both values are config, never literals at a
125/// call site: the right interval depends on how long the machine's steps take.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RunsConfig {
128 /// How often `execute_dag` stamps `last_heartbeat_at`.
129 #[serde(default = "default_heartbeat_interval_secs")]
130 pub heartbeat_interval_secs: u64,
131 /// How many missed intervals before a live process counts as `stalled`.
132 /// Three tolerates one lost tick plus scheduling jitter without calling a
133 /// healthy run dead.
134 #[serde(default = "default_heartbeat_stale_after_intervals")]
135 pub heartbeat_stale_after_intervals: u32,
136}
137
138pub(super) fn default_heartbeat_interval_secs() -> u64 {
139 10
140}
141
142pub(super) fn default_heartbeat_stale_after_intervals() -> u32 {
143 3
144}
145
146impl Default for RunsConfig {
147 fn default() -> Self {
148 Self {
149 heartbeat_interval_secs: default_heartbeat_interval_secs(),
150 heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
151 }
152 }
153}
154
155/// Authorization gate for the `parallel_jobs` MCP tool. Stored under `parallel_jobs:`
156/// in `~/.mur/config.yaml`. Deny-by-default: an empty `targets` list means the
157/// tool cannot delegate to ANY agent (inert until the user opts specific
158/// agents in). This is a deterministic, out-of-model gate that a
159/// prompt-injected concierge cannot widen (OWASP Agentic ASI02/03/04).
160#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
161pub struct ParallelJobsConfig {
162 /// Canonical agent names the `parallel_jobs` tool is allowed to delegate to.
163 /// Empty = deny all.
164 #[serde(default)]
165 pub targets: Vec<String>,
166}
167
168// --- memory-federation snapshot (spec 2026-08-04-unified-memory-federation) ---
169
170/// Daemon-side settings for the signed snapshot pull. Stored under
171/// `federation_snapshot:` in `~/.mur/config.yaml`; every field has a default
172/// so an absent block means "defaults", never "off".
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174#[serde(default)]
175pub struct SnapshotConfig {
176 /// How often the daemon sweeps `inbox/snapshot-requests/`, in seconds.
177 pub poll_secs: u64,
178 /// Reject requests older than this (replay blunting), in seconds.
179 pub request_max_age_secs: u64,
180 /// Minimum lifecycle state a global skill needs to enter a snapshot.
181 pub min_lifecycle: crate::skill::stats::LifecycleState,
182}
183
184impl Default for SnapshotConfig {
185 fn default() -> Self {
186 Self {
187 poll_secs: 30,
188 request_max_age_secs: 600,
189 min_lifecycle: crate::skill::stats::LifecycleState::Stable,
190 }
191 }
192}
193
194/// Proactive memory capture (memory federation P2): gates the runtime's
195/// built-in `remember` tool and its system-prompt directive. Stored under
196/// `memory:` in `~/.mur/config.yaml`.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198#[serde(default)]
199pub struct MemoryConfig {
200 pub capture: CaptureMode,
201 /// How many memories may enter the system prompt per turn. Deliberately
202 /// NOT `skills.max_skills_in_prompt`: sharing that budget means saving a
203 /// memory silently evicts a skill, and five is a plausible number of
204 /// standing preferences for one user to have.
205 #[serde(default = "default_memory_in_prompt")]
206 pub max_in_prompt: usize,
207 /// Character ceiling for the whole memory block, spent before skills.
208 #[serde(default = "default_memory_chars")]
209 pub max_chars: usize,
210}
211
212fn default_memory_in_prompt() -> usize {
213 20
214}
215
216fn default_memory_chars() -> usize {
217 1500
218}
219
220impl Default for MemoryConfig {
221 fn default() -> Self {
222 Self {
223 capture: CaptureMode::AutoAnnounce,
224 max_in_prompt: default_memory_in_prompt(),
225 max_chars: default_memory_chars(),
226 }
227 }
228}
229
230/// How agents capture memories mid-conversation.
231#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "snake_case")]
233pub enum CaptureMode {
234 /// Agent asks for a one-line confirmation before saving.
235 Ask,
236 /// Agent saves immediately and announces in the same reply (default).
237 AutoAnnounce,
238 /// The `remember` tool is not registered at all.
239 Off,
240}
241
242/// Authorization gate for the runtime's built-in `fleet_run` tool. Stored under
243/// `fleet_run:` in `~/.mur/config.yaml`. Deny-by-default on BOTH axes: an agent
244/// not named in `agents` never even sees the tool, and a fleet not named in
245/// `fleets` cannot be run. Lives in the global config (not the agent profile)
246/// because the profile is writable by the concierge itself — this gate must be
247/// out of reach of a prompt-injected agent (same rationale as
248/// [`ParallelJobsConfig`]).
249#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
250pub struct FleetRunConfig {
251 /// Canonical agent names allowed to call `fleet_run`. Empty = deny all.
252 #[serde(default)]
253 pub agents: Vec<String>,
254 /// Fleet names those agents may run. Empty = deny all.
255 #[serde(default)]
256 pub fleets: Vec<String>,
257}
258
259/// Gate for the git push broker (`git_push:` in `~/.mur/config.yaml`).
260/// Deny-by-default on BOTH axes, like [`FleetRunConfig`]: `enabled` is the
261/// broker-wide switch (off: the daemon sweeper does not run and no agent gets
262/// anything), and `agents` names the only agents that see `git_push_request` /
263/// `git_push_status` / `git_push_cancel` and get the Landlock read grant on the
264/// registry. An agent not named here — a deep-research worker, say — never sees
265/// the tools, so it neither learns the registry exists nor adds requests to the
266/// human approval queue. Global config, not the agent profile, for the same
267/// reason as [`FleetRunConfig`]: a prompt-injected agent must not widen it.
268#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
269pub struct GitPushConfig {
270 #[serde(default)]
271 pub enabled: bool,
272 /// Canonical agent names allowed the push tools. Empty = deny all.
273 #[serde(default)]
274 pub agents: Vec<String>,
275}
276
277/// Display policy for `mur open`.
278///
279/// Lives in `config.yaml` rather than in `open-items.jsonl` because that log
280/// is append-only and agent-writable via the `open_item` tool. A user's
281/// decision to stop looking at a source must not be overturnable by an agent
282/// appending a record. Same reasoning as `fleet_run.agents`.
283#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
284pub struct OpenItemsConfig {
285 /// Exact `origin` strings to collapse out of `mur open`. Exact match
286 /// only — `fleet` never matches `fleet:acme`.
287 #[serde(default)]
288 pub muted: Vec<String>,
289}
290
291/// Daemon-wide gate for unattended fleet auto-run (`mur-daemon`'s `fleet_tick`).
292/// Stored under `fleet:` in `~/.mur/config.yaml`. Either this flag OR the
293/// `MUR_FLEET_AUTORUN` env var satisfies the gate — both are equally explicit,
294/// off-by-default opt-ins; the env var remains for ops/CI use, this flag is
295/// what the Hub's Settings toggle controls. Per-fleet `budget_usd > 0` and the
296/// `.stopped` kill-switch are unaffected — see `mur-daemon/src/fleet_tick.rs`.
297#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
298pub struct FleetConfig {
299 /// Allow fleets with a trigger + budget configured to auto-run unattended.
300 #[serde(default)]
301 pub autorun: bool,
302}
303
304/// Routing for Anthropic subscription-OAuth (`sk-ant-oat*`) tokens through a
305/// local bridge — cc-proxy — that swaps `x-api-key` for the Bearer +
306/// claude-code betas disguise the upstream requires.
307///
308/// The Hub injects `ANTHROPIC_BASE_URL` pointing at [`url`](Self::url) when it
309/// spawns an agent runtime, but only when [`enabled`](Self::enabled) is set and
310/// the bridge is actually listening; otherwise it leaves the runtime on the
311/// direct `api.anthropic.com` path (where an oat token would 401). A runtime
312/// launched with `ANTHROPIC_BASE_URL` already in its environment is never
313/// overridden.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct CcProxyConfig {
316 /// Bridge base URL. Defaults to cc-proxy's default bind.
317 #[serde(default = "default_cc_proxy_url")]
318 pub url: String,
319
320 /// Master switch. When false the Hub never routes runtimes through the
321 /// bridge, regardless of reachability.
322 #[serde(default = "default_true")]
323 pub enabled: bool,
324}
325
326fn default_cc_proxy_url() -> String {
327 "http://127.0.0.1:8088".to_string()
328}
329
330fn default_true() -> bool {
331 true
332}
333
334impl Default for CcProxyConfig {
335 fn default() -> Self {
336 Self {
337 url: default_cc_proxy_url(),
338 enabled: true,
339 }
340 }
341}
342
343/// Configuration for the agent CLI TUI.
344/// Stored in ~/.mur/config.yaml under the `cli:` key.
345#[derive(Debug, Clone, Serialize, Deserialize, Default)]
346pub struct CliConfig {
347 /// Default visual skin for `mur agent cli`. Overridable with --skin.
348 /// Valid values: "ansi" (default; "dark" is an alias), "light", "mur".
349 pub skin: Option<String>,
350
351 /// True once the one-time "permanent instructions" notice has been shown
352 /// (memories P1 §10).
353 ///
354 /// The notice is informational and must appear at most once — repeating a
355 /// feature announcement every session reads as a warning about something
356 /// wrong. Absent (false) in existing configs, which is correct: a user who
357 /// has never seen it should.
358 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
359 pub seen_permanent_instructions_notice: bool,
360}
361
362/// Configuration for the mobile relay (P4).
363/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
364#[derive(Debug, Clone, Serialize, Deserialize, Default)]
365pub struct MobileRelayConfig {
366 /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
367 /// Leave blank to disable relay forwarding on the Mac daemon side.
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub relay_url: Option<String>,
370
371 /// API key or JWT used by the Mac daemon to authenticate with the relay.
372 /// The value is typically a `mur_...` API key from app.mur.run.
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub api_key: Option<String>,
375}
376
377#[cfg(test)]
378mod fleet_config_tests {
379 use super::*;
380
381 #[test]
382 fn fleet_config_defaults_off_and_roundtrips() {
383 assert!(!FleetConfig::default().autorun);
384
385 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
386 assert!(cfg.fleet.autorun);
387
388 // `fleet:` key entirely absent → defaults to off
389 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
390 assert!(!cfg2.fleet.autorun);
391 }
392
393 #[test]
394 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
395 // Absent section → both allowlists empty → deny all.
396 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
397 assert!(cfg.fleet_run.agents.is_empty());
398 assert!(cfg.fleet_run.fleets.is_empty());
399
400 let cfg2: Config =
401 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
402 .unwrap();
403 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
404 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
405 }
406
407 #[test]
408 fn git_push_config_defaults_off_and_roundtrips() {
409 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
410 assert!(!cfg.git_push.enabled);
411 assert!(cfg.git_push.agents.is_empty());
412 let cfg2: Config =
413 serde_yaml_ng::from_str("git_push:\n enabled: true\n agents: [mur]\n").unwrap();
414 assert!(cfg2.git_push.enabled);
415 assert_eq!(cfg2.git_push.agents, vec!["mur"]);
416 }
417}
418
419#[cfg(test)]
420mod runs_config_tests {
421 use super::*;
422
423 /// A zero `heartbeat_interval_secs` is legal YAML but illegal at runtime:
424 /// it zeroes the stale threshold (every live run instantly reads STALLED)
425 /// and `tokio::time::interval(Duration::ZERO)` panics in the executor's
426 /// ticker. The loader must clamp zeroes to the defaults so one
427 /// user-edited line can neither lie nor crash.
428 #[test]
429 fn zero_heartbeat_values_clamp_to_defaults_at_load() {
430 let tmp = tempfile::tempdir().unwrap();
431 let path = tmp.path().join("config.yaml");
432 std::fs::write(
433 &path,
434 "runs:\n heartbeat_interval_secs: 0\n heartbeat_stale_after_intervals: 0\n",
435 )
436 .unwrap();
437
438 let cfg = Config::load_or_default(&path);
439 assert_eq!(
440 cfg.runs.heartbeat_interval_secs, 10,
441 "a zero interval must load as the default, not 0"
442 );
443 assert_eq!(
444 cfg.runs.heartbeat_stale_after_intervals, 3,
445 "a zero interval count must load as the default, not 0"
446 );
447 }
448
449 /// The clamp must not rewrite legitimate tuning: positive values survive.
450 #[test]
451 fn positive_heartbeat_values_survive_the_load() {
452 let tmp = tempfile::tempdir().unwrap();
453 let path = tmp.path().join("config.yaml");
454 std::fs::write(
455 &path,
456 "runs:\n heartbeat_interval_secs: 60\n heartbeat_stale_after_intervals: 2\n",
457 )
458 .unwrap();
459
460 let cfg = Config::load_or_default(&path);
461 assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
462 assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
463 }
464}
465
466#[cfg(test)]
467mod ambient_capture_cfg_tests {
468 use super::*;
469
470 #[test]
471 fn session_and_harvest_defaults() {
472 let cfg: Config = serde_yaml::from_str("{}").unwrap();
473 assert_eq!(cfg.session.capture, "ambient");
474 assert_eq!(cfg.session.retention_days, 14);
475 assert!(cfg.harvest.auto_gate);
476 assert_eq!(cfg.harvest.llm, "local-first");
477 assert_eq!(cfg.harvest.min_events, 5);
478 assert_eq!(cfg.harvest.min_user_turns, 2);
479 assert_eq!(cfg.harvest.min_duration_secs, 120);
480 assert_eq!(cfg.harvest.idle_minutes, 30);
481 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
482 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
483 assert!(cfg.harvest.session_start_hint);
484 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
485 }
486
487 #[test]
488 fn session_capture_override_parses() {
489 let cfg: Config =
490 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
491 assert_eq!(cfg.session.capture, "off");
492 assert_eq!(cfg.session.retention_days, 3);
493 }
494}
495
496#[cfg(test)]
497mod cc_proxy_cfg_tests {
498 use super::*;
499
500 #[test]
501 fn defaults_to_local_cc_proxy_enabled() {
502 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
503 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
504 assert!(cfg.cc_proxy.enabled);
505 }
506
507 #[test]
508 fn url_and_enabled_override_parse() {
509 let cfg: Config =
510 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
511 .unwrap();
512 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
513 assert!(!cfg.cc_proxy.enabled);
514 }
515
516 #[test]
517 fn partial_section_keeps_other_default() {
518 // Only `enabled` given → url stays at the default.
519 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
520 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
521 assert!(!cfg.cc_proxy.enabled);
522 }
523}
524
525#[cfg(test)]
526mod notifications_config_tests {
527 use super::*;
528
529 #[test]
530 fn notifications_default_to_log_only() {
531 let c: Config = serde_yaml::from_str("{}").unwrap();
532 assert!(!c.notifications.desktop, "desktop must be opt-in");
533 }
534
535 #[test]
536 fn an_existing_config_without_the_block_still_parses() {
537 // Every user upgrading has a config.yaml with no `notifications:` key.
538 let c: Config = serde_yaml::from_str("retrieval:\n min_score: 0.42\n").unwrap();
539 assert!(!c.notifications.desktop);
540 }
541
542 /// The whole safety argument for the resolver rests on this one bit: an
543 /// upgrade must never start letting the daemon talk to a model. Asserted
544 /// from both an empty config and a realistic existing one, because the
545 /// failure that matters is an upgrade, not a fresh install.
546 #[test]
547 fn the_monitor_resolver_is_off_until_a_user_turns_it_on() {
548 let empty: Config = serde_yaml::from_str("{}").unwrap();
549 assert!(
550 !empty.monitor_resolver.enabled,
551 "asking a model must be opt-in"
552 );
553 assert!(empty.monitor_resolver.model.is_none());
554
555 let upgraded: Config = serde_yaml::from_str(
556 "retrieval:\n min_score: 0.42\nnotifications:\n desktop: true\n",
557 )
558 .unwrap();
559 assert!(
560 !upgraded.monitor_resolver.enabled,
561 "a config written before this feature existed must not enable it"
562 );
563 }
564
565 #[test]
566 fn the_monitor_resolver_reads_back_what_a_user_wrote() {
567 let c: Config =
568 serde_yaml::from_str("monitor_resolver:\n enabled: true\n model: claude_haiku\n")
569 .unwrap();
570 assert!(c.monitor_resolver.enabled);
571 assert_eq!(c.monitor_resolver.model.as_deref(), Some("claude_haiku"));
572 }
573
574 /// Triage must be inert for everyone who never asked for it — including
575 /// every config file written before it existed.
576 #[test]
577 fn triage_is_off_and_non_binding_by_default() {
578 let c: Config = serde_yaml::from_str("retrieval:\n min_score: 0.42\n").unwrap();
579 assert!(!c.triage.enabled, "triage must not run unasked");
580 assert!(!c.triage.enforce);
581 assert!(!c.triage.enforces());
582 }
583
584 /// The trap this guards: `enforce: true` alone looks like it turned
585 /// something on, but there is nothing running to enforce.
586 #[test]
587 fn enforce_without_enabled_binds_nothing() {
588 let c: Config = serde_yaml::from_str("triage:\n enforce: true\n").unwrap();
589 assert!(c.triage.enforce, "the user's word is kept verbatim");
590 assert!(
591 !c.triage.enforces(),
592 "but it binds nothing while triage does not run"
593 );
594 }
595
596 #[test]
597 fn triage_binds_only_when_both_knobs_are_set() {
598 let c: Config =
599 serde_yaml::from_str("triage:\n enabled: true\n enforce: true\n").unwrap();
600 assert!(c.triage.enforces());
601 }
602
603 /// Enabled without enforce is shadow mode: it runs, it records, it never
604 /// stops anything.
605 #[test]
606 fn enabled_alone_is_shadow_mode() {
607 let c: Config = serde_yaml::from_str("triage:\n enabled: true\n").unwrap();
608 assert!(c.triage.enabled);
609 assert!(!c.triage.enforces());
610 }
611}