car_server_core/coder/config.rs
1//! Operator config for the coder, loaded from `~/.car/coder.toml`.
2//!
3//! A small, tolerant config the operator can drop next to the coder state dir
4//! to tune a handful of knobs without recompiling:
5//!
6//! ```toml
7//! [coder]
8//! engine_preference = ["claude-code", "codex", "gemini"] # foreman/external delegation order
9//! keep_workspace_on_failure = false # keep the worktree for postmortem
10//! default_max_iterations = 8
11//! max_session_wall_secs = 3600 # 0 = unlimited
12//! max_agent_build_wall_secs = 600 # operator ceiling; 0 = keep the card's value (unlimited only if the card has none)
13//! max_check_timeout_secs = 600 # ceiling for one contract check
14//! max_replay_events = 2000 # per-live-session replay, 0 = unlimited
15//! max_sessions = 200 # session-snapshot retention, 0 = unlimited
16//! max_session_age_days = 30 # 0 = unlimited
17//! ```
18//!
19//! Loading is deliberately forgiving: a missing file, a missing `[coder]`
20//! table, or any missing key falls back to the documented default. A malformed
21//! file is logged and treated as absent rather than panicking — the daemon must
22//! always boot. `CAR_CODER_CONFIG` overrides the path for tests and embedders,
23//! mirroring `CAR_CODER_STATE_DIR`.
24
25use std::path::PathBuf;
26
27use serde::Deserialize;
28
29use super::router::DEFAULT_PREFERENCE;
30
31/// Default loop iteration cap when neither the RPC nor the config specifies one.
32pub const DEFAULT_MAX_ITERATIONS: u32 = 8;
33
34/// Default cap on retained coder session snapshots.
35///
36/// Nothing pruned the coder state dir before car#1310, so `<id>.json` and its
37/// `<id>.events.jsonl` accumulated for the life of the installation and
38/// `coder.list` read every session ever run on every call.
39pub const DEFAULT_MAX_SESSIONS: usize = 200;
40
41/// Default age cap on retained coder session snapshots, in days.
42pub const DEFAULT_MAX_SESSION_AGE_DAYS: u64 = 30;
43
44/// Default number of events retained for reconnect replay by one live session.
45pub const DEFAULT_MAX_REPLAY_EVENTS: usize = 2_000;
46
47/// Default patch budget for the merge-approval surface (512 KB).
48///
49/// Sixteen times the previous hardcoded 32 KB. Large enough that an ordinary
50/// session is shown whole, small enough to stay a bounded payload on the wire.
51pub const DEFAULT_APPROVAL_PATCH_BYTES: usize = 512 * 1024;
52
53/// Resolved coder operator config. Every field is populated (defaults applied),
54/// so callers never deal with `Option`.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct CoderConfig {
57 /// External/foreman delegation order: the first ready CLI in this list
58 /// wins. Defaults to [`DEFAULT_PREFERENCE`].
59 pub engine_preference: Vec<String>,
60 /// Keep the throwaway worktree when a session ends `Failed`, so the operator
61 /// can inspect it for a postmortem. Defaults to `false` (drop it).
62 pub keep_workspace_on_failure: bool,
63 /// Default loop iteration cap when `coder.start` omits `max_iterations`.
64 /// Defaults to [`DEFAULT_MAX_ITERATIONS`].
65 pub default_max_iterations: u32,
66 /// Pin the native loop's inference model (e.g. `"parslee/reasoning"` for
67 /// gpt-5.5 via the Parslee gateway). `None` = adaptive routing
68 /// (`TaskHint::Code`). The seam that lets a paired A/B run the native arm on
69 /// the same backbone as the external CLI arm.
70 ///
71 /// The pin applies to WHICHEVER engine runs the session: the native loop
72 /// reasons on it, and an `external:<agent_id>` session forwards it to the
73 /// CLI (`codex -m`, `claude --model`). It reached only the native loop
74 /// once, which left a paired A/B's "both arms on one backbone" an
75 /// unverified assumption. Foreman is the exception — its farmed workers
76 /// run their own configured backbones and take no pin at all.
77 ///
78 /// Because the external rung hands this string to a third-party CLI's own
79 /// namespace, a value here is NOT required to be a name `car models list`
80 /// knows. Nothing validates it, deliberately.
81 pub model: Option<String>,
82 /// Byte budget for the patch shown at merge approval. Defaults to
83 /// [`DEFAULT_APPROVAL_PATCH_BYTES`].
84 ///
85 /// This was a hardcoded 32 KB tail, which made the review surface shrink
86 /// exactly as the risk grew: the gap between what a contract verifies and
87 /// what a change actually does widens with session length, so the human
88 /// backstop weakened precisely where the automated one did (car#706). The
89 /// `--stat` is always shown in full regardless of this value.
90 pub approval_patch_bytes: usize,
91 /// Maximum events retained in memory for reconnect replay by one live
92 /// session. Defaults to [`DEFAULT_MAX_REPLAY_EVENTS`]; `0` means unlimited.
93 /// The event sequence remains monotonic when the oldest retained event is
94 /// dropped, and `coder.subscribe.events_skipped` reports a stale cursor.
95 pub max_replay_events: usize,
96 /// Wall-clock ceiling for a whole session, across every rung of the engine
97 /// fallback ladder. `0` means unlimited — for callers that impose their own
98 /// bound and want the daemon out of the way. Defaults to
99 /// [`car_budget::DEFAULT_SESSION_WALL_SECS`](super::budget::DEFAULT_SESSION_WALL_SECS).
100 pub max_session_wall_secs: u64,
101 /// Wall-clock ceiling for an Agent project's complete build: spec
102 /// generation, repair attempts, and scenario runs. A confirmed contract
103 /// may lower a positive ceiling, but cannot remove or raise it. `0` is the
104 /// operator's explicit choice to keep the contract's value: a positive
105 /// timeout is used, while a missing or zero timeout is unlimited. Defaults
106 /// to 600 seconds, also shown on the synthesized `agent_scenarios_pass`
107 /// contract.
108 pub max_agent_build_wall_secs: u64,
109 /// Wall-clock ceiling for ONE outcome-contract check command. Defaults to
110 /// [`MAX_SHELL_TIMEOUT_SECS`](super::shell_tool::MAX_SHELL_TIMEOUT_SECS).
111 ///
112 /// A repository whose real verification gate takes longer than ten minutes
113 /// could not express that gate as a contract check at all: the check was
114 /// killed at the shell ceiling however much session budget remained, and no
115 /// value of `max_session_wall_secs` moved it (car#1065). Raising this is an
116 /// operator decision about their own test gate — it does NOT raise the
117 /// ceiling on the `shell` tool the model calls, which stays at the 600s the
118 /// tool description advertises.
119 ///
120 /// Unlike `max_session_wall_secs`, `0` is not "unlimited" here — it would
121 /// floor every check at one second — so it is treated as unset.
122 pub max_check_timeout_secs: u64,
123 /// How many terminal session snapshots to keep in the coder state dir.
124 /// Defaults to [`DEFAULT_MAX_SESSIONS`]; `0` means unlimited.
125 ///
126 /// Only collectable sessions are counted and evicted — see
127 /// [`gc_sessions`](super::session::gc_sessions) for what is exempt. So this
128 /// bounds what retention manages, not the size of the directory.
129 ///
130 /// `0` disables the cap, matching `config.toml`'s `[runs]`. The two used to
131 /// disagree — `crate::run_store::RetentionConfig` read `0` literally, so
132 /// `max_per_agent = 0` made `completed_rank >= 0` always true and
133 /// `max_age_days = 0` put the cutoff at now, either one deleting every
134 /// collectable run trace. Two similarly-named retention knobs in one daemon
135 /// whose zero values inverted was a hazard whose failure mode was silent
136 /// data loss; car#1338 settled it in this direction. Note this is not a
137 /// blanket rule — `max_check_timeout_secs` two fields down reads `0` as
138 /// UNSET, because zero there would floor every check at one second rather
139 /// than lift a ceiling.
140 pub max_sessions: usize,
141 /// Age ceiling for a retained terminal session snapshot, in days. Defaults
142 /// to [`DEFAULT_MAX_SESSION_AGE_DAYS`]; `0` means unlimited — see the note
143 /// on [`Self::max_sessions`].
144 pub max_session_age_days: u64,
145}
146
147impl CoderConfig {
148 /// The retention policy [`gc_sessions`](super::session::gc_sessions) wants.
149 pub fn session_retention(&self) -> super::session::SessionRetention {
150 super::session::SessionRetention {
151 max_sessions: self.max_sessions,
152 max_age_days: self.max_session_age_days,
153 }
154 }
155}
156
157impl Default for CoderConfig {
158 fn default() -> Self {
159 Self {
160 engine_preference: DEFAULT_PREFERENCE.iter().map(|s| s.to_string()).collect(),
161 keep_workspace_on_failure: false,
162 default_max_iterations: DEFAULT_MAX_ITERATIONS,
163 model: None,
164 approval_patch_bytes: DEFAULT_APPROVAL_PATCH_BYTES,
165 max_replay_events: DEFAULT_MAX_REPLAY_EVENTS,
166 max_session_wall_secs: super::budget::DEFAULT_SESSION_WALL_SECS,
167 max_agent_build_wall_secs: super::shell_tool::MAX_SHELL_TIMEOUT_SECS,
168 max_check_timeout_secs: super::shell_tool::MAX_SHELL_TIMEOUT_SECS,
169 max_sessions: DEFAULT_MAX_SESSIONS,
170 max_session_age_days: DEFAULT_MAX_SESSION_AGE_DAYS,
171 }
172 }
173}
174
175/// The raw on-disk shape. Every field is optional so a partial file is valid;
176/// missing fields fall back to [`CoderConfig::default`].
177#[derive(Debug, Default, Deserialize)]
178struct RawConfigFile {
179 #[serde(default)]
180 coder: RawCoderTable,
181}
182
183#[derive(Debug, Default, Deserialize)]
184struct RawCoderTable {
185 #[serde(default)]
186 engine_preference: Option<Vec<String>>,
187 #[serde(default)]
188 keep_workspace_on_failure: Option<bool>,
189 #[serde(default)]
190 default_max_iterations: Option<u32>,
191 #[serde(default)]
192 model: Option<String>,
193 #[serde(default)]
194 approval_patch_bytes: Option<usize>,
195 #[serde(default)]
196 max_replay_events: Option<usize>,
197 #[serde(default)]
198 max_session_wall_secs: Option<u64>,
199 #[serde(default)]
200 max_agent_build_wall_secs: Option<u64>,
201 #[serde(default)]
202 max_check_timeout_secs: Option<u64>,
203 #[serde(default)]
204 max_sessions: Option<usize>,
205 #[serde(default)]
206 max_session_age_days: Option<u64>,
207}
208
209impl CoderConfig {
210 /// Resolve `engine_preference` to the `&str` slice [`resolve_engine`] wants.
211 ///
212 /// [`resolve_engine`]: super::router::resolve_engine
213 pub fn preference_refs(&self) -> Vec<&str> {
214 self.engine_preference.iter().map(|s| s.as_str()).collect()
215 }
216
217 fn from_raw(raw: RawConfigFile) -> Self {
218 let defaults = Self::default();
219 let coder = raw.coder;
220 Self {
221 // An explicitly-empty list would mean "no preference at all"; treat
222 // it as unset and fall back to the default order so the operator
223 // can't accidentally disable all delegation by writing `[]`.
224 engine_preference: coder
225 .engine_preference
226 .filter(|p| !p.is_empty())
227 .unwrap_or(defaults.engine_preference),
228 keep_workspace_on_failure: coder
229 .keep_workspace_on_failure
230 .unwrap_or(defaults.keep_workspace_on_failure),
231 default_max_iterations: coder
232 .default_max_iterations
233 // 0 iterations is a footgun (the loop never runs); ignore it.
234 .filter(|n| *n > 0)
235 .unwrap_or(defaults.default_max_iterations),
236 // A blank string is "unset", not "pin the empty model".
237 model: coder.model.filter(|m| !m.trim().is_empty()),
238 // 0 would show no patch at all — treat it as unset rather than
239 // silently blinding the approval surface.
240 approval_patch_bytes: coder
241 .approval_patch_bytes
242 .filter(|n| *n > 0)
243 .unwrap_or(defaults.approval_patch_bytes),
244 // 0 explicitly disables the in-memory replay cap. The durable
245 // event journal is independent of this reconnect-speed window.
246 max_replay_events: coder
247 .max_replay_events
248 .unwrap_or(defaults.max_replay_events),
249 // 0 is meaningful here (unlimited), unlike `approval_patch_bytes`
250 // where it would blind the approval surface — so it is NOT filtered.
251 max_session_wall_secs: coder
252 .max_session_wall_secs
253 .unwrap_or(defaults.max_session_wall_secs),
254 // The dedicated agent-build clock uses the same 0 == unlimited
255 // convention as the general session clock.
256 max_agent_build_wall_secs: coder
257 .max_agent_build_wall_secs
258 .unwrap_or(defaults.max_agent_build_wall_secs),
259 // And here 0 is NOT unlimited — it would clamp every check to the
260 // shell's 1s floor and turn a whole contract red. Treat it as unset,
261 // like `approval_patch_bytes`.
262 max_check_timeout_secs: coder
263 .max_check_timeout_secs
264 .filter(|n| *n > 0)
265 .unwrap_or(defaults.max_check_timeout_secs),
266 // 0 IS meaningful for both retention caps: it disables that cap,
267 // which is the operator saying "keep everything" rather than
268 // "keep nothing". Not filtered, unlike `approval_patch_bytes`.
269 max_sessions: coder.max_sessions.unwrap_or(defaults.max_sessions),
270 max_session_age_days: coder
271 .max_session_age_days
272 .unwrap_or(defaults.max_session_age_days),
273 }
274 }
275
276 /// Parse a TOML string. Tolerant: a malformed document yields defaults
277 /// rather than an error.
278 pub fn parse_toml(text: &str) -> Self {
279 match toml::from_str::<RawConfigFile>(text) {
280 Ok(raw) => Self::from_raw(raw),
281 Err(e) => {
282 tracing::warn!("ignoring malformed ~/.car/coder.toml: {e}");
283 Self::default()
284 }
285 }
286 }
287
288 /// Load from `path`. A missing file → defaults; an unreadable or malformed
289 /// file → defaults (logged). Never panics.
290 pub fn load_from(path: &std::path::Path) -> Self {
291 match std::fs::read_to_string(path) {
292 Ok(text) => Self::parse_toml(&text),
293 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
294 Err(e) => {
295 tracing::warn!("ignoring unreadable {}: {e}", path.display());
296 Self::default()
297 }
298 }
299 }
300
301 /// Load from the resolved config path ([`config_path`]).
302 pub fn load() -> Self {
303 match config_path() {
304 Ok(path) => Self::load_from(&path),
305 Err(_) => Self::default(),
306 }
307 }
308}
309
310/// Which file a resolved session model pin came from.
311///
312/// Carried out of [`session_model`] rather than re-derived by the caller: the
313/// precedence rule and "which one won" are one decision, and a caller that
314/// answers the second question with its own copy of the first is how the two
315/// come to disagree — the defect car#1360 was.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum PinSource {
318 /// The per-session pin — `coder.start`'s `model`, `car code --model`, or
319 /// `heal.toml`'s `coder_model`.
320 Request,
321 /// This file's `[coder] model`.
322 Config,
323}
324
325/// The model a coder session actually runs on, and which file said so.
326///
327/// The per-session pin wins; a blank one is "unset" rather than "pin the empty
328/// model". `None` from both means unpinned — adaptive routing picks per
329/// request, and nothing before the run can say what it will pick.
330///
331/// The config arm is NOT trimmed, only blank-filtered ([`CoderConfig`]'s
332/// `model` is normalized that way at parse). Documented rather than fixed
333/// because this is now the single copy and a padded value reaches the engine
334/// padded — the callers have always behaved this way.
335///
336/// One copy on purpose. Three callers need this answer — `coder.start`,
337/// `car code-task`, and the self-heal loop's "the coder may not sit on the
338/// review panel" check — and heal passes `heal.toml`'s `coder_model` as the
339/// request pin, so an ABSENT `coder_model` does not mean "unpinned", it means
340/// "whatever this file says". Re-deriving the rule in the check read only the
341/// first source and let a coder that was also a review seat through (car#1360).
342pub fn session_model<'a>(
343 request: Option<&'a str>,
344 config: Option<&'a str>,
345) -> Option<(&'a str, PinSource)> {
346 match request.map(str::trim).filter(|m| !m.is_empty()) {
347 Some(m) => Some((m, PinSource::Request)),
348 None => config.map(|m| (m, PinSource::Config)),
349 }
350}
351
352/// Process-wide lock serializing tests that mutate the `CAR_CODER_CONFIG`
353/// global env var, across every module that does.
354///
355/// ONE lock, here, next to the thing it guards. Two modules each with their own
356/// `OnceLock<Mutex>` over the same global is not mutual exclusion — all of
357/// `car-server-core`'s unit tests link into one binary and libtest runs them on
358/// a thread pool, so `rpc`'s and `heal_service`'s tests would interleave and
359/// read each other's `coder.toml`.
360#[cfg(test)]
361pub(crate) fn config_env_lock() -> &'static std::sync::Mutex<()> {
362 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
363 LOCK.get_or_init(|| std::sync::Mutex::new(()))
364}
365
366/// Where the operator config lives. `CAR_CODER_CONFIG` overrides for tests and
367/// embedders (mirroring `CAR_CODER_STATE_DIR`); otherwise `coder.toml` under
368/// the CAR state root, which is `~/.car/coder.toml` unless `CAR_HOME` moves it.
369pub fn config_path() -> Result<PathBuf, String> {
370 if let Some(path) = std::env::var_os("CAR_CODER_CONFIG") {
371 // Don't let an env var silently redirect coder config. It stays a
372 // test/embedder path override (no longer the model-pinning mechanism —
373 // that's the `coder.start` `model` param), but a native app shouldn't
374 // hide behavior-changing env, so surface it.
375 tracing::info!(
376 path = ?path,
377 "coder config path overridden by CAR_CODER_CONFIG env"
378 );
379 return Ok(PathBuf::from(path));
380 }
381 let root = car_home::root()
382 .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
383 Ok(root.join("coder.toml"))
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn missing_keys_fall_back_to_documented_defaults() {
392 // Empty file → every default.
393 let c = CoderConfig::parse_toml("");
394 assert_eq!(c, CoderConfig::default());
395 assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
396 assert!(!c.keep_workspace_on_failure);
397 assert_eq!(c.default_max_iterations, 8);
398
399 // Empty [coder] table → still defaults.
400 let c = CoderConfig::parse_toml("[coder]\n");
401 assert_eq!(c, CoderConfig::default());
402 }
403
404 #[test]
405 fn replay_cap_is_read_and_zero_means_unlimited() {
406 assert_eq!(
407 CoderConfig::parse_toml("").max_replay_events,
408 DEFAULT_MAX_REPLAY_EVENTS
409 );
410 assert_eq!(
411 CoderConfig::parse_toml("[coder]\nmax_replay_events = 37\n").max_replay_events,
412 37
413 );
414 assert_eq!(
415 CoderConfig::parse_toml("[coder]\nmax_replay_events = 0\n").max_replay_events,
416 0
417 );
418 }
419
420 #[test]
421 fn retention_knobs_are_read_and_zero_means_unlimited() {
422 let c = CoderConfig::parse_toml("");
423 assert_eq!(c.max_sessions, DEFAULT_MAX_SESSIONS);
424 assert_eq!(c.max_session_age_days, DEFAULT_MAX_SESSION_AGE_DAYS);
425
426 let c = CoderConfig::parse_toml(
427 r#"
428 [coder]
429 max_sessions = 25
430 max_session_age_days = 7
431 "#,
432 );
433 assert_eq!(c.session_retention().max_sessions, 25);
434 assert_eq!(c.session_retention().max_age_days, 7);
435
436 // 0 disables the cap rather than collecting everything — the operator
437 // saying "keep it all", which is why neither field is filtered the way
438 // `approval_patch_bytes` is.
439 let c = CoderConfig::parse_toml(
440 r#"
441 [coder]
442 max_sessions = 0
443 max_session_age_days = 0
444 "#,
445 );
446 assert_eq!(c.max_sessions, 0);
447 assert_eq!(c.max_session_age_days, 0);
448 }
449
450 #[test]
451 fn full_config_is_honored() {
452 let c = CoderConfig::parse_toml(
453 r#"
454 [coder]
455 engine_preference = ["codex", "claude-code"]
456 keep_workspace_on_failure = true
457 default_max_iterations = 3
458 "#,
459 );
460 assert_eq!(c.engine_preference, ["codex", "claude-code"]);
461 assert!(c.keep_workspace_on_failure);
462 assert_eq!(c.default_max_iterations, 3);
463 assert_eq!(c.preference_refs(), vec!["codex", "claude-code"]);
464 }
465
466 #[test]
467 fn the_check_timeout_ceiling_defaults_to_the_shell_ceiling_and_zero_is_unset() {
468 use super::super::shell_tool::MAX_SHELL_TIMEOUT_SECS;
469 assert_eq!(
470 CoderConfig::default().max_check_timeout_secs,
471 MAX_SHELL_TIMEOUT_SECS
472 );
473 assert_eq!(
474 CoderConfig::parse_toml("").max_check_timeout_secs,
475 MAX_SHELL_TIMEOUT_SECS
476 );
477
478 // The whole point of the knob (car#1065): a suite that needs more than
479 // ten minutes.
480 let c = CoderConfig::parse_toml("[coder]\nmax_check_timeout_secs = 1800\n");
481 assert_eq!(c.max_check_timeout_secs, 1800);
482
483 // 0 is "unset", not "unlimited" — unlike max_session_wall_secs, where it
484 // is. Honoring it would floor every check at one second.
485 let c = CoderConfig::parse_toml("[coder]\nmax_check_timeout_secs = 0\n");
486 assert_eq!(c.max_check_timeout_secs, MAX_SHELL_TIMEOUT_SECS);
487 assert_eq!(
488 CoderConfig::parse_toml("[coder]\nmax_session_wall_secs = 0\n").max_session_wall_secs,
489 0,
490 "the sibling knob keeps 0 == unlimited"
491 );
492 let agent = CoderConfig::parse_toml("[coder]\nmax_agent_build_wall_secs = 45\n");
493 assert_eq!(agent.max_agent_build_wall_secs, 45);
494 assert_eq!(
495 CoderConfig::parse_toml("[coder]\nmax_agent_build_wall_secs = 0\n")
496 .max_agent_build_wall_secs,
497 0,
498 "the agent-build knob keeps 0 == unlimited"
499 );
500 }
501
502 /// The precedence rule, pinned. `heal_service` reads it to decide whether
503 /// its coder is also a review seat, so a change here silently changes what
504 /// that check is checking.
505 #[test]
506 fn the_request_pin_wins_and_a_blank_one_falls_through_to_this_file() {
507 // Request wins, trimmed.
508 assert_eq!(
509 session_model(Some(" gpt-5.6 "), Some("parslee/reasoning")),
510 Some(("gpt-5.6", PinSource::Request))
511 );
512 // A blank request is "unset", not "pin blank" — this file still wins.
513 assert_eq!(
514 session_model(Some(" "), Some("parslee/reasoning")),
515 Some(("parslee/reasoning", PinSource::Config))
516 );
517 // The case car#1360 turned on: no request pin does NOT mean unpinned.
518 assert_eq!(
519 session_model(None, Some("parslee/reasoning")),
520 Some(("parslee/reasoning", PinSource::Config))
521 );
522 // Neither source pins: the router picks per request, and only what
523 // actually authored the change can say what it picked.
524 assert_eq!(session_model(None, None), None);
525 assert_eq!(session_model(Some(""), None), None);
526 // The documented asymmetry: the config arm is blank-filtered at parse
527 // but never trimmed, so a padded value reaches the engine padded.
528 assert_eq!(
529 session_model(None, Some(" gpt-5.6 ")),
530 Some((" gpt-5.6 ", PinSource::Config))
531 );
532 }
533
534 #[test]
535 fn model_pin_parses_and_blank_is_unset() {
536 assert_eq!(CoderConfig::default().model, None);
537 let c = CoderConfig::parse_toml("[coder]\nmodel = \"parslee/reasoning\"\n");
538 assert_eq!(c.model.as_deref(), Some("parslee/reasoning"));
539 let c = CoderConfig::parse_toml("[coder]\nmodel = \" \"\n");
540 assert_eq!(c.model, None);
541 }
542
543 #[test]
544 fn partial_config_mixes_explicit_and_default() {
545 // Only one key set; the rest default.
546 let c = CoderConfig::parse_toml("[coder]\nkeep_workspace_on_failure = true\n");
547 assert!(c.keep_workspace_on_failure);
548 assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
549 assert_eq!(c.default_max_iterations, 8);
550 }
551
552 #[test]
553 fn empty_preference_and_zero_iterations_are_ignored() {
554 let c = CoderConfig::parse_toml(
555 "[coder]\nengine_preference = []\ndefault_max_iterations = 0\n",
556 );
557 // Both footguns fall back to defaults rather than disabling the feature.
558 assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
559 assert_eq!(c.default_max_iterations, 8);
560 }
561
562 #[test]
563 fn malformed_toml_yields_defaults_not_panic() {
564 let c = CoderConfig::parse_toml("this is not = = toml [[[");
565 assert_eq!(c, CoderConfig::default());
566 }
567
568 #[test]
569 fn missing_file_yields_defaults() {
570 let dir = tempfile::tempdir().unwrap();
571 let path = dir.path().join("does-not-exist.toml");
572 assert_eq!(CoderConfig::load_from(&path), CoderConfig::default());
573 }
574
575 #[test]
576 fn load_from_real_file_round_trips() {
577 let dir = tempfile::tempdir().unwrap();
578 let path = dir.path().join("coder.toml");
579 std::fs::write(
580 &path,
581 "[coder]\nengine_preference = [\"gemini\"]\nkeep_workspace_on_failure = true\ndefault_max_iterations = 5\n",
582 )
583 .unwrap();
584 let c = CoderConfig::load_from(&path);
585 assert_eq!(c.engine_preference, ["gemini"]);
586 assert!(c.keep_workspace_on_failure);
587 assert_eq!(c.default_max_iterations, 5);
588 }
589}