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