Skip to main content

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}