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/// How much of its own working spar narrates into a pull request thread.
172///
173/// The agents never read the PR: they receive findings through their prompts,
174/// so nothing in the loop depends on any of this being posted. It exists purely
175/// for the person who reads the thread later, which is why the default is the
176/// outcome rather than the play by play.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "lowercase")]
179pub enum PrComments {
180    /// One comment when the run finishes, and only if it has something to say.
181    Outcome,
182    /// A comment per review and per response, as it happens. An audit trail,
183    /// at the cost of a thread nobody wants to read.
184    Rounds,
185    /// Never comment on a pull request. Everything goes to the terminal.
186    None,
187}
188
189impl std::fmt::Display for PrComments {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.write_str(match self {
192            PrComments::Outcome => "outcome",
193            PrComments::Rounds => "rounds",
194            PrComments::None => "none",
195        })
196    }
197}
198
199/// Where resume state lives. Local keeps the PR clean and costs no API calls;
200/// writing to the PR only buys anything if a run might be resumed from a
201/// different checkout.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "lowercase")]
204pub enum StateStore {
205    Local,
206    Pr,
207    Both,
208}
209
210impl StateStore {
211    pub fn writes_local(self) -> bool {
212        matches!(self, StateStore::Local | StateStore::Both)
213    }
214    pub fn writes_pr(self) -> bool {
215        matches!(self, StateStore::Pr | StateStore::Both)
216    }
217}
218
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct EffortSchedule {
222    /// The deep first review.
223    pub round_1: Option<String>,
224    /// Later rounds only see a small delta.
225    pub rest: Option<String>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct LoopCfg {
231    #[serde(default = "three")]
232    pub max_rounds: u32,
233    #[serde(default)]
234    pub auto_merge: bool,
235    #[serde(default)]
236    pub first_implementor: Option<String>,
237    #[serde(default = "main_branch")]
238    pub base_branch: String,
239    #[serde(default = "yes")]
240    pub worktrees: bool,
241    #[serde(default)]
242    pub keep_worktrees: bool,
243    #[serde(default = "store_local")]
244    pub state_store: StateStore,
245    #[serde(default)]
246    pub branch_prefix: String,
247    #[serde(default = "followups_local")]
248    pub followups: Followups,
249    /// File a non-blocking finding as a follow-up.
250    ///
251    /// Off by default, and this is the setting that stops a run breeding. A
252    /// thorough reviewer always finds improvements, and turning each one into a
253    /// tracker item made a single issue spawn ten, which spawned more: mean
254    /// offspring above one never terminates. Not gating a merge is not the same
255    /// as being worth somebody's triage queue.
256    #[serde(default)]
257    pub file_non_blocking: bool,
258    /// Most follow-ups one run may record before it stops and says what it
259    /// dropped. A backstop, not a target.
260    #[serde(default = "five")]
261    pub max_followups: usize,
262    /// Nits stay in the PR thread by default. A filed nit is somebody else's
263    /// notification: a run on a production codebase once opened an issue titled
264    /// "Log wording".
265    #[serde(default)]
266    pub file_nits: bool,
267    /// Close an issue that both agents independently declined, after posting
268    /// the shared reasoning. One agent's opinion is never enough.
269    #[serde(default = "yes")]
270    pub close_skipped: bool,
271    /// Ask both agents to triage at the same time. They only read during
272    /// triage, so there is nothing to serialise.
273    #[serde(default = "yes")]
274    pub parallel_triage: bool,
275    /// Ignore issues and pull requests numbered below this when spar is picking
276    /// for itself. 0 is no floor.
277    ///
278    /// A repository that has been going a while carries a tail of old issues
279    /// nobody is going to reach, and since spar takes the lowest numbered open
280    /// items it walks straight into them. A number you name explicitly is still
281    /// honoured: naming it is the point.
282    #[serde(default)]
283    pub min_number: i64,
284    /// Waves of newly filed follow-ups to fold back into the same run, rather
285    /// than leaving them for the next one.
286    ///
287    /// Off by default because it multiplies what a run costs, and because each
288    /// wave can file follow-ups of its own. Every wave is triaged like any
289    /// other issue, so both agents still have to agree it is worth doing.
290    #[serde(default)]
291    pub absorb_new_issues: u32,
292    #[serde(default)]
293    pub effort_schedule: EffortSchedule,
294}
295
296fn three() -> u32 {
297    3
298}
299fn main_branch() -> String {
300    "main".to_string()
301}
302fn yes() -> bool {
303    true
304}
305fn store_local() -> StateStore {
306    StateStore::Local
307}
308fn followups_local() -> Followups {
309    Followups::Local
310}
311fn five() -> usize {
312    5
313}
314
315impl Default for LoopCfg {
316    fn default() -> Self {
317        Self {
318            max_rounds: 3,
319            auto_merge: false,
320            first_implementor: None,
321            base_branch: "main".into(),
322            worktrees: true,
323            keep_worktrees: false,
324            state_store: StateStore::Local,
325            branch_prefix: String::new(),
326            followups: Followups::Local,
327            file_non_blocking: false,
328            max_followups: 5,
329            file_nits: false,
330            close_skipped: true,
331            parallel_triage: true,
332            min_number: 0,
333            absorb_new_issues: 0,
334            effort_schedule: EffortSchedule::default(),
335        }
336    }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub struct StyleCfg {
342    #[serde(default = "yes")]
343    pub ban_em_dash: bool,
344    #[serde(default = "yes")]
345    pub ban_ai_attribution: bool,
346    #[serde(default = "yes")]
347    pub terse: bool,
348    #[serde(default = "d320")]
349    pub max_detail_chars: usize,
350    #[serde(default = "d200")]
351    pub max_summary_chars: usize,
352    #[serde(default = "d900")]
353    pub max_body_chars: usize,
354    /// A filed issue's body. Far larger than a PR comment's on purpose: a
355    /// comment is read with the diff in front of you, an issue is picked up
356    /// cold months later by somebody who needs the whole story.
357    #[serde(default = "d4000")]
358    pub max_issue_body_chars: usize,
359    #[serde(default = "d90")]
360    pub max_title_chars: usize,
361    #[serde(default = "outcome_only")]
362    pub pr_comments: PrComments,
363}
364
365fn outcome_only() -> PrComments {
366    PrComments::Outcome
367}
368
369fn d320() -> usize {
370    6000
371}
372fn d200() -> usize {
373    2000
374}
375fn d900() -> usize {
376    8000
377}
378fn d4000() -> usize {
379    20000
380}
381fn d90() -> usize {
382    140
383}
384
385impl Default for StyleCfg {
386    fn default() -> Self {
387        Self {
388            ban_em_dash: true,
389            ban_ai_attribution: true,
390            terse: true,
391            max_detail_chars: 6000,
392            max_summary_chars: 2000,
393            max_body_chars: 8000,
394            max_issue_body_chars: 20000,
395            max_title_chars: 140,
396            pr_comments: PrComments::Outcome,
397        }
398    }
399}
400
401impl StyleCfg {
402    pub fn to_style(&self) -> Style {
403        Style {
404            ban_em_dash: self.ban_em_dash,
405            ban_ai_attribution: self.ban_ai_attribution,
406            terse: self.terse,
407            max_detail_chars: self.max_detail_chars,
408            max_summary_chars: self.max_summary_chars,
409            max_body_chars: self.max_body_chars,
410            max_issue_body_chars: self.max_issue_body_chars,
411            max_title_chars: self.max_title_chars,
412            pr_comments: self.pr_comments,
413        }
414    }
415}
416
417// ---------------------------------------------------------------------------
418// The whole config
419// ---------------------------------------------------------------------------
420
421#[derive(Debug, Clone)]
422pub struct Config {
423    /// In declaration order, which is what `first_implementor` defaults to.
424    pub agents: Vec<AgentSpec>,
425    pub loop_cfg: LoopCfg,
426    pub style: Style,
427    /// Resolved: never empty, always one of the configured agents.
428    pub first_implementor: String,
429    /// Where this config was read from, for error messages.
430    pub source: Option<PathBuf>,
431}
432
433impl Config {
434    pub fn agent_names(&self) -> Vec<String> {
435        self.agents.iter().map(|a| a.name.clone()).collect()
436    }
437
438    pub fn has_agent(&self, name: &str) -> bool {
439        self.agents.iter().any(|a| a.name == name)
440    }
441
442    pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
443        self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
444            spar_err!(
445                "no agent named '{name}' ({})",
446                self.agent_names().join(", ")
447            )
448        })
449    }
450
451    /// The other agent. With exactly two configured, custody alternates by
452    /// definition.
453    pub fn other(&self, name: &str) -> String {
454        let names = self.agent_names();
455        if names.first().map(String::as_str) == Some(name) {
456            names.get(1).cloned().unwrap_or_else(|| name.to_string())
457        } else {
458            names.first().cloned().unwrap_or_else(|| name.to_string())
459        }
460    }
461
462    /// Round 1 gets the deep pass; later rounds only see a small delta, and a
463    /// full ultra review of a three line delta is money on fire.
464    pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
465        let scheduled = if round <= 1 {
466            self.loop_cfg.effort_schedule.round_1.clone()
467        } else {
468            self.loop_cfg.effort_schedule.rest.clone()
469        };
470        scheduled
471            .filter(|s| !s.trim().is_empty())
472            .or_else(|| spec.effort.clone())
473    }
474
475    pub fn base_branch(&self) -> &str {
476        &self.loop_cfg.base_branch
477    }
478}
479
480#[derive(Debug, Deserialize)]
481#[serde(deny_unknown_fields)]
482struct RawConfig {
483    #[serde(default)]
484    agents: toml::Table,
485    #[serde(default)]
486    #[serde(rename = "loop")]
487    loop_cfg: Option<LoopCfg>,
488    #[serde(default)]
489    style: Option<StyleCfg>,
490}
491
492// ---------------------------------------------------------------------------
493// Presets
494// ---------------------------------------------------------------------------
495
496/// Directories searched for preset overrides, nearest first.
497///
498/// Deliberately *not* a bare `presets/`. spar runs from inside the user's own
499/// repository, where `presets/` is a perfectly ordinary directory name for
500/// something unrelated (sampler configs, prompt libraries, editor themes), and
501/// a stray `presets/claude.toml` shadowing the built in preset produces a
502/// baffling failure: `spar init` reports Claude Code as missing while it sits
503/// on PATH. Overrides live somewhere that names spar.
504pub fn preset_dirs() -> Vec<PathBuf> {
505    let mut dirs = Vec::new();
506    if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
507        dirs.push(PathBuf::from(custom));
508    }
509    dirs.push(PathBuf::from(".spar").join("presets"));
510    if let Some(home) = home_dir() {
511        dirs.push(home.join(".config").join("spar").join("presets"));
512    }
513    dirs
514}
515
516/// Every preset name available, built in and on disk, sorted.
517pub fn available_presets() -> Vec<String> {
518    let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
519    for dir in preset_dirs() {
520        if let Ok(entries) = std::fs::read_dir(&dir) {
521            for entry in entries.flatten() {
522                let path = entry.path();
523                if path.extension().and_then(|e| e.to_str()) == Some("toml") {
524                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
525                        names.push(stem.to_string());
526                    }
527                }
528            }
529        }
530    }
531    names.sort();
532    names.dedup();
533    names
534}
535
536/// Parse a whole TOML document into a `Value`.
537///
538/// `"...".parse::<Value>()` parses a single TOML *value*, not a document, so it
539/// rejects the first comment line of every preset.
540fn parse_document(text: &str, what: &str) -> Result<Value> {
541    let table: toml::Table =
542        toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
543    Ok(Value::Table(table))
544}
545
546/// Load a preset. A file on disk wins over the built in copy of the same name,
547/// so a drifting CLI can be corrected without waiting for a release.
548pub fn load_preset(name: &str) -> Result<Value> {
549    for dir in preset_dirs() {
550        let path = dir.join(format!("{name}.toml"));
551        if path.is_file() {
552            let text = std::fs::read_to_string(&path)
553                .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
554            return parse_document(&text, &format!("preset {}", path.display()));
555        }
556    }
557    for (builtin, text) in BUILTIN_PRESETS {
558        if *builtin == name {
559            return parse_document(text, &format!("built in preset {name}"));
560        }
561    }
562    Err(spar_err!(
563        "unknown preset '{name}'. Available: {}",
564        available_presets().join(", ")
565    ))
566}
567
568/// Deep merge, with `over` winning. Used so a config block can override one
569/// field of a preset without restating the whole command template.
570fn merge(base: &Value, over: &Value) -> Value {
571    match (base, over) {
572        (Value::Table(b), Value::Table(o)) => {
573            let mut out = b.clone();
574            for (key, value) in o {
575                let merged = match out.get(key) {
576                    Some(existing) => merge(existing, value),
577                    None => value.clone(),
578                };
579                out.insert(key.clone(), merged);
580            }
581            Value::Table(out)
582        }
583        _ => over.clone(),
584    }
585}
586
587fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
588    let table = raw
589        .as_table()
590        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
591
592    let merged = match table.get("preset").and_then(Value::as_str) {
593        Some(preset) => merge(&load_preset(preset)?, raw),
594        None => raw.clone(),
595    };
596
597    let mut merged_table = merged
598        .as_table()
599        .cloned()
600        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
601    merged_table.remove("preset");
602
603    if !merged_table.contains_key("command") {
604        bail!(
605            "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
606            available_presets().join(", ")
607        );
608    }
609
610    let mut spec: AgentSpec = Value::Table(merged_table)
611        .try_into()
612        .map_err(|e| spar_err!("agent '{name}': {e}"))?;
613    spec.name = name.to_string();
614
615    if spec.command.is_empty() {
616        bail!("agent '{name}' has an empty command");
617    }
618    if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
619        bail!("agent '{name}': the first command element must be the program name, not a group");
620    }
621    if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
622        bail!(
623            "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
624        );
625    }
626    Ok(spec)
627}
628
629// ---------------------------------------------------------------------------
630// Loading
631// ---------------------------------------------------------------------------
632
633/// One option the parser accepts, with the value it takes when unset.
634#[derive(Debug, Clone)]
635pub struct OptionInfo {
636    pub section: &'static str,
637    pub key: String,
638    pub default: String,
639}
640
641/// Every option a config file may set, with its default.
642///
643/// Derived from the defaults themselves rather than written out by hand, so an
644/// option added to the code cannot go missing here. That is what lets `doctor`
645/// tell somebody upgrading which settings are new since they wrote their file.
646pub fn known_options() -> Vec<OptionInfo> {
647    fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
648        toml::to_string(value)
649            .unwrap_or_default()
650            .lines()
651            .filter_map(|line| line.split_once(" = "))
652            .map(|(key, default)| OptionInfo {
653                section,
654                key: key.trim().to_string(),
655                default: default.trim().to_string(),
656            })
657            .collect()
658    }
659    let mut out = lines("loop", &LoopCfg::default());
660    out.extend(lines("style", &StyleCfg::default()));
661    out.extend(lines(
662        "loop.effort_schedule",
663        &EffortSchedule {
664            round_1: Some("high".into()),
665            rest: Some("low".into()),
666        },
667    ));
668    out
669}
670
671/// Whether a config file mentions an option at all, set or commented out.
672pub fn mentions(config_text: &str, key: &str) -> bool {
673    config_text.lines().any(|line| {
674        let bare = line.trim_start().trim_start_matches('#').trim_start();
675        bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
676    })
677}
678
679/// Options this config file has never heard of, which is what somebody who
680/// upgraded wants to know.
681pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
682    known_options()
683        .into_iter()
684        .filter(|o| !mentions(config_text, &o.key))
685        .collect()
686}
687
688pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
689
690/// Find a config: an explicit path, then the working directory, then
691/// `~/.config/spar/spar.toml`.
692pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
693    if let Some(path) = explicit {
694        if !path.is_file() {
695            bail!("config not found: {}", path.display());
696        }
697        return Ok(Some(path.to_path_buf()));
698    }
699    for name in CONFIG_NAMES {
700        let path = PathBuf::from(name);
701        if path.is_file() {
702            return Ok(Some(path));
703        }
704    }
705    if let Some(home) = home_dir() {
706        let path = home.join(".config").join("spar").join("spar.toml");
707        if path.is_file() {
708            return Ok(Some(path));
709        }
710    }
711    Ok(None)
712}
713
714pub fn load(explicit: Option<&Path>) -> Result<Config> {
715    let Some(path) = find_config(explicit)? else {
716        bail!(
717            "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
718        );
719    };
720    let text = std::fs::read_to_string(&path)
721        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
722    let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
723    cfg.source = Some(path);
724    Ok(cfg)
725}
726
727pub fn parse(text: &str) -> Result<Config> {
728    let raw: RawConfig = toml::from_str(text)?;
729
730    if raw.agents.len() != 2 {
731        bail!(
732            "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
733            raw.agents.len()
734        );
735    }
736
737    let mut agents = Vec::new();
738    for (name, value) in raw.agents.iter() {
739        agents.push(build_spec(name, value)?);
740    }
741
742    let loop_cfg = raw.loop_cfg.unwrap_or_default();
743    let style = raw.style.unwrap_or_default().to_style();
744
745    if loop_cfg.max_rounds == 0 {
746        bail!("max_rounds must be at least 1");
747    }
748
749    let first = match &loop_cfg.first_implementor {
750        Some(name) if !name.trim().is_empty() => name.trim().to_string(),
751        _ => agents[0].name.clone(),
752    };
753    if !agents.iter().any(|a| a.name == first) {
754        bail!(
755            "first_implementor '{first}' is not a configured agent ({})",
756            agents
757                .iter()
758                .map(|a| a.name.as_str())
759                .collect::<Vec<_>>()
760                .join(", ")
761        );
762    }
763
764    Ok(Config {
765        agents,
766        loop_cfg,
767        style,
768        first_implementor: first,
769        source: None,
770    })
771}
772
773/// Resolve a configured search path, expanding a leading `~`.
774pub fn resolve_search_path(raw: &str) -> PathBuf {
775    expand_tilde(raw)
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    const TWO_AGENTS: &str = r#"
783[agents.claude]
784preset = "claude"
785model = "fable"
786
787[agents.codex]
788preset = "codex"
789model = "gpt-5.6-sol"
790"#;
791
792    #[test]
793    fn every_builtin_preset_parses() {
794        for (name, _) in BUILTIN_PRESETS {
795            let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
796            assert!(value.get("command").is_some(), "{name} has no command");
797        }
798    }
799
800    #[test]
801    fn every_builtin_preset_builds_a_spec() {
802        for (name, _) in BUILTIN_PRESETS {
803            let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
804            build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
805        }
806    }
807
808    /// `--allowedTools` is variadic, so the separate form swallows the
809    /// following positional prompt unless another flag happens to sit between
810    /// them. The equals form is not cosmetic.
811    #[test]
812    fn claude_preset_uses_the_equals_form_for_allowed_tools() {
813        let spec = build_spec(
814            "claude",
815            &parse_document("preset = \"claude\"", "test").unwrap(),
816        )
817        .unwrap();
818        let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
819        assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
820        assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
821    }
822
823    #[test]
824    fn codex_preset_declares_where_its_answer_lives() {
825        let spec = build_spec(
826            "codex",
827            &parse_document("preset = \"codex\"", "test").unwrap(),
828        )
829        .unwrap();
830        assert_eq!(OutputMode::Jsonl, spec.output);
831        assert_eq!(Some("item.text"), spec.message_path.as_deref());
832        assert!(!spec.message_match.is_empty());
833    }
834
835    #[test]
836    fn agent_order_follows_declaration_order() {
837        let cfg = parse(TWO_AGENTS).unwrap();
838        assert_eq!(vec!["claude", "codex"], cfg.agent_names());
839        assert_eq!("claude", cfg.first_implementor);
840    }
841
842    #[test]
843    fn other_alternates() {
844        let cfg = parse(TWO_AGENTS).unwrap();
845        assert_eq!("codex", cfg.other("claude"));
846        assert_eq!("claude", cfg.other("codex"));
847    }
848
849    #[test]
850    fn a_config_block_overrides_one_preset_field() {
851        let cfg = parse(TWO_AGENTS).unwrap();
852        let claude = cfg.spec("claude").unwrap();
853        assert_eq!(Some("fable"), claude.model.as_deref());
854        assert!(claude.command.len() > 1, "the preset command survived");
855    }
856
857    #[test]
858    fn exactly_two_agents_are_required() {
859        let one = "[agents.claude]\npreset = \"claude\"\n";
860        assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
861    }
862
863    #[test]
864    fn an_unknown_agent_option_is_named() {
865        let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
866        let err = parse(text).unwrap_err().to_string();
867        assert!(err.contains("widget"), "{err}");
868    }
869
870    #[test]
871    fn an_unknown_loop_option_is_named() {
872        let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
873        let err = parse(&text).unwrap_err().to_string();
874        assert!(err.contains("max_round"), "{err}");
875    }
876
877    #[test]
878    fn an_agent_with_no_command_and_no_preset_is_rejected() {
879        let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
880        let err = parse(text).unwrap_err().to_string();
881        assert!(err.contains("no command and no preset"), "{err}");
882    }
883
884    #[test]
885    fn jsonl_without_a_message_path_is_rejected() {
886        let text =
887            "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
888        let err = parse(text).unwrap_err().to_string();
889        assert!(err.contains("message_path"), "{err}");
890    }
891
892    #[test]
893    fn first_implementor_must_name_a_configured_agent() {
894        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
895        let err = parse(&text).unwrap_err().to_string();
896        assert!(err.contains("not a configured agent"), "{err}");
897    }
898
899    #[test]
900    fn defaults_are_the_conservative_ones() {
901        let cfg = parse(TWO_AGENTS).unwrap();
902        assert!(
903            !cfg.loop_cfg.auto_merge,
904            "auto_merge must be off by default"
905        );
906        assert!(cfg.loop_cfg.worktrees);
907        assert!(
908            !cfg.loop_cfg.file_nits,
909            "a filed nit is somebody else's triage queue"
910        );
911        assert_eq!(3, cfg.loop_cfg.max_rounds);
912        assert_eq!(
913            Followups::Local,
914            cfg.loop_cfg.followups,
915            "the tracker is somebody's queue; the default must not write to it"
916        );
917        assert!(
918            !cfg.loop_cfg.file_non_blocking,
919            "a suggestion is not a tracker item"
920        );
921        assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
922        assert!(cfg.style.terse);
923    }
924
925    #[test]
926    fn effort_schedule_splits_round_one_from_the_rest() {
927        let text =
928            format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
929        let cfg = parse(&text).unwrap();
930        let spec = cfg.spec("claude").unwrap();
931        assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
932        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
933        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
934    }
935
936    #[test]
937    fn effort_falls_back_to_the_agents_own_setting() {
938        let text = format!("{TWO_AGENTS}effort = \"low\"\n");
939        let cfg = parse(&text).unwrap();
940        let spec = cfg.spec("codex").unwrap();
941        assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
942    }
943
944    #[test]
945    fn an_unset_model_and_an_empty_model_normalise_the_same() {
946        let a = AgentSpec {
947            name: "a".into(),
948            command: vec![CommandPart::One("x".into())],
949            model: None,
950            effort: None,
951            output: OutputMode::Text,
952            message_match: BTreeMap::new(),
953            message_path: None,
954            search_paths: vec![],
955            system_via: SystemVia::Prompt,
956            timeout: 60,
957            models: vec![],
958            efforts: vec![],
959            options_note: None,
960        };
961        let b = AgentSpec {
962            model: Some("  ".into()),
963            ..a.clone()
964        };
965        assert_eq!(a.model_key(), b.model_key());
966    }
967
968    #[test]
969    fn max_rounds_zero_is_rejected() {
970        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
971        assert!(parse(&text).is_err());
972    }
973
974    #[test]
975    fn an_inline_command_needs_no_preset() {
976        let text = r#"
977[agents.custom]
978command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
979output = "text"
980
981[agents.other]
982command = ["othertool", "{prompt}"]
983"#;
984        let cfg = parse(text).unwrap();
985        assert_eq!(4, cfg.spec("custom").unwrap().command.len());
986    }
987
988    #[test]
989    fn style_budgets_are_configurable() {
990        let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
991        let cfg = parse(&text).unwrap();
992        assert!(!cfg.style.terse);
993        assert_eq!(40, cfg.style.max_detail_chars);
994    }
995}