1use 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
21pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum OutputMode {
55 Text,
57 Json,
59 Jsonl,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum SystemVia {
67 Prompt,
69 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#[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 #[serde(default)]
101 pub message_match: BTreeMap<String, String>,
102 #[serde(default)]
104 pub message_path: Option<String>,
105 #[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 #[serde(default)]
121 pub models: Vec<String>,
122 #[serde(default)]
124 pub efforts: Vec<String>,
125 #[serde(default)]
127 pub options_note: Option<String>,
128}
129
130impl AgentSpec {
131 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "lowercase")]
176pub enum StateStore {
177 Local,
178 Pr,
179 Both,
180}
181
182impl StateStore {
183 pub fn writes_local(self) -> bool {
184 matches!(self, StateStore::Local | StateStore::Both)
185 }
186 pub fn writes_pr(self) -> bool {
187 matches!(self, StateStore::Pr | StateStore::Both)
188 }
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct EffortSchedule {
194 pub round_1: Option<String>,
196 pub rest: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct LoopCfg {
203 #[serde(default = "three")]
204 pub max_rounds: u32,
205 #[serde(default)]
206 pub auto_merge: bool,
207 #[serde(default)]
208 pub first_implementor: Option<String>,
209 #[serde(default = "main_branch")]
210 pub base_branch: String,
211 #[serde(default = "yes")]
212 pub worktrees: bool,
213 #[serde(default)]
214 pub keep_worktrees: bool,
215 #[serde(default = "store_local")]
216 pub state_store: StateStore,
217 #[serde(default)]
218 pub branch_prefix: String,
219 #[serde(default = "followups_issues")]
220 pub followups: Followups,
221 #[serde(default)]
225 pub file_nits: bool,
226 #[serde(default = "yes")]
229 pub close_skipped: bool,
230 #[serde(default = "yes")]
233 pub parallel_triage: bool,
234 #[serde(default)]
235 pub effort_schedule: EffortSchedule,
236}
237
238fn three() -> u32 {
239 3
240}
241fn main_branch() -> String {
242 "main".to_string()
243}
244fn yes() -> bool {
245 true
246}
247fn store_local() -> StateStore {
248 StateStore::Local
249}
250fn followups_issues() -> Followups {
251 Followups::Issues
252}
253
254impl Default for LoopCfg {
255 fn default() -> Self {
256 Self {
257 max_rounds: 3,
258 auto_merge: false,
259 first_implementor: None,
260 base_branch: "main".into(),
261 worktrees: true,
262 keep_worktrees: false,
263 state_store: StateStore::Local,
264 branch_prefix: String::new(),
265 followups: Followups::Issues,
266 file_nits: false,
267 close_skipped: true,
268 parallel_triage: true,
269 effort_schedule: EffortSchedule::default(),
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct StyleCfg {
277 #[serde(default = "yes")]
278 pub ban_em_dash: bool,
279 #[serde(default = "yes")]
280 pub ban_ai_attribution: bool,
281 #[serde(default = "yes")]
282 pub terse: bool,
283 #[serde(default = "d320")]
284 pub max_detail_chars: usize,
285 #[serde(default = "d200")]
286 pub max_summary_chars: usize,
287 #[serde(default = "d900")]
288 pub max_body_chars: usize,
289 #[serde(default = "d90")]
290 pub max_title_chars: usize,
291}
292
293fn d320() -> usize {
294 320
295}
296fn d200() -> usize {
297 200
298}
299fn d900() -> usize {
300 900
301}
302fn d90() -> usize {
303 90
304}
305
306impl Default for StyleCfg {
307 fn default() -> Self {
308 Self {
309 ban_em_dash: true,
310 ban_ai_attribution: true,
311 terse: true,
312 max_detail_chars: 320,
313 max_summary_chars: 200,
314 max_body_chars: 900,
315 max_title_chars: 90,
316 }
317 }
318}
319
320impl StyleCfg {
321 pub fn to_style(&self) -> Style {
322 Style {
323 ban_em_dash: self.ban_em_dash,
324 ban_ai_attribution: self.ban_ai_attribution,
325 terse: self.terse,
326 max_detail_chars: self.max_detail_chars,
327 max_summary_chars: self.max_summary_chars,
328 max_body_chars: self.max_body_chars,
329 max_title_chars: self.max_title_chars,
330 }
331 }
332}
333
334#[derive(Debug, Clone)]
339pub struct Config {
340 pub agents: Vec<AgentSpec>,
342 pub loop_cfg: LoopCfg,
343 pub style: Style,
344 pub first_implementor: String,
346 pub source: Option<PathBuf>,
348}
349
350impl Config {
351 pub fn agent_names(&self) -> Vec<String> {
352 self.agents.iter().map(|a| a.name.clone()).collect()
353 }
354
355 pub fn has_agent(&self, name: &str) -> bool {
356 self.agents.iter().any(|a| a.name == name)
357 }
358
359 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
360 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
361 spar_err!(
362 "no agent named '{name}' ({})",
363 self.agent_names().join(", ")
364 )
365 })
366 }
367
368 pub fn other(&self, name: &str) -> String {
371 let names = self.agent_names();
372 if names.first().map(String::as_str) == Some(name) {
373 names.get(1).cloned().unwrap_or_else(|| name.to_string())
374 } else {
375 names.first().cloned().unwrap_or_else(|| name.to_string())
376 }
377 }
378
379 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
382 let scheduled = if round <= 1 {
383 self.loop_cfg.effort_schedule.round_1.clone()
384 } else {
385 self.loop_cfg.effort_schedule.rest.clone()
386 };
387 scheduled
388 .filter(|s| !s.trim().is_empty())
389 .or_else(|| spec.effort.clone())
390 }
391
392 pub fn base_branch(&self) -> &str {
393 &self.loop_cfg.base_branch
394 }
395}
396
397#[derive(Debug, Deserialize)]
398#[serde(deny_unknown_fields)]
399struct RawConfig {
400 #[serde(default)]
401 agents: toml::Table,
402 #[serde(default)]
403 #[serde(rename = "loop")]
404 loop_cfg: Option<LoopCfg>,
405 #[serde(default)]
406 style: Option<StyleCfg>,
407}
408
409pub fn preset_dirs() -> Vec<PathBuf> {
422 let mut dirs = Vec::new();
423 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
424 dirs.push(PathBuf::from(custom));
425 }
426 dirs.push(PathBuf::from(".spar").join("presets"));
427 if let Some(home) = home_dir() {
428 dirs.push(home.join(".config").join("spar").join("presets"));
429 }
430 dirs
431}
432
433pub fn available_presets() -> Vec<String> {
435 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
436 for dir in preset_dirs() {
437 if let Ok(entries) = std::fs::read_dir(&dir) {
438 for entry in entries.flatten() {
439 let path = entry.path();
440 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
441 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
442 names.push(stem.to_string());
443 }
444 }
445 }
446 }
447 }
448 names.sort();
449 names.dedup();
450 names
451}
452
453fn parse_document(text: &str, what: &str) -> Result<Value> {
458 let table: toml::Table =
459 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
460 Ok(Value::Table(table))
461}
462
463pub fn load_preset(name: &str) -> Result<Value> {
466 for dir in preset_dirs() {
467 let path = dir.join(format!("{name}.toml"));
468 if path.is_file() {
469 let text = std::fs::read_to_string(&path)
470 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
471 return parse_document(&text, &format!("preset {}", path.display()));
472 }
473 }
474 for (builtin, text) in BUILTIN_PRESETS {
475 if *builtin == name {
476 return parse_document(text, &format!("built in preset {name}"));
477 }
478 }
479 Err(spar_err!(
480 "unknown preset '{name}'. Available: {}",
481 available_presets().join(", ")
482 ))
483}
484
485fn merge(base: &Value, over: &Value) -> Value {
488 match (base, over) {
489 (Value::Table(b), Value::Table(o)) => {
490 let mut out = b.clone();
491 for (key, value) in o {
492 let merged = match out.get(key) {
493 Some(existing) => merge(existing, value),
494 None => value.clone(),
495 };
496 out.insert(key.clone(), merged);
497 }
498 Value::Table(out)
499 }
500 _ => over.clone(),
501 }
502}
503
504fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
505 let table = raw
506 .as_table()
507 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
508
509 let merged = match table.get("preset").and_then(Value::as_str) {
510 Some(preset) => merge(&load_preset(preset)?, raw),
511 None => raw.clone(),
512 };
513
514 let mut merged_table = merged
515 .as_table()
516 .cloned()
517 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
518 merged_table.remove("preset");
519
520 if !merged_table.contains_key("command") {
521 bail!(
522 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
523 available_presets().join(", ")
524 );
525 }
526
527 let mut spec: AgentSpec = Value::Table(merged_table)
528 .try_into()
529 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
530 spec.name = name.to_string();
531
532 if spec.command.is_empty() {
533 bail!("agent '{name}' has an empty command");
534 }
535 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
536 bail!("agent '{name}': the first command element must be the program name, not a group");
537 }
538 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
539 bail!(
540 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
541 );
542 }
543 Ok(spec)
544}
545
546pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
551
552pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
555 if let Some(path) = explicit {
556 if !path.is_file() {
557 bail!("config not found: {}", path.display());
558 }
559 return Ok(Some(path.to_path_buf()));
560 }
561 for name in CONFIG_NAMES {
562 let path = PathBuf::from(name);
563 if path.is_file() {
564 return Ok(Some(path));
565 }
566 }
567 if let Some(home) = home_dir() {
568 let path = home.join(".config").join("spar").join("spar.toml");
569 if path.is_file() {
570 return Ok(Some(path));
571 }
572 }
573 Ok(None)
574}
575
576pub fn load(explicit: Option<&Path>) -> Result<Config> {
577 let Some(path) = find_config(explicit)? else {
578 bail!(
579 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
580 );
581 };
582 let text = std::fs::read_to_string(&path)
583 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
584 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
585 cfg.source = Some(path);
586 Ok(cfg)
587}
588
589pub fn parse(text: &str) -> Result<Config> {
590 let raw: RawConfig = toml::from_str(text)?;
591
592 if raw.agents.len() != 2 {
593 bail!(
594 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
595 raw.agents.len()
596 );
597 }
598
599 let mut agents = Vec::new();
600 for (name, value) in raw.agents.iter() {
601 agents.push(build_spec(name, value)?);
602 }
603
604 let loop_cfg = raw.loop_cfg.unwrap_or_default();
605 let style = raw.style.unwrap_or_default().to_style();
606
607 if loop_cfg.max_rounds == 0 {
608 bail!("max_rounds must be at least 1");
609 }
610
611 let first = match &loop_cfg.first_implementor {
612 Some(name) if !name.trim().is_empty() => name.trim().to_string(),
613 _ => agents[0].name.clone(),
614 };
615 if !agents.iter().any(|a| a.name == first) {
616 bail!(
617 "first_implementor '{first}' is not a configured agent ({})",
618 agents
619 .iter()
620 .map(|a| a.name.as_str())
621 .collect::<Vec<_>>()
622 .join(", ")
623 );
624 }
625
626 Ok(Config {
627 agents,
628 loop_cfg,
629 style,
630 first_implementor: first,
631 source: None,
632 })
633}
634
635pub fn resolve_search_path(raw: &str) -> PathBuf {
637 expand_tilde(raw)
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 const TWO_AGENTS: &str = r#"
645[agents.claude]
646preset = "claude"
647model = "fable"
648
649[agents.codex]
650preset = "codex"
651model = "gpt-5.6-sol"
652"#;
653
654 #[test]
655 fn every_builtin_preset_parses() {
656 for (name, _) in BUILTIN_PRESETS {
657 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
658 assert!(value.get("command").is_some(), "{name} has no command");
659 }
660 }
661
662 #[test]
663 fn every_builtin_preset_builds_a_spec() {
664 for (name, _) in BUILTIN_PRESETS {
665 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
666 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
667 }
668 }
669
670 #[test]
674 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
675 let spec = build_spec(
676 "claude",
677 &parse_document("preset = \"claude\"", "test").unwrap(),
678 )
679 .unwrap();
680 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
681 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
682 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
683 }
684
685 #[test]
686 fn codex_preset_declares_where_its_answer_lives() {
687 let spec = build_spec(
688 "codex",
689 &parse_document("preset = \"codex\"", "test").unwrap(),
690 )
691 .unwrap();
692 assert_eq!(OutputMode::Jsonl, spec.output);
693 assert_eq!(Some("item.text"), spec.message_path.as_deref());
694 assert!(!spec.message_match.is_empty());
695 }
696
697 #[test]
698 fn agent_order_follows_declaration_order() {
699 let cfg = parse(TWO_AGENTS).unwrap();
700 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
701 assert_eq!("claude", cfg.first_implementor);
702 }
703
704 #[test]
705 fn other_alternates() {
706 let cfg = parse(TWO_AGENTS).unwrap();
707 assert_eq!("codex", cfg.other("claude"));
708 assert_eq!("claude", cfg.other("codex"));
709 }
710
711 #[test]
712 fn a_config_block_overrides_one_preset_field() {
713 let cfg = parse(TWO_AGENTS).unwrap();
714 let claude = cfg.spec("claude").unwrap();
715 assert_eq!(Some("fable"), claude.model.as_deref());
716 assert!(claude.command.len() > 1, "the preset command survived");
717 }
718
719 #[test]
720 fn exactly_two_agents_are_required() {
721 let one = "[agents.claude]\npreset = \"claude\"\n";
722 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
723 }
724
725 #[test]
726 fn an_unknown_agent_option_is_named() {
727 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
728 let err = parse(text).unwrap_err().to_string();
729 assert!(err.contains("widget"), "{err}");
730 }
731
732 #[test]
733 fn an_unknown_loop_option_is_named() {
734 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
735 let err = parse(&text).unwrap_err().to_string();
736 assert!(err.contains("max_round"), "{err}");
737 }
738
739 #[test]
740 fn an_agent_with_no_command_and_no_preset_is_rejected() {
741 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
742 let err = parse(text).unwrap_err().to_string();
743 assert!(err.contains("no command and no preset"), "{err}");
744 }
745
746 #[test]
747 fn jsonl_without_a_message_path_is_rejected() {
748 let text =
749 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
750 let err = parse(text).unwrap_err().to_string();
751 assert!(err.contains("message_path"), "{err}");
752 }
753
754 #[test]
755 fn first_implementor_must_name_a_configured_agent() {
756 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
757 let err = parse(&text).unwrap_err().to_string();
758 assert!(err.contains("not a configured agent"), "{err}");
759 }
760
761 #[test]
762 fn defaults_are_the_conservative_ones() {
763 let cfg = parse(TWO_AGENTS).unwrap();
764 assert!(
765 !cfg.loop_cfg.auto_merge,
766 "auto_merge must be off by default"
767 );
768 assert!(cfg.loop_cfg.worktrees);
769 assert!(
770 !cfg.loop_cfg.file_nits,
771 "a filed nit is somebody else's triage queue"
772 );
773 assert_eq!(3, cfg.loop_cfg.max_rounds);
774 assert_eq!(Followups::Issues, cfg.loop_cfg.followups);
775 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
776 assert!(cfg.style.terse);
777 }
778
779 #[test]
780 fn effort_schedule_splits_round_one_from_the_rest() {
781 let text =
782 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
783 let cfg = parse(&text).unwrap();
784 let spec = cfg.spec("claude").unwrap();
785 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
786 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
787 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
788 }
789
790 #[test]
791 fn effort_falls_back_to_the_agents_own_setting() {
792 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
793 let cfg = parse(&text).unwrap();
794 let spec = cfg.spec("codex").unwrap();
795 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
796 }
797
798 #[test]
799 fn an_unset_model_and_an_empty_model_normalise_the_same() {
800 let a = AgentSpec {
801 name: "a".into(),
802 command: vec![CommandPart::One("x".into())],
803 model: None,
804 effort: None,
805 output: OutputMode::Text,
806 message_match: BTreeMap::new(),
807 message_path: None,
808 search_paths: vec![],
809 system_via: SystemVia::Prompt,
810 timeout: 60,
811 models: vec![],
812 efforts: vec![],
813 options_note: None,
814 };
815 let b = AgentSpec {
816 model: Some(" ".into()),
817 ..a.clone()
818 };
819 assert_eq!(a.model_key(), b.model_key());
820 }
821
822 #[test]
823 fn max_rounds_zero_is_rejected() {
824 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
825 assert!(parse(&text).is_err());
826 }
827
828 #[test]
829 fn an_inline_command_needs_no_preset() {
830 let text = r#"
831[agents.custom]
832command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
833output = "text"
834
835[agents.other]
836command = ["othertool", "{prompt}"]
837"#;
838 let cfg = parse(text).unwrap();
839 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
840 }
841
842 #[test]
843 fn style_budgets_are_configurable() {
844 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
845 let cfg = parse(&text).unwrap();
846 assert!(!cfg.style.terse);
847 assert_eq!(40, cfg.style.max_detail_chars);
848 }
849}