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                          # 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}