Skip to main content

spar/
config.rs

1//! Configuration, and the agent presets that make a new CLI a data change
2//! rather than a code change.
3//!
4//! Presets are compiled into the binary. That is not an optimisation: a
5//! `cargo install`ed binary has no source tree beside it, so a preset read from
6//! a relative path would work for the author and fail for everyone else. Files
7//! on disk still win over the built in copies, so a preset can be overridden or
8//! a new one added without rebuilding.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use toml::Value;
15
16use crate::error::Result;
17use crate::proc::{expand_tilde, home_dir};
18use crate::style::Style;
19use crate::{bail, spar_err};
20
21/// Presets that ship inside the binary.
22pub const BUILTIN_PRESETS: &[(&str, &str)] = &[
23    ("aider", include_str!("../presets/aider.toml")),
24    ("claude", include_str!("../presets/claude.toml")),
25    ("codex", include_str!("../presets/codex.toml")),
26    ("gemini", include_str!("../presets/gemini.toml")),
27];
28
29// ---------------------------------------------------------------------------
30// Agents
31// ---------------------------------------------------------------------------
32
33/// One element of a command template: a bare argument, or a group that is
34/// dropped whole when its placeholder is unset.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(untagged)]
37pub enum CommandPart {
38    One(String),
39    Group(Vec<String>),
40}
41
42impl CommandPart {
43    pub fn args(&self) -> &[String] {
44        match self {
45            CommandPart::One(s) => std::slice::from_ref(s),
46            CommandPart::Group(v) => v,
47        }
48    }
49}
50
51/// How to read the answer out of what a CLI printed.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum OutputMode {
55    /// Everything on stdout is the answer.
56    Text,
57    /// Same as text; named separately because it reads better in a preset.
58    Json,
59    /// An event stream, one JSON object per line.
60    Jsonl,
61}
62
63/// Where the style rules go when a CLI has a system prompt flag.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum SystemVia {
67    /// Prepended to the prompt.
68    Prompt,
69    /// Passed through the `{system}` placeholder.
70    Placeholder,
71}
72
73fn default_timeout() -> u64 {
74    crate::proc::DEFAULT_TIMEOUT_SECS
75}
76
77fn default_output() -> OutputMode {
78    OutputMode::Text
79}
80
81fn default_system_via() -> SystemVia {
82    SystemVia::Prompt
83}
84
85/// Everything needed to drive one CLI. An agent is data, not a class:
86/// supporting a new tool is a preset file.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct AgentSpec {
90    #[serde(skip)]
91    pub name: String,
92    pub command: Vec<CommandPart>,
93    #[serde(default)]
94    pub model: Option<String>,
95    #[serde(default)]
96    pub effort: Option<String>,
97    #[serde(default = "default_output")]
98    pub output: OutputMode,
99    /// For `jsonl`: the event fields that identify the agent's own message.
100    #[serde(default)]
101    pub message_match: BTreeMap<String, String>,
102    /// For `jsonl`: the dotted path to the text inside a matching event.
103    #[serde(default)]
104    pub message_path: Option<String>,
105    /// Extra places to look for the binary when it is not on PATH.
106    #[serde(default)]
107    pub search_paths: Vec<String>,
108    #[serde(default = "default_system_via")]
109    pub system_via: SystemVia,
110    #[serde(default = "default_timeout")]
111    pub timeout: u64,
112
113    // -- hints, inert at runtime -----------------------------------------
114    //
115    // Written into a generated config as comments so nobody has to guess what
116    // to put in `model` or `effort`. Deliberately never validated against: a
117    // CLI's options drift, and a stale allow list that refuses a model which
118    // actually works would be worse than no hint at all.
119    /// Model names this CLI is known to accept.
120    #[serde(default)]
121    pub models: Vec<String>,
122    /// Effort levels this CLI is known to accept.
123    #[serde(default)]
124    pub efforts: Vec<String>,
125    /// Where to check the current list, when spar cannot enumerate it.
126    #[serde(default)]
127    pub options_note: Option<String>,
128}
129
130impl AgentSpec {
131    /// The model as configured, normalised. An unset and an empty value both
132    /// mean "let the CLI pick", because `render` drops the flag either way.
133    pub fn model_key(&self) -> String {
134        self.model.as_deref().unwrap_or("").trim().to_string()
135    }
136
137    pub fn describe(&self) -> String {
138        format!(
139            "{}/{}",
140            self.model.as_deref().unwrap_or("default model"),
141            self.effort.as_deref().unwrap_or("default effort")
142        )
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Loop and style blocks
148// ---------------------------------------------------------------------------
149
150/// Where an out of scope finding goes. On your own repository an issue is the
151/// right home. On a large repository that is not yours it is somebody else's
152/// notification and somebody else's triage queue.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum Followups {
156    Issues,
157    Local,
158    None,
159}
160
161impl std::fmt::Display for Followups {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.write_str(match self {
164            Followups::Issues => "issues",
165            Followups::Local => "local",
166            Followups::None => "none",
167        })
168    }
169}
170
171/// Where resume state lives. Local keeps the PR clean and costs no API calls;
172/// writing to the PR only buys anything if a run might be resumed from a
173/// different checkout.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "lowercase")]
176pub enum StateStore {
177    Local,
178    Pr,
179    Both,
180}
181
182impl StateStore {
183    pub fn writes_local(self) -> bool {
184        matches!(self, StateStore::Local | StateStore::Both)
185    }
186    pub fn writes_pr(self) -> bool {
187        matches!(self, StateStore::Pr | StateStore::Both)
188    }
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct EffortSchedule {
194    /// The deep first review.
195    pub round_1: Option<String>,
196    /// Later rounds only see a small delta.
197    pub rest: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct LoopCfg {
203    #[serde(default = "three")]
204    pub max_rounds: u32,
205    #[serde(default)]
206    pub auto_merge: bool,
207    #[serde(default)]
208    pub first_implementor: Option<String>,
209    #[serde(default = "main_branch")]
210    pub base_branch: String,
211    #[serde(default = "yes")]
212    pub worktrees: bool,
213    #[serde(default)]
214    pub keep_worktrees: bool,
215    #[serde(default = "store_local")]
216    pub state_store: StateStore,
217    #[serde(default)]
218    pub branch_prefix: String,
219    #[serde(default = "followups_issues")]
220    pub followups: Followups,
221    /// Nits stay in the PR thread by default. A filed nit is somebody else's
222    /// notification: a run on a production codebase once opened an issue titled
223    /// "Log wording".
224    #[serde(default)]
225    pub file_nits: bool,
226    /// Close an issue that both agents independently declined, after posting
227    /// the shared reasoning. One agent's opinion is never enough.
228    #[serde(default = "yes")]
229    pub close_skipped: bool,
230    /// Ask both agents to triage at the same time. They only read during
231    /// triage, so there is nothing to serialise.
232    #[serde(default = "yes")]
233    pub parallel_triage: bool,
234    #[serde(default)]
235    pub effort_schedule: EffortSchedule,
236}
237
238fn three() -> u32 {
239    3
240}
241fn main_branch() -> String {
242    "main".to_string()
243}
244fn yes() -> bool {
245    true
246}
247fn store_local() -> StateStore {
248    StateStore::Local
249}
250fn followups_issues() -> Followups {
251    Followups::Issues
252}
253
254impl Default for LoopCfg {
255    fn default() -> Self {
256        Self {
257            max_rounds: 3,
258            auto_merge: false,
259            first_implementor: None,
260            base_branch: "main".into(),
261            worktrees: true,
262            keep_worktrees: false,
263            state_store: StateStore::Local,
264            branch_prefix: String::new(),
265            followups: Followups::Issues,
266            file_nits: false,
267            close_skipped: true,
268            parallel_triage: true,
269            effort_schedule: EffortSchedule::default(),
270        }
271    }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct StyleCfg {
277    #[serde(default = "yes")]
278    pub ban_em_dash: bool,
279    #[serde(default = "yes")]
280    pub ban_ai_attribution: bool,
281    #[serde(default = "yes")]
282    pub terse: bool,
283    #[serde(default = "d320")]
284    pub max_detail_chars: usize,
285    #[serde(default = "d200")]
286    pub max_summary_chars: usize,
287    #[serde(default = "d900")]
288    pub max_body_chars: usize,
289    #[serde(default = "d90")]
290    pub max_title_chars: usize,
291}
292
293fn d320() -> usize {
294    320
295}
296fn d200() -> usize {
297    200
298}
299fn d900() -> usize {
300    900
301}
302fn d90() -> usize {
303    90
304}
305
306impl Default for StyleCfg {
307    fn default() -> Self {
308        Self {
309            ban_em_dash: true,
310            ban_ai_attribution: true,
311            terse: true,
312            max_detail_chars: 320,
313            max_summary_chars: 200,
314            max_body_chars: 900,
315            max_title_chars: 90,
316        }
317    }
318}
319
320impl StyleCfg {
321    pub fn to_style(&self) -> Style {
322        Style {
323            ban_em_dash: self.ban_em_dash,
324            ban_ai_attribution: self.ban_ai_attribution,
325            terse: self.terse,
326            max_detail_chars: self.max_detail_chars,
327            max_summary_chars: self.max_summary_chars,
328            max_body_chars: self.max_body_chars,
329            max_title_chars: self.max_title_chars,
330        }
331    }
332}
333
334// ---------------------------------------------------------------------------
335// The whole config
336// ---------------------------------------------------------------------------
337
338#[derive(Debug, Clone)]
339pub struct Config {
340    /// In declaration order, which is what `first_implementor` defaults to.
341    pub agents: Vec<AgentSpec>,
342    pub loop_cfg: LoopCfg,
343    pub style: Style,
344    /// Resolved: never empty, always one of the configured agents.
345    pub first_implementor: String,
346    /// Where this config was read from, for error messages.
347    pub source: Option<PathBuf>,
348}
349
350impl Config {
351    pub fn agent_names(&self) -> Vec<String> {
352        self.agents.iter().map(|a| a.name.clone()).collect()
353    }
354
355    pub fn has_agent(&self, name: &str) -> bool {
356        self.agents.iter().any(|a| a.name == name)
357    }
358
359    pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
360        self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
361            spar_err!(
362                "no agent named '{name}' ({})",
363                self.agent_names().join(", ")
364            )
365        })
366    }
367
368    /// The other agent. With exactly two configured, custody alternates by
369    /// definition.
370    pub fn other(&self, name: &str) -> String {
371        let names = self.agent_names();
372        if names.first().map(String::as_str) == Some(name) {
373            names.get(1).cloned().unwrap_or_else(|| name.to_string())
374        } else {
375            names.first().cloned().unwrap_or_else(|| name.to_string())
376        }
377    }
378
379    /// Round 1 gets the deep pass; later rounds only see a small delta, and a
380    /// full ultra review of a three line delta is money on fire.
381    pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
382        let scheduled = if round <= 1 {
383            self.loop_cfg.effort_schedule.round_1.clone()
384        } else {
385            self.loop_cfg.effort_schedule.rest.clone()
386        };
387        scheduled
388            .filter(|s| !s.trim().is_empty())
389            .or_else(|| spec.effort.clone())
390    }
391
392    pub fn base_branch(&self) -> &str {
393        &self.loop_cfg.base_branch
394    }
395}
396
397#[derive(Debug, Deserialize)]
398#[serde(deny_unknown_fields)]
399struct RawConfig {
400    #[serde(default)]
401    agents: toml::Table,
402    #[serde(default)]
403    #[serde(rename = "loop")]
404    loop_cfg: Option<LoopCfg>,
405    #[serde(default)]
406    style: Option<StyleCfg>,
407}
408
409// ---------------------------------------------------------------------------
410// Presets
411// ---------------------------------------------------------------------------
412
413/// Directories searched for preset overrides, nearest first.
414///
415/// Deliberately *not* a bare `presets/`. spar runs from inside the user's own
416/// repository, where `presets/` is a perfectly ordinary directory name for
417/// something unrelated (sampler configs, prompt libraries, editor themes), and
418/// a stray `presets/claude.toml` shadowing the built in preset produces a
419/// baffling failure: `spar init` reports Claude Code as missing while it sits
420/// on PATH. Overrides live somewhere that names spar.
421pub fn preset_dirs() -> Vec<PathBuf> {
422    let mut dirs = Vec::new();
423    if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
424        dirs.push(PathBuf::from(custom));
425    }
426    dirs.push(PathBuf::from(".spar").join("presets"));
427    if let Some(home) = home_dir() {
428        dirs.push(home.join(".config").join("spar").join("presets"));
429    }
430    dirs
431}
432
433/// Every preset name available, built in and on disk, sorted.
434pub fn available_presets() -> Vec<String> {
435    let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
436    for dir in preset_dirs() {
437        if let Ok(entries) = std::fs::read_dir(&dir) {
438            for entry in entries.flatten() {
439                let path = entry.path();
440                if path.extension().and_then(|e| e.to_str()) == Some("toml") {
441                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
442                        names.push(stem.to_string());
443                    }
444                }
445            }
446        }
447    }
448    names.sort();
449    names.dedup();
450    names
451}
452
453/// Parse a whole TOML document into a `Value`.
454///
455/// `"...".parse::<Value>()` parses a single TOML *value*, not a document, so it
456/// rejects the first comment line of every preset.
457fn parse_document(text: &str, what: &str) -> Result<Value> {
458    let table: toml::Table =
459        toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
460    Ok(Value::Table(table))
461}
462
463/// Load a preset. A file on disk wins over the built in copy of the same name,
464/// so a drifting CLI can be corrected without waiting for a release.
465pub fn load_preset(name: &str) -> Result<Value> {
466    for dir in preset_dirs() {
467        let path = dir.join(format!("{name}.toml"));
468        if path.is_file() {
469            let text = std::fs::read_to_string(&path)
470                .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
471            return parse_document(&text, &format!("preset {}", path.display()));
472        }
473    }
474    for (builtin, text) in BUILTIN_PRESETS {
475        if *builtin == name {
476            return parse_document(text, &format!("built in preset {name}"));
477        }
478    }
479    Err(spar_err!(
480        "unknown preset '{name}'. Available: {}",
481        available_presets().join(", ")
482    ))
483}
484
485/// Deep merge, with `over` winning. Used so a config block can override one
486/// field of a preset without restating the whole command template.
487fn merge(base: &Value, over: &Value) -> Value {
488    match (base, over) {
489        (Value::Table(b), Value::Table(o)) => {
490            let mut out = b.clone();
491            for (key, value) in o {
492                let merged = match out.get(key) {
493                    Some(existing) => merge(existing, value),
494                    None => value.clone(),
495                };
496                out.insert(key.clone(), merged);
497            }
498            Value::Table(out)
499        }
500        _ => over.clone(),
501    }
502}
503
504fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
505    let table = raw
506        .as_table()
507        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
508
509    let merged = match table.get("preset").and_then(Value::as_str) {
510        Some(preset) => merge(&load_preset(preset)?, raw),
511        None => raw.clone(),
512    };
513
514    let mut merged_table = merged
515        .as_table()
516        .cloned()
517        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
518    merged_table.remove("preset");
519
520    if !merged_table.contains_key("command") {
521        bail!(
522            "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
523            available_presets().join(", ")
524        );
525    }
526
527    let mut spec: AgentSpec = Value::Table(merged_table)
528        .try_into()
529        .map_err(|e| spar_err!("agent '{name}': {e}"))?;
530    spec.name = name.to_string();
531
532    if spec.command.is_empty() {
533        bail!("agent '{name}' has an empty command");
534    }
535    if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
536        bail!("agent '{name}': the first command element must be the program name, not a group");
537    }
538    if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
539        bail!(
540            "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
541        );
542    }
543    Ok(spec)
544}
545
546// ---------------------------------------------------------------------------
547// Loading
548// ---------------------------------------------------------------------------
549
550pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
551
552/// Find a config: an explicit path, then the working directory, then
553/// `~/.config/spar/spar.toml`.
554pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
555    if let Some(path) = explicit {
556        if !path.is_file() {
557            bail!("config not found: {}", path.display());
558        }
559        return Ok(Some(path.to_path_buf()));
560    }
561    for name in CONFIG_NAMES {
562        let path = PathBuf::from(name);
563        if path.is_file() {
564            return Ok(Some(path));
565        }
566    }
567    if let Some(home) = home_dir() {
568        let path = home.join(".config").join("spar").join("spar.toml");
569        if path.is_file() {
570            return Ok(Some(path));
571        }
572    }
573    Ok(None)
574}
575
576pub fn load(explicit: Option<&Path>) -> Result<Config> {
577    let Some(path) = find_config(explicit)? else {
578        bail!(
579            "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
580        );
581    };
582    let text = std::fs::read_to_string(&path)
583        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
584    let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
585    cfg.source = Some(path);
586    Ok(cfg)
587}
588
589pub fn parse(text: &str) -> Result<Config> {
590    let raw: RawConfig = toml::from_str(text)?;
591
592    if raw.agents.len() != 2 {
593        bail!(
594            "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
595            raw.agents.len()
596        );
597    }
598
599    let mut agents = Vec::new();
600    for (name, value) in raw.agents.iter() {
601        agents.push(build_spec(name, value)?);
602    }
603
604    let loop_cfg = raw.loop_cfg.unwrap_or_default();
605    let style = raw.style.unwrap_or_default().to_style();
606
607    if loop_cfg.max_rounds == 0 {
608        bail!("max_rounds must be at least 1");
609    }
610
611    let first = match &loop_cfg.first_implementor {
612        Some(name) if !name.trim().is_empty() => name.trim().to_string(),
613        _ => agents[0].name.clone(),
614    };
615    if !agents.iter().any(|a| a.name == first) {
616        bail!(
617            "first_implementor '{first}' is not a configured agent ({})",
618            agents
619                .iter()
620                .map(|a| a.name.as_str())
621                .collect::<Vec<_>>()
622                .join(", ")
623        );
624    }
625
626    Ok(Config {
627        agents,
628        loop_cfg,
629        style,
630        first_implementor: first,
631        source: None,
632    })
633}
634
635/// Resolve a configured search path, expanding a leading `~`.
636pub fn resolve_search_path(raw: &str) -> PathBuf {
637    expand_tilde(raw)
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    const TWO_AGENTS: &str = r#"
645[agents.claude]
646preset = "claude"
647model = "fable"
648
649[agents.codex]
650preset = "codex"
651model = "gpt-5.6-sol"
652"#;
653
654    #[test]
655    fn every_builtin_preset_parses() {
656        for (name, _) in BUILTIN_PRESETS {
657            let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
658            assert!(value.get("command").is_some(), "{name} has no command");
659        }
660    }
661
662    #[test]
663    fn every_builtin_preset_builds_a_spec() {
664        for (name, _) in BUILTIN_PRESETS {
665            let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
666            build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
667        }
668    }
669
670    /// `--allowedTools` is variadic, so the separate form swallows the
671    /// following positional prompt unless another flag happens to sit between
672    /// them. The equals form is not cosmetic.
673    #[test]
674    fn claude_preset_uses_the_equals_form_for_allowed_tools() {
675        let spec = build_spec(
676            "claude",
677            &parse_document("preset = \"claude\"", "test").unwrap(),
678        )
679        .unwrap();
680        let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
681        assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
682        assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
683    }
684
685    #[test]
686    fn codex_preset_declares_where_its_answer_lives() {
687        let spec = build_spec(
688            "codex",
689            &parse_document("preset = \"codex\"", "test").unwrap(),
690        )
691        .unwrap();
692        assert_eq!(OutputMode::Jsonl, spec.output);
693        assert_eq!(Some("item.text"), spec.message_path.as_deref());
694        assert!(!spec.message_match.is_empty());
695    }
696
697    #[test]
698    fn agent_order_follows_declaration_order() {
699        let cfg = parse(TWO_AGENTS).unwrap();
700        assert_eq!(vec!["claude", "codex"], cfg.agent_names());
701        assert_eq!("claude", cfg.first_implementor);
702    }
703
704    #[test]
705    fn other_alternates() {
706        let cfg = parse(TWO_AGENTS).unwrap();
707        assert_eq!("codex", cfg.other("claude"));
708        assert_eq!("claude", cfg.other("codex"));
709    }
710
711    #[test]
712    fn a_config_block_overrides_one_preset_field() {
713        let cfg = parse(TWO_AGENTS).unwrap();
714        let claude = cfg.spec("claude").unwrap();
715        assert_eq!(Some("fable"), claude.model.as_deref());
716        assert!(claude.command.len() > 1, "the preset command survived");
717    }
718
719    #[test]
720    fn exactly_two_agents_are_required() {
721        let one = "[agents.claude]\npreset = \"claude\"\n";
722        assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
723    }
724
725    #[test]
726    fn an_unknown_agent_option_is_named() {
727        let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
728        let err = parse(text).unwrap_err().to_string();
729        assert!(err.contains("widget"), "{err}");
730    }
731
732    #[test]
733    fn an_unknown_loop_option_is_named() {
734        let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
735        let err = parse(&text).unwrap_err().to_string();
736        assert!(err.contains("max_round"), "{err}");
737    }
738
739    #[test]
740    fn an_agent_with_no_command_and_no_preset_is_rejected() {
741        let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
742        let err = parse(text).unwrap_err().to_string();
743        assert!(err.contains("no command and no preset"), "{err}");
744    }
745
746    #[test]
747    fn jsonl_without_a_message_path_is_rejected() {
748        let text =
749            "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
750        let err = parse(text).unwrap_err().to_string();
751        assert!(err.contains("message_path"), "{err}");
752    }
753
754    #[test]
755    fn first_implementor_must_name_a_configured_agent() {
756        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
757        let err = parse(&text).unwrap_err().to_string();
758        assert!(err.contains("not a configured agent"), "{err}");
759    }
760
761    #[test]
762    fn defaults_are_the_conservative_ones() {
763        let cfg = parse(TWO_AGENTS).unwrap();
764        assert!(
765            !cfg.loop_cfg.auto_merge,
766            "auto_merge must be off by default"
767        );
768        assert!(cfg.loop_cfg.worktrees);
769        assert!(
770            !cfg.loop_cfg.file_nits,
771            "a filed nit is somebody else's triage queue"
772        );
773        assert_eq!(3, cfg.loop_cfg.max_rounds);
774        assert_eq!(Followups::Issues, cfg.loop_cfg.followups);
775        assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
776        assert!(cfg.style.terse);
777    }
778
779    #[test]
780    fn effort_schedule_splits_round_one_from_the_rest() {
781        let text =
782            format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
783        let cfg = parse(&text).unwrap();
784        let spec = cfg.spec("claude").unwrap();
785        assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
786        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
787        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
788    }
789
790    #[test]
791    fn effort_falls_back_to_the_agents_own_setting() {
792        let text = format!("{TWO_AGENTS}effort = \"low\"\n");
793        let cfg = parse(&text).unwrap();
794        let spec = cfg.spec("codex").unwrap();
795        assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
796    }
797
798    #[test]
799    fn an_unset_model_and_an_empty_model_normalise_the_same() {
800        let a = AgentSpec {
801            name: "a".into(),
802            command: vec![CommandPart::One("x".into())],
803            model: None,
804            effort: None,
805            output: OutputMode::Text,
806            message_match: BTreeMap::new(),
807            message_path: None,
808            search_paths: vec![],
809            system_via: SystemVia::Prompt,
810            timeout: 60,
811            models: vec![],
812            efforts: vec![],
813            options_note: None,
814        };
815        let b = AgentSpec {
816            model: Some("  ".into()),
817            ..a.clone()
818        };
819        assert_eq!(a.model_key(), b.model_key());
820    }
821
822    #[test]
823    fn max_rounds_zero_is_rejected() {
824        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
825        assert!(parse(&text).is_err());
826    }
827
828    #[test]
829    fn an_inline_command_needs_no_preset() {
830        let text = r#"
831[agents.custom]
832command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
833output = "text"
834
835[agents.other]
836command = ["othertool", "{prompt}"]
837"#;
838        let cfg = parse(text).unwrap();
839        assert_eq!(4, cfg.spec("custom").unwrap().command.len());
840    }
841
842    #[test]
843    fn style_budgets_are_configurable() {
844        let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
845        let cfg = parse(&text).unwrap();
846        assert!(!cfg.style.terse);
847        assert_eq!(40, cfg.style.max_detail_chars);
848    }
849}