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