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/// Whether a pull request spar opens starts as a draft, and when it stops being
185/// one.
186///
187/// A draft says the work is not for a person yet, which is exactly true while
188/// two agents are still arguing about it. `UntilApproved` makes that state mean
189/// something and clear itself: the loop marks the pull request ready the moment
190/// it has no blocking findings left. `Always` is for somebody who promotes
191/// every pull request by hand and wants spar to keep out of it.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum Drafts {
195    /// Open ordinary pull requests. The default, and what spar has always done.
196    Never,
197    /// Open as a draft, and mark it ready when the review converges.
198    UntilApproved,
199    /// Open as a draft and leave it that way.
200    Always,
201}
202
203impl std::fmt::Display for Drafts {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.write_str(match self {
206            Drafts::Never => "never",
207            Drafts::UntilApproved => "until_approved",
208            Drafts::Always => "always",
209        })
210    }
211}
212
213/// How much of its own working spar narrates into a pull request thread.
214///
215/// The agents never read the PR: they receive findings through their prompts,
216/// so nothing in the loop depends on any of this being posted. It exists purely
217/// for the person who reads the thread later, which is why the default is the
218/// outcome rather than the play by play.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "lowercase")]
221pub enum PrComments {
222    /// One comment when the run finishes, and only if it has something to say.
223    Outcome,
224    /// A comment per review and per response, as it happens. An audit trail,
225    /// at the cost of a thread nobody wants to read.
226    Rounds,
227    /// Never comment on a pull request. Everything goes to the terminal.
228    None,
229}
230
231impl std::fmt::Display for PrComments {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        f.write_str(match self {
234            PrComments::Outcome => "outcome",
235            PrComments::Rounds => "rounds",
236            PrComments::None => "none",
237        })
238    }
239}
240
241/// Where resume state lives. Local keeps the PR clean and costs no API calls;
242/// writing to the PR only buys anything if a run might be resumed from a
243/// different checkout.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "lowercase")]
246pub enum StateStore {
247    Local,
248    Pr,
249    Both,
250}
251
252impl StateStore {
253    pub fn writes_local(self) -> bool {
254        matches!(self, StateStore::Local | StateStore::Both)
255    }
256    pub fn writes_pr(self) -> bool {
257        matches!(self, StateStore::Pr | StateStore::Both)
258    }
259}
260
261/// Whose comments `spar checkin` will act on.
262///
263/// The default is not timidity. Acting on a comment means a commit pushed to a
264/// branch because somebody typed a sentence, and `authorAssociation` is one
265/// field GitHub already returns on every comment endpoint that says whether
266/// they can write to this repository at all. Everybody is still answered in
267/// words either way; this governs only whether a comment can produce a commit.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "lowercase")]
270pub enum Trust {
271    /// Anybody GitHub says can write here: OWNER, MEMBER, COLLABORATOR.
272    Write,
273    /// Anybody at all. Both agents still have to agree before anything changes.
274    Anyone,
275}
276
277impl std::fmt::Display for Trust {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.write_str(match self {
280            Trust::Write => "write",
281            Trust::Anyone => "anyone",
282        })
283    }
284}
285
286impl Trust {
287    /// Whether a comment from somebody with this association may produce a
288    /// commit.
289    pub fn may_act_on(self, association: &str) -> bool {
290        match self {
291            Trust::Anyone => true,
292            Trust::Write => matches!(
293                association.trim().to_uppercase().as_str(),
294                "OWNER" | "MEMBER" | "COLLABORATOR"
295            ),
296        }
297    }
298}
299
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct EffortSchedule {
303    /// The deep first review.
304    pub round_1: Option<String>,
305    /// Every round after the first, and the closing pass. Both are asked a
306    /// narrower question than the first review, so neither buys its depth again.
307    pub rest: Option<String>,
308}
309
310/// Every field takes its value from `LoopCfg::default()` when a config does not
311/// mention it, rather than from a per-field function saying the same thing in a
312/// second place. Two places is how a default goes stale.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[serde(default, deny_unknown_fields)]
315pub struct LoopCfg {
316    /// Review rounds one invocation may spend asking for changes. A run that
317    /// spends the full budget may use one closing pass, which is not a round.
318    pub max_rounds: u32,
319    pub auto_merge: bool,
320    pub first_implementor: Option<String>,
321    pub base_branch: String,
322    pub worktrees: bool,
323    pub keep_worktrees: bool,
324    pub state_store: StateStore,
325    pub branch_prefix: String,
326    pub followups: Followups,
327    /// File a non-blocking finding as a follow-up.
328    ///
329    /// Off by default, and this is the setting that stops a run breeding. A
330    /// thorough reviewer always finds improvements, and turning each one into a
331    /// tracker item made a single issue spawn ten, which spawned more: mean
332    /// offspring above one never terminates. Not gating a merge is not the same
333    /// as being worth somebody's triage queue.
334    pub file_non_blocking: bool,
335    /// Most follow-ups one run may record before it stops and says what it
336    /// dropped. A backstop, not a target.
337    pub max_followups: usize,
338    /// Most parts `spar split` will make out of one issue or pull request.
339    ///
340    /// A backstop, not a target, in the shape of `max_followups`. There is no
341    /// setting to turn splitting off: it is a command somebody types, and a
342    /// setting to disable a command nobody has to run only ever confuses. There
343    /// is no mechanical threshold either, because a floor on files changed
344    /// would split a forty file rename and hold back a three file mess.
345    pub max_split_parts: usize,
346    /// Nits stay in the PR thread by default. A filed nit is somebody else's
347    /// notification: a run on a production codebase once opened an issue titled
348    /// "Log wording".
349    pub file_nits: bool,
350    /// Close an issue that both agents independently declined, after posting
351    /// the shared reasoning. One agent's opinion is never enough.
352    pub close_skipped: bool,
353    /// Ask both agents to triage at the same time. They only read during
354    /// triage, so there is nothing to serialise.
355    pub parallel_triage: bool,
356    /// Ignore issues and pull requests numbered below this when spar is picking
357    /// for itself. 0 is no floor.
358    ///
359    /// A repository that has been going a while carries a tail of old issues
360    /// nobody is going to reach, and since spar takes the lowest numbered open
361    /// items it walks straight into them. A number you name explicitly is still
362    /// honoured: naming it is the point.
363    pub min_number: i64,
364    /// Waves of newly filed follow-ups to fold back into the same run, rather
365    /// than leaving them for the next one.
366    ///
367    /// Off by default because it multiplies what a run costs, and because each
368    /// wave can file follow-ups of its own. Every wave is triaged like any
369    /// other issue, so both agents still have to agree it is worth doing.
370    pub absorb_new_issues: u32,
371    /// Turn the checklist in a tracking issue into issues, and work them.
372    ///
373    /// Off by default, for the reason `file_non_blocking` is: a tracker exists
374    /// to contain many things, so this is the shape of feature that multiplies.
375    /// `max_tracker_children` caps it, a child that triage calls a tracker is
376    /// held rather than decomposed in turn, and both agents still gate every
377    /// child at triage.
378    ///
379    /// The trigger is the checklist, never a judgement about what the parts
380    /// are. Writing `- [ ]` lines is something a person does on purpose, which
381    /// makes this opt in per issue as well as per repository.
382    pub decompose_trackers: bool,
383    /// Most items from one tracker's checklist that one run may take on. A cap,
384    /// not a target, and what it left is named out loud.
385    pub max_tracker_children: usize,
386    /// Whether a pull request spar opens starts as a draft.
387    pub drafts: Drafts,
388    /// Extra instructions handed to both agents with every request.
389    ///
390    /// For what a person wants of this repository that the code cannot say and
391    /// spar has no setting for: how far to go, what not to touch, what not to
392    /// wait on. A CLI reads its own conventions file already, CLAUDE.md or
393    /// AGENTS.md, but each reads only its own, and two agents given different
394    /// standing instructions are not the pair this design rests on.
395    ///
396    /// Subordinate to the request and to the schema, which is said in the
397    /// header they arrive under: they change how the work is done, never what
398    /// was asked for or the shape of the answer.
399    pub instructions: String,
400    /// The most of one issue body that reaches a prompt.
401    ///
402    /// Sized so that no issue a person wrote is ever cut. It was 2000 for
403    /// triage and 6000 for implement, silently, and both were small enough to
404    /// clip an ordinary bug report: an agent given half an issue judges and
405    /// implements the half it saw and has no way to know the rest existed.
406    /// When this does fire it is said out loud, in the log and in the prompt.
407    pub max_issue_chars: usize,
408    /// The most every issue body together may add to one triage prompt.
409    ///
410    /// Triage reads the whole queue at once, so the only unbounded thing here
411    /// is the queue. Past this, whole issues are left for the next run rather
412    /// than every issue being shortened: a verdict is posted on the issue and
413    /// can close it, so judging one on part of its body is worse than not
414    /// reaching it yet.
415    pub max_triage_chars: usize,
416    /// Whose comments `spar checkin` will act on.
417    pub checkin_trust: Trust,
418    /// Mark a review thread resolved when spar made the change it asked for.
419    ///
420    /// A thread spar disagreed with is left open whatever this says: the person
421    /// who raised it has not had their say yet, and it is their thread.
422    pub checkin_resolve: bool,
423    /// Most unanswered comments spar answers on one pull request in a run.
424    ///
425    /// A backstop against a long argument being read back to somebody, not a
426    /// target. What it held back is said out loud.
427    pub max_checkin_comments: usize,
428    pub effort_schedule: EffortSchedule,
429}
430
431impl Default for LoopCfg {
432    fn default() -> Self {
433        Self {
434            max_rounds: 3,
435            auto_merge: false,
436            first_implementor: None,
437            base_branch: "main".into(),
438            worktrees: true,
439            keep_worktrees: false,
440            state_store: StateStore::Local,
441            branch_prefix: String::new(),
442            followups: Followups::Local,
443            file_non_blocking: false,
444            max_followups: 5,
445            max_split_parts: 4,
446            file_nits: false,
447            close_skipped: true,
448            parallel_triage: true,
449            min_number: 0,
450            absorb_new_issues: 0,
451            decompose_trackers: false,
452            max_tracker_children: 5,
453            drafts: Drafts::Never,
454            instructions: String::new(),
455            max_issue_chars: 60_000,
456            max_triage_chars: 200_000,
457            checkin_trust: Trust::Write,
458            checkin_resolve: true,
459            max_checkin_comments: 20,
460            effort_schedule: EffortSchedule::default(),
461        }
462    }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(default, deny_unknown_fields)]
467pub struct StyleCfg {
468    pub ban_em_dash: bool,
469    pub ban_ai_attribution: bool,
470    pub terse: bool,
471    pub max_detail_chars: usize,
472    pub max_summary_chars: usize,
473    pub max_body_chars: usize,
474    /// A filed issue's body. Far larger than a PR comment's on purpose: a
475    /// comment is read with the diff in front of you, an issue is picked up
476    /// cold months later by somebody who needs the whole story.
477    pub max_issue_body_chars: usize,
478    pub max_title_chars: usize,
479    pub pr_comments: PrComments,
480}
481
482impl Default for StyleCfg {
483    /// Taken from `Style`, which is where the budgets are decided, rather than
484    /// written out again here.
485    ///
486    /// They were written out again here, and they drifted. The functions
487    /// supplying them to serde were still named `d90`, `d200`, `d320`, `d900`
488    /// and `d4000` while returning 140, 2000, 6000, 8000 and 20000, and the
489    /// config `spar init` generated offered the old numbers as though they
490    /// were current. Uncommenting one of those lines to see what it did cut
491    /// every comment spar posts to a fifth of its length.
492    fn default() -> Self {
493        let style = Style::default();
494        Self {
495            ban_em_dash: style.ban_em_dash,
496            ban_ai_attribution: style.ban_ai_attribution,
497            terse: style.terse,
498            max_detail_chars: style.max_detail_chars,
499            max_summary_chars: style.max_summary_chars,
500            max_body_chars: style.max_body_chars,
501            max_issue_body_chars: style.max_issue_body_chars,
502            max_title_chars: style.max_title_chars,
503            pr_comments: style.pr_comments,
504        }
505    }
506}
507
508impl StyleCfg {
509    pub fn to_style(&self) -> Style {
510        Style {
511            ban_em_dash: self.ban_em_dash,
512            ban_ai_attribution: self.ban_ai_attribution,
513            terse: self.terse,
514            max_detail_chars: self.max_detail_chars,
515            max_summary_chars: self.max_summary_chars,
516            max_body_chars: self.max_body_chars,
517            max_issue_body_chars: self.max_issue_body_chars,
518            max_title_chars: self.max_title_chars,
519            pr_comments: self.pr_comments,
520        }
521    }
522}
523
524// ---------------------------------------------------------------------------
525// The whole config
526// ---------------------------------------------------------------------------
527
528#[derive(Debug, Clone)]
529pub struct Config {
530    /// In declaration order, which is what `first_implementor` defaults to.
531    pub agents: Vec<AgentSpec>,
532    pub loop_cfg: LoopCfg,
533    pub style: Style,
534    /// Resolved: never empty, always one of the configured agents.
535    pub first_implementor: String,
536    /// Where this config was read from, for error messages.
537    pub source: Option<PathBuf>,
538}
539
540impl Config {
541    pub fn agent_names(&self) -> Vec<String> {
542        self.agents.iter().map(|a| a.name.clone()).collect()
543    }
544
545    pub fn has_agent(&self, name: &str) -> bool {
546        self.agents.iter().any(|a| a.name == name)
547    }
548
549    pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
550        self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
551            spar_err!(
552                "no agent named '{name}' ({})",
553                self.agent_names().join(", ")
554            )
555        })
556    }
557
558    /// The other agent. With exactly two configured, custody alternates by
559    /// definition.
560    pub fn other(&self, name: &str) -> String {
561        let names = self.agent_names();
562        if names.first().map(String::as_str) == Some(name) {
563            names.get(1).cloned().unwrap_or_else(|| name.to_string())
564        } else {
565            names.first().cloned().unwrap_or_else(|| name.to_string())
566        }
567    }
568
569    /// Round 1 gets the deep pass; later rounds only see a small delta, and a
570    /// full ultra review of a three line delta is money on fire.
571    pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
572        let scheduled = if round <= 1 {
573            self.loop_cfg.effort_schedule.round_1.clone()
574        } else {
575            self.loop_cfg.effort_schedule.rest.clone()
576        };
577        scheduled
578            .filter(|s| !s.trim().is_empty())
579            .or_else(|| spec.effort.clone())
580    }
581
582    pub fn base_branch(&self) -> &str {
583        &self.loop_cfg.base_branch
584    }
585}
586
587#[derive(Debug, Deserialize)]
588#[serde(deny_unknown_fields)]
589struct RawConfig {
590    #[serde(default)]
591    agents: toml::Table,
592    #[serde(default)]
593    #[serde(rename = "loop")]
594    loop_cfg: Option<LoopCfg>,
595    #[serde(default)]
596    style: Option<StyleCfg>,
597}
598
599// ---------------------------------------------------------------------------
600// Presets
601// ---------------------------------------------------------------------------
602
603/// Directories searched for preset overrides, nearest first.
604///
605/// Deliberately *not* a bare `presets/`. spar runs from inside the user's own
606/// repository, where `presets/` is a perfectly ordinary directory name for
607/// something unrelated (sampler configs, prompt libraries, editor themes), and
608/// a stray `presets/claude.toml` shadowing the built in preset produces a
609/// baffling failure: `spar init` reports Claude Code as missing while it sits
610/// on PATH. Overrides live somewhere that names spar.
611pub fn preset_dirs() -> Vec<PathBuf> {
612    let mut dirs = Vec::new();
613    if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
614        dirs.push(PathBuf::from(custom));
615    }
616    dirs.push(PathBuf::from(".spar").join("presets"));
617    if let Some(home) = home_dir() {
618        dirs.push(home.join(".config").join("spar").join("presets"));
619    }
620    dirs
621}
622
623/// Every preset name available, built in and on disk, sorted.
624pub fn available_presets() -> Vec<String> {
625    let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
626    for dir in preset_dirs() {
627        if let Ok(entries) = std::fs::read_dir(&dir) {
628            for entry in entries.flatten() {
629                let path = entry.path();
630                if path.extension().and_then(|e| e.to_str()) == Some("toml") {
631                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
632                        names.push(stem.to_string());
633                    }
634                }
635            }
636        }
637    }
638    names.sort();
639    names.dedup();
640    names
641}
642
643/// Parse a whole TOML document into a `Value`.
644///
645/// `"...".parse::<Value>()` parses a single TOML *value*, not a document, so it
646/// rejects the first comment line of every preset.
647fn parse_document(text: &str, what: &str) -> Result<Value> {
648    let table: toml::Table =
649        toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
650    Ok(Value::Table(table))
651}
652
653/// Load a preset. A file on disk wins over the built in copy of the same name,
654/// so a drifting CLI can be corrected without waiting for a release.
655pub fn load_preset(name: &str) -> Result<Value> {
656    for dir in preset_dirs() {
657        let path = dir.join(format!("{name}.toml"));
658        if path.is_file() {
659            let text = std::fs::read_to_string(&path)
660                .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
661            return parse_document(&text, &format!("preset {}", path.display()));
662        }
663    }
664    for (builtin, text) in BUILTIN_PRESETS {
665        if *builtin == name {
666            return parse_document(text, &format!("built in preset {name}"));
667        }
668    }
669    Err(spar_err!(
670        "unknown preset '{name}'. Available: {}",
671        available_presets().join(", ")
672    ))
673}
674
675/// Deep merge, with `over` winning. Used so a config block can override one
676/// field of a preset without restating the whole command template.
677fn merge(base: &Value, over: &Value) -> Value {
678    match (base, over) {
679        (Value::Table(b), Value::Table(o)) => {
680            let mut out = b.clone();
681            for (key, value) in o {
682                let merged = match out.get(key) {
683                    Some(existing) => merge(existing, value),
684                    None => value.clone(),
685                };
686                out.insert(key.clone(), merged);
687            }
688            Value::Table(out)
689        }
690        _ => over.clone(),
691    }
692}
693
694fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
695    let table = raw
696        .as_table()
697        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
698
699    let merged = match table.get("preset").and_then(Value::as_str) {
700        Some(preset) => merge(&load_preset(preset)?, raw),
701        None => raw.clone(),
702    };
703
704    let mut merged_table = merged
705        .as_table()
706        .cloned()
707        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
708    merged_table.remove("preset");
709    // Lifted out before the spec is deserialized: a fallback is a whole agent,
710    // preset and all, and only this function knows how to resolve a preset.
711    let fallback_raw = merged_table.remove("fallback");
712
713    if !merged_table.contains_key("command") {
714        bail!(
715            "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
716            available_presets().join(", ")
717        );
718    }
719
720    let mut spec: AgentSpec = Value::Table(merged_table)
721        .try_into()
722        .map_err(|e| spar_err!("agent '{name}': {e}"))?;
723    spec.name = name.to_string();
724
725    if spec.command.is_empty() {
726        bail!("agent '{name}' has an empty command");
727    }
728    if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
729        bail!("agent '{name}': the first command element must be the program name, not a group");
730    }
731    if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
732        bail!(
733            "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
734        );
735    }
736
737    if let Some(raw) = fallback_raw {
738        if !raw.is_table() {
739            bail!(
740                "agent '{name}': fallback is a whole agent, so write it as a table:\n                   [agents.{name}.fallback]\n  preset = \"cursor\""
741            );
742        }
743        // Named for the env override it answers to, SPAR_<NAME>_FALLBACK_BIN,
744        // and so a log line says which agent stood in for which.
745        let backup = build_spec(&format!("{name}-fallback"), &raw)?;
746        if backup.fallback.is_some() {
747            bail!(
748                "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
749                 another full timeout on a call that has already failed once."
750            );
751        }
752        spec.fallback = Some(Box::new(backup));
753    }
754
755    Ok(spec)
756}
757
758// ---------------------------------------------------------------------------
759// Loading
760// ---------------------------------------------------------------------------
761
762/// One option the parser accepts, with the value it takes when unset.
763#[derive(Debug, Clone)]
764pub struct OptionInfo {
765    pub section: &'static str,
766    pub key: String,
767    pub default: String,
768}
769
770/// Every option a config file may set, with its default.
771///
772/// Derived from the defaults themselves rather than written out by hand, so an
773/// option added to the code cannot go missing here. That is what lets `doctor`
774/// tell somebody upgrading which settings are new since they wrote their file.
775pub fn known_options() -> Vec<OptionInfo> {
776    fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
777        toml::to_string(value)
778            .unwrap_or_default()
779            .lines()
780            .filter_map(|line| line.split_once(" = "))
781            .map(|(key, default)| OptionInfo {
782                section,
783                key: key.trim().to_string(),
784                default: default.trim().to_string(),
785            })
786            .collect()
787    }
788    let mut out = lines("loop", &LoopCfg::default());
789    out.extend(lines("style", &StyleCfg::default()));
790    out.extend(lines(
791        "loop.effort_schedule",
792        &EffortSchedule {
793            round_1: Some("high".into()),
794            rest: Some("low".into()),
795        },
796    ));
797    out
798}
799
800/// Whether a config file mentions an option at all, set or commented out.
801pub fn mentions(config_text: &str, key: &str) -> bool {
802    config_text.lines().any(|line| {
803        let bare = line.trim_start().trim_start_matches('#').trim_start();
804        bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
805    })
806}
807
808/// Options this config file has never heard of, which is what somebody who
809/// upgraded wants to know.
810pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
811    known_options()
812        .into_iter()
813        .filter(|o| !mentions(config_text, &o.key))
814        .collect()
815}
816
817pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
818
819/// Find a config: an explicit path, then the working directory, then
820/// `~/.config/spar/spar.toml`.
821pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
822    if let Some(path) = explicit {
823        if !path.is_file() {
824            bail!("config not found: {}", path.display());
825        }
826        return Ok(Some(path.to_path_buf()));
827    }
828    for name in CONFIG_NAMES {
829        let path = PathBuf::from(name);
830        if path.is_file() {
831            return Ok(Some(path));
832        }
833    }
834    if let Some(home) = home_dir() {
835        let path = home.join(".config").join("spar").join("spar.toml");
836        if path.is_file() {
837            return Ok(Some(path));
838        }
839    }
840    Ok(None)
841}
842
843pub fn load(explicit: Option<&Path>) -> Result<Config> {
844    let Some(path) = find_config(explicit)? else {
845        bail!(
846            "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
847        );
848    };
849    let text = std::fs::read_to_string(&path)
850        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
851    let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
852    cfg.source = Some(path);
853    Ok(cfg)
854}
855
856pub fn parse(text: &str) -> Result<Config> {
857    let raw: RawConfig = toml::from_str(text)?;
858
859    if raw.agents.len() != 2 {
860        bail!(
861            "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
862            raw.agents.len()
863        );
864    }
865
866    let mut agents = Vec::new();
867    for (name, value) in raw.agents.iter() {
868        agents.push(build_spec(name, value)?);
869    }
870
871    let loop_cfg = raw.loop_cfg.unwrap_or_default();
872    let style = raw.style.unwrap_or_default().to_style();
873
874    if loop_cfg.max_rounds == 0 {
875        bail!("max_rounds must be at least 1");
876    }
877    // A draft cannot be merged, so merging one means promoting it first, which
878    // is the one thing `always` asks spar not to do. Refusing is better than
879    // picking a winner: either setting alone is coherent and only somebody who
880    // set both can say which they meant.
881    if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
882        bail!(
883            "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
884             ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
885             to have it promoted when the review converges, or turn auto_merge off."
886        );
887    }
888
889    let first = match &loop_cfg.first_implementor {
890        Some(name) if !name.trim().is_empty() => name.trim().to_string(),
891        _ => agents[0].name.clone(),
892    };
893    if !agents.iter().any(|a| a.name == first) {
894        bail!(
895            "first_implementor '{first}' is not a configured agent ({})",
896            agents
897                .iter()
898                .map(|a| a.name.as_str())
899                .collect::<Vec<_>>()
900                .join(", ")
901        );
902    }
903
904    Ok(Config {
905        agents,
906        loop_cfg,
907        style,
908        first_implementor: first,
909        source: None,
910    })
911}
912
913/// Resolve a configured search path, expanding a leading `~`.
914pub fn resolve_search_path(raw: &str) -> PathBuf {
915    expand_tilde(raw)
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    const TWO_AGENTS: &str = r#"
923[agents.claude]
924preset = "claude"
925model = "fable"
926
927[agents.codex]
928preset = "codex"
929model = "gpt-5.6-sol"
930"#;
931
932    // -- fallback --------------------------------------------------------
933
934    #[test]
935    fn a_fallback_is_a_whole_agent_with_its_own_preset() {
936        let text = format!(
937            "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
938        );
939        let cfg = parse(&text).expect("parses");
940        // Still a pair. A backup is not a third opinion.
941        assert_eq!(2, cfg.agents.len());
942        let codex = cfg.spec("codex").expect("codex");
943        let backup = codex.fallback.as_ref().expect("fallback");
944        assert_eq!("codex-fallback", backup.name);
945        assert_eq!(Some("kimi-k3"), backup.model.as_deref());
946        assert_eq!(
947            Some(&CommandPart::One("cursor-agent".into())),
948            backup.command.first()
949        );
950    }
951
952    #[test]
953    fn the_agent_without_a_fallback_does_not_grow_one() {
954        let cfg = parse(TWO_AGENTS).expect("parses");
955        assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
956    }
957
958    #[test]
959    fn a_fallback_may_not_have_one_of_its_own() {
960        let text = format!(
961            "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
962             [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
963        );
964        let err = parse(&text).expect_err("rejected");
965        assert!(err.message().contains("may not have a fallback"), "{err}");
966    }
967
968    #[test]
969    fn a_fallback_written_as_a_string_says_what_it_should_be() {
970        let text = "[agents.claude]\npreset = \"claude\"\n\n\
971                    [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
972        let err = parse(text).expect_err("rejected");
973        assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
974    }
975
976    /// A block that names one setting keeps the defaults for every setting it
977    /// did not name. That is what the container level serde default buys: each
978    /// field used to carry its own default function repeating a number that
979    /// also lived in `Default`, and the two copies stopped agreeing.
980    #[test]
981    fn a_partial_block_keeps_the_defaults_it_did_not_name() {
982        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
983        let cfg = parse(&text).expect("parses");
984
985        assert_eq!(9, cfg.loop_cfg.max_rounds);
986        assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
987        assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
988
989        assert!(!cfg.style.terse);
990        assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
991        assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
992    }
993
994    /// The budgets are decided in `Style` and read from there by the config
995    /// layer. When they were written out in both places they drifted, and the
996    /// generated config offered the older set for months.
997    #[test]
998    fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
999        assert_eq!(Style::default(), StyleCfg::default().to_style());
1000    }
1001
1002    // -- drafts ------------------------------------------------------------
1003
1004    #[test]
1005    fn pull_requests_are_not_drafts_unless_asked_for() {
1006        assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1007    }
1008
1009    #[test]
1010    fn each_draft_setting_parses() {
1011        for (text, want) in [
1012            ("never", Drafts::Never),
1013            ("until_approved", Drafts::UntilApproved),
1014            ("always", Drafts::Always),
1015        ] {
1016            let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1017                .unwrap_or_else(|e| panic!("{text}: {e}"));
1018            assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1019        }
1020    }
1021
1022    /// Merging a draft means marking it ready, which is the one thing `always`
1023    /// asks spar not to do. Either setting alone is coherent, so refusing beats
1024    /// picking a winner between them.
1025    #[test]
1026    fn auto_merge_and_a_permanent_draft_are_refused_together() {
1027        let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1028        let err = parse(&text).expect_err("refused");
1029        assert!(err.message().contains("auto_merge"), "{err}");
1030        assert!(
1031            err.message().contains("until_approved"),
1032            "says the way out: {err}"
1033        );
1034    }
1035
1036    /// The pairing that does make sense: the draft clears when the review
1037    /// converges, and then it can merge.
1038    #[test]
1039    fn auto_merge_is_fine_with_a_draft_that_clears() {
1040        let text =
1041            format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1042        assert!(parse(&text).is_ok());
1043    }
1044
1045    #[test]
1046    fn every_builtin_preset_parses() {
1047        for (name, _) in BUILTIN_PRESETS {
1048            let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1049            assert!(value.get("command").is_some(), "{name} has no command");
1050        }
1051    }
1052
1053    #[test]
1054    fn every_builtin_preset_builds_a_spec() {
1055        for (name, _) in BUILTIN_PRESETS {
1056            let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1057            build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1058        }
1059    }
1060
1061    /// `--allowedTools` is variadic, so the separate form swallows the
1062    /// following positional prompt unless another flag happens to sit between
1063    /// them. The equals form is not cosmetic.
1064    #[test]
1065    fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1066        let spec = build_spec(
1067            "claude",
1068            &parse_document("preset = \"claude\"", "test").unwrap(),
1069        )
1070        .unwrap();
1071        let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1072        assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1073        assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1074    }
1075
1076    #[test]
1077    fn codex_preset_declares_where_its_answer_lives() {
1078        let spec = build_spec(
1079            "codex",
1080            &parse_document("preset = \"codex\"", "test").unwrap(),
1081        )
1082        .unwrap();
1083        assert_eq!(OutputMode::Jsonl, spec.output);
1084        assert_eq!(Some("item.text"), spec.message_path.as_deref());
1085        assert!(!spec.message_match.is_empty());
1086    }
1087
1088    #[test]
1089    fn agent_order_follows_declaration_order() {
1090        let cfg = parse(TWO_AGENTS).unwrap();
1091        assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1092        assert_eq!("claude", cfg.first_implementor);
1093    }
1094
1095    #[test]
1096    fn other_alternates() {
1097        let cfg = parse(TWO_AGENTS).unwrap();
1098        assert_eq!("codex", cfg.other("claude"));
1099        assert_eq!("claude", cfg.other("codex"));
1100    }
1101
1102    #[test]
1103    fn a_config_block_overrides_one_preset_field() {
1104        let cfg = parse(TWO_AGENTS).unwrap();
1105        let claude = cfg.spec("claude").unwrap();
1106        assert_eq!(Some("fable"), claude.model.as_deref());
1107        assert!(claude.command.len() > 1, "the preset command survived");
1108    }
1109
1110    #[test]
1111    fn exactly_two_agents_are_required() {
1112        let one = "[agents.claude]\npreset = \"claude\"\n";
1113        assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1114    }
1115
1116    #[test]
1117    fn an_unknown_agent_option_is_named() {
1118        let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1119        let err = parse(text).unwrap_err().to_string();
1120        assert!(err.contains("widget"), "{err}");
1121    }
1122
1123    #[test]
1124    fn an_unknown_loop_option_is_named() {
1125        let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1126        let err = parse(&text).unwrap_err().to_string();
1127        assert!(err.contains("max_round"), "{err}");
1128    }
1129
1130    #[test]
1131    fn an_agent_with_no_command_and_no_preset_is_rejected() {
1132        let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1133        let err = parse(text).unwrap_err().to_string();
1134        assert!(err.contains("no command and no preset"), "{err}");
1135    }
1136
1137    #[test]
1138    fn jsonl_without_a_message_path_is_rejected() {
1139        let text =
1140            "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1141        let err = parse(text).unwrap_err().to_string();
1142        assert!(err.contains("message_path"), "{err}");
1143    }
1144
1145    #[test]
1146    fn first_implementor_must_name_a_configured_agent() {
1147        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1148        let err = parse(&text).unwrap_err().to_string();
1149        assert!(err.contains("not a configured agent"), "{err}");
1150    }
1151
1152    #[test]
1153    fn defaults_are_the_conservative_ones() {
1154        let cfg = parse(TWO_AGENTS).unwrap();
1155        assert!(
1156            !cfg.loop_cfg.auto_merge,
1157            "auto_merge must be off by default"
1158        );
1159        assert!(cfg.loop_cfg.worktrees);
1160        assert!(
1161            !cfg.loop_cfg.file_nits,
1162            "a filed nit is somebody else's triage queue"
1163        );
1164        assert_eq!(3, cfg.loop_cfg.max_rounds);
1165        assert_eq!(
1166            Followups::Local,
1167            cfg.loop_cfg.followups,
1168            "the tracker is somebody's queue; the default must not write to it"
1169        );
1170        assert!(
1171            !cfg.loop_cfg.file_non_blocking,
1172            "a suggestion is not a tracker item"
1173        );
1174        assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1175        assert!(cfg.style.terse);
1176    }
1177
1178    #[test]
1179    fn effort_schedule_splits_round_one_from_the_rest() {
1180        let text =
1181            format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1182        let cfg = parse(&text).unwrap();
1183        let spec = cfg.spec("claude").unwrap();
1184        assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1185        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1186        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1187    }
1188
1189    #[test]
1190    fn effort_falls_back_to_the_agents_own_setting() {
1191        let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1192        let cfg = parse(&text).unwrap();
1193        let spec = cfg.spec("codex").unwrap();
1194        assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1195    }
1196
1197    #[test]
1198    fn an_unset_model_and_an_empty_model_normalise_the_same() {
1199        let a = AgentSpec {
1200            name: "a".into(),
1201            command: vec![CommandPart::One("x".into())],
1202            model: None,
1203            effort: None,
1204            output: OutputMode::Text,
1205            message_match: BTreeMap::new(),
1206            message_path: None,
1207            search_paths: vec![],
1208            system_via: SystemVia::Prompt,
1209            timeout: 60,
1210            fallback: None,
1211            models: vec![],
1212            efforts: vec![],
1213            options_note: None,
1214        };
1215        let b = AgentSpec {
1216            model: Some("  ".into()),
1217            ..a.clone()
1218        };
1219        assert_eq!(a.model_key(), b.model_key());
1220    }
1221
1222    #[test]
1223    fn max_rounds_zero_is_rejected() {
1224        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1225        assert!(parse(&text).is_err());
1226    }
1227
1228    #[test]
1229    fn an_inline_command_needs_no_preset() {
1230        let text = r#"
1231[agents.custom]
1232command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1233output = "text"
1234
1235[agents.other]
1236command = ["othertool", "{prompt}"]
1237"#;
1238        let cfg = parse(text).unwrap();
1239        assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1240    }
1241
1242    #[test]
1243    fn style_budgets_are_configurable() {
1244        let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1245        let cfg = parse(&text).unwrap();
1246        assert!(!cfg.style.terse);
1247        assert_eq!(40, cfg.style.max_detail_chars);
1248    }
1249}