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 ("cursor", include_str!("../presets/cursor.toml")),
27 ("gemini", include_str!("../presets/gemini.toml")),
28];
29
30#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum OutputMode {
56 Text,
58 Json,
60 Jsonl,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum SystemVia {
68 Prompt,
70 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#[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)]
104 pub effort_schedule: EffortSchedule,
105 #[serde(default = "default_output")]
106 pub output: OutputMode,
107 #[serde(default)]
109 pub message_match: BTreeMap<String, String>,
110 #[serde(default)]
112 pub message_path: Option<String>,
113 #[serde(default)]
115 pub search_paths: Vec<String>,
116 #[serde(default = "default_system_via")]
117 pub system_via: SystemVia,
118 #[serde(default = "default_timeout")]
119 pub timeout: u64,
120 #[serde(skip)]
131 pub fallback: Option<Box<AgentSpec>>,
132
133 #[serde(default)]
141 pub models: Vec<String>,
142 #[serde(default)]
144 pub efforts: Vec<String>,
145 #[serde(default)]
147 pub options_note: Option<String>,
148}
149
150impl AgentSpec {
151 pub fn model_key(&self) -> String {
154 self.model.as_deref().unwrap_or("").trim().to_string()
155 }
156
157 pub fn describe(&self) -> String {
158 format!(
159 "{}/{}",
160 self.model.as_deref().unwrap_or("default model"),
161 self.effort.as_deref().unwrap_or("default effort")
162 )
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "lowercase")]
175pub enum Followups {
176 Issues,
177 Local,
178 None,
179}
180
181impl std::fmt::Display for Followups {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.write_str(match self {
184 Followups::Issues => "issues",
185 Followups::Local => "local",
186 Followups::None => "none",
187 })
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum Drafts {
202 Never,
204 UntilApproved,
206 Always,
208}
209
210impl std::fmt::Display for Drafts {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 f.write_str(match self {
213 Drafts::Never => "never",
214 Drafts::UntilApproved => "until_approved",
215 Drafts::Always => "always",
216 })
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "lowercase")]
228pub enum PrComments {
229 Outcome,
231 Rounds,
234 None,
236}
237
238impl std::fmt::Display for PrComments {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 f.write_str(match self {
241 PrComments::Outcome => "outcome",
242 PrComments::Rounds => "rounds",
243 PrComments::None => "none",
244 })
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "lowercase")]
253pub enum StateStore {
254 Local,
255 Pr,
256 Both,
257}
258
259impl StateStore {
260 pub fn writes_local(self) -> bool {
261 matches!(self, StateStore::Local | StateStore::Both)
262 }
263 pub fn writes_pr(self) -> bool {
264 matches!(self, StateStore::Pr | StateStore::Both)
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "lowercase")]
277pub enum Trust {
278 Write,
280 Anyone,
282}
283
284impl std::fmt::Display for Trust {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.write_str(match self {
287 Trust::Write => "write",
288 Trust::Anyone => "anyone",
289 })
290 }
291}
292
293impl Trust {
294 pub fn may_act_on(self, association: &str) -> bool {
297 match self {
298 Trust::Anyone => true,
299 Trust::Write => matches!(
300 association.trim().to_uppercase().as_str(),
301 "OWNER" | "MEMBER" | "COLLABORATOR"
302 ),
303 }
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum Call {
317 Triage,
319 Implement,
321 Review(u32),
323 Respond(u32),
325 Close,
327 Screen,
329 Checkin(u32),
331 Split(u32),
333}
334
335impl Call {
336 pub fn key(self) -> &'static str {
338 match self {
339 Call::Triage => "triage",
340 Call::Implement => "implement",
341 Call::Review(round) if round <= 1 => "review_1",
342 Call::Review(_) => "review_rest",
343 Call::Respond(_) => "respond",
344 Call::Close => "close",
345 Call::Screen => "screen",
346 Call::Checkin(_) => "checkin",
347 Call::Split(_) => "split",
348 }
349 }
350
351 fn legacy_round(self) -> u32 {
354 match self {
355 Call::Triage | Call::Implement | Call::Screen => 1,
356 Call::Review(round) | Call::Respond(round) => round,
357 Call::Checkin(round) | Call::Split(round) => round,
358 Call::Close => 2,
359 }
360 }
361
362 pub fn every() -> [Call; 9] {
364 [
365 Call::Triage,
366 Call::Implement,
367 Call::Review(1),
368 Call::Review(2),
369 Call::Respond(1),
370 Call::Close,
371 Call::Screen,
372 Call::Checkin(1),
373 Call::Split(1),
374 ]
375 }
376}
377
378#[derive(Debug, Clone, Default, Serialize, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct EffortSchedule {
381 pub round_1: Option<String>,
386 pub rest: Option<String>,
389 pub triage: Option<String>,
392 pub implement: Option<String>,
393 pub review_1: Option<String>,
394 pub review_rest: Option<String>,
395 pub respond: Option<String>,
396 pub close: Option<String>,
397 pub screen: Option<String>,
398 pub checkin: Option<String>,
399 pub split: Option<String>,
400}
401
402impl EffortSchedule {
403 pub fn get(&self, call: Call) -> Option<String> {
405 let value = match call.key() {
406 "triage" => &self.triage,
407 "implement" => &self.implement,
408 "review_1" => &self.review_1,
409 "review_rest" => &self.review_rest,
410 "respond" => &self.respond,
411 "close" => &self.close,
412 "screen" => &self.screen,
413 "checkin" => &self.checkin,
414 _ => &self.split,
415 };
416 value.clone().filter(|s| !s.trim().is_empty())
417 }
418
419 fn legacy(&self, call: Call) -> Option<String> {
421 let value = if call.legacy_round() <= 1 {
422 &self.round_1
423 } else {
424 &self.rest
425 };
426 value.clone().filter(|s| !s.trim().is_empty())
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(untagged)]
438pub enum CheckCommand {
439 Line(String),
440 Argv(Vec<String>),
441}
442
443impl Default for CheckCommand {
444 fn default() -> Self {
447 CheckCommand::Argv(Vec::new())
448 }
449}
450
451impl CheckCommand {
452 pub fn argv(&self) -> Vec<String> {
453 match self {
454 CheckCommand::Line(line) => line.split_whitespace().map(str::to_string).collect(),
455 CheckCommand::Argv(argv) => argv
456 .iter()
457 .map(|a| a.trim().to_string())
458 .filter(|a| !a.is_empty())
459 .collect(),
460 }
461 }
462
463 pub fn is_set(&self) -> bool {
464 !self.argv().is_empty()
465 }
466
467 pub fn describe(&self) -> String {
469 self.argv().join(" ")
470 }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
477#[serde(default, deny_unknown_fields)]
478pub struct LoopCfg {
479 pub max_rounds: u32,
482 pub auto_merge: bool,
483 pub first_implementor: Option<String>,
484 pub base_branch: String,
485 pub worktrees: bool,
486 pub keep_worktrees: bool,
487 pub state_store: StateStore,
488 pub branch_prefix: String,
489 pub followups: Followups,
490 pub file_non_blocking: bool,
498 pub max_followups: usize,
501 pub max_split_parts: usize,
509 pub file_nits: bool,
513 pub close_skipped: bool,
516 pub parallel_triage: bool,
519 pub min_number: i64,
527 pub absorb_new_issues: u32,
534 pub decompose_trackers: bool,
546 pub max_tracker_children: usize,
549 pub drafts: Drafts,
551 pub instructions: String,
563 pub max_issue_chars: usize,
571 pub max_triage_chars: usize,
579 pub checkin_trust: Trust,
581 pub checkin_resolve: bool,
586 pub max_checkin_comments: usize,
591 pub check: CheckCommand,
601 pub effort_schedule: EffortSchedule,
602}
603
604impl Default for LoopCfg {
605 fn default() -> Self {
606 Self {
607 max_rounds: 3,
608 auto_merge: false,
609 first_implementor: None,
610 base_branch: "main".into(),
611 worktrees: true,
612 keep_worktrees: false,
613 state_store: StateStore::Local,
614 branch_prefix: String::new(),
615 followups: Followups::Local,
616 file_non_blocking: false,
617 max_followups: 5,
618 max_split_parts: 4,
619 file_nits: false,
620 close_skipped: true,
621 parallel_triage: true,
622 min_number: 0,
623 absorb_new_issues: 0,
624 decompose_trackers: false,
625 max_tracker_children: 5,
626 drafts: Drafts::Never,
627 instructions: String::new(),
628 max_issue_chars: 60_000,
629 max_triage_chars: 200_000,
630 checkin_trust: Trust::Write,
631 checkin_resolve: true,
632 max_checkin_comments: 20,
633 check: CheckCommand::default(),
634 effort_schedule: EffortSchedule::default(),
635 }
636 }
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize)]
640#[serde(default, deny_unknown_fields)]
641pub struct StyleCfg {
642 pub ban_em_dash: bool,
643 pub ban_ai_attribution: bool,
644 pub terse: bool,
645 pub max_detail_chars: usize,
646 pub max_summary_chars: usize,
647 pub max_body_chars: usize,
648 pub max_issue_body_chars: usize,
652 pub max_title_chars: usize,
653 pub pr_comments: PrComments,
654}
655
656impl Default for StyleCfg {
657 fn default() -> Self {
667 let style = Style::default();
668 Self {
669 ban_em_dash: style.ban_em_dash,
670 ban_ai_attribution: style.ban_ai_attribution,
671 terse: style.terse,
672 max_detail_chars: style.max_detail_chars,
673 max_summary_chars: style.max_summary_chars,
674 max_body_chars: style.max_body_chars,
675 max_issue_body_chars: style.max_issue_body_chars,
676 max_title_chars: style.max_title_chars,
677 pr_comments: style.pr_comments,
678 }
679 }
680}
681
682impl StyleCfg {
683 pub fn to_style(&self) -> Style {
684 Style {
685 ban_em_dash: self.ban_em_dash,
686 ban_ai_attribution: self.ban_ai_attribution,
687 terse: self.terse,
688 max_detail_chars: self.max_detail_chars,
689 max_summary_chars: self.max_summary_chars,
690 max_body_chars: self.max_body_chars,
691 max_issue_body_chars: self.max_issue_body_chars,
692 max_title_chars: self.max_title_chars,
693 pr_comments: self.pr_comments,
694 }
695 }
696}
697
698#[derive(Debug, Clone)]
703pub struct Config {
704 pub agents: Vec<AgentSpec>,
706 pub loop_cfg: LoopCfg,
707 pub style: Style,
708 pub first_implementor: String,
711 pub alternate_first: bool,
713 pub source: Option<PathBuf>,
715}
716
717impl Config {
718 pub fn agent_names(&self) -> Vec<String> {
719 self.agents.iter().map(|a| a.name.clone()).collect()
720 }
721
722 pub fn has_agent(&self, name: &str) -> bool {
723 self.agents.iter().any(|a| a.name == name)
724 }
725
726 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
727 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
728 spar_err!(
729 "no agent named '{name}' ({})",
730 self.agent_names().join(", ")
731 )
732 })
733 }
734
735 pub fn other(&self, name: &str) -> String {
738 let names = self.agent_names();
739 if names.first().map(String::as_str) == Some(name) {
740 names.get(1).cloned().unwrap_or_else(|| name.to_string())
741 } else {
742 names.first().cloned().unwrap_or_else(|| name.to_string())
743 }
744 }
745
746 pub fn effort_for(&self, spec: &AgentSpec, call: Call) -> Option<String> {
754 spec.effort_schedule
755 .get(call)
756 .or_else(|| self.loop_cfg.effort_schedule.get(call))
757 .or_else(|| self.loop_cfg.effort_schedule.legacy(call))
758 .or_else(|| spec.effort.clone())
759 }
760
761 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
764 self.effort_for(spec, Call::Review(round))
765 }
766
767 pub fn first_implementor_for(&self, nth: usize) -> String {
772 if !self.alternate_first || nth % 2 == 0 {
773 return self.first_implementor.clone();
774 }
775 self.other(&self.first_implementor)
776 }
777
778 pub fn first_implementor_policy(&self) -> String {
780 if self.alternate_first {
781 format!("alternating, {} first", self.first_implementor)
782 } else {
783 self.first_implementor.clone()
784 }
785 }
786
787 pub fn base_branch(&self) -> &str {
788 &self.loop_cfg.base_branch
789 }
790}
791
792#[derive(Debug, Deserialize)]
793#[serde(deny_unknown_fields)]
794struct RawConfig {
795 #[serde(default)]
796 agents: toml::Table,
797 #[serde(default)]
798 #[serde(rename = "loop")]
799 loop_cfg: Option<LoopCfg>,
800 #[serde(default)]
801 style: Option<StyleCfg>,
802}
803
804pub fn preset_dirs() -> Vec<PathBuf> {
817 let mut dirs = Vec::new();
818 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
819 dirs.push(PathBuf::from(custom));
820 }
821 dirs.push(PathBuf::from(".spar").join("presets"));
822 if let Some(home) = home_dir() {
823 dirs.push(home.join(".config").join("spar").join("presets"));
824 }
825 dirs
826}
827
828pub fn available_presets() -> Vec<String> {
830 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
831 for dir in preset_dirs() {
832 if let Ok(entries) = std::fs::read_dir(&dir) {
833 for entry in entries.flatten() {
834 let path = entry.path();
835 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
836 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
837 names.push(stem.to_string());
838 }
839 }
840 }
841 }
842 }
843 names.sort();
844 names.dedup();
845 names
846}
847
848fn parse_document(text: &str, what: &str) -> Result<Value> {
853 let table: toml::Table =
854 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
855 Ok(Value::Table(table))
856}
857
858pub fn load_preset(name: &str) -> Result<Value> {
861 for dir in preset_dirs() {
862 let path = dir.join(format!("{name}.toml"));
863 if path.is_file() {
864 let text = std::fs::read_to_string(&path)
865 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
866 return parse_document(&text, &format!("preset {}", path.display()));
867 }
868 }
869 for (builtin, text) in BUILTIN_PRESETS {
870 if *builtin == name {
871 return parse_document(text, &format!("built in preset {name}"));
872 }
873 }
874 Err(spar_err!(
875 "unknown preset '{name}'. Available: {}",
876 available_presets().join(", ")
877 ))
878}
879
880fn merge(base: &Value, over: &Value) -> Value {
883 match (base, over) {
884 (Value::Table(b), Value::Table(o)) => {
885 let mut out = b.clone();
886 for (key, value) in o {
887 let merged = match out.get(key) {
888 Some(existing) => merge(existing, value),
889 None => value.clone(),
890 };
891 out.insert(key.clone(), merged);
892 }
893 Value::Table(out)
894 }
895 _ => over.clone(),
896 }
897}
898
899fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
900 let table = raw
901 .as_table()
902 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
903
904 let merged = match table.get("preset").and_then(Value::as_str) {
905 Some(preset) => merge(&load_preset(preset)?, raw),
906 None => raw.clone(),
907 };
908
909 let mut merged_table = merged
910 .as_table()
911 .cloned()
912 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
913 merged_table.remove("preset");
914 let fallback_raw = merged_table.remove("fallback");
917
918 if !merged_table.contains_key("command") {
919 bail!(
920 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
921 available_presets().join(", ")
922 );
923 }
924
925 let mut spec: AgentSpec = Value::Table(merged_table)
926 .try_into()
927 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
928 spec.name = name.to_string();
929
930 if spec.command.is_empty() {
931 bail!("agent '{name}' has an empty command");
932 }
933 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
934 bail!("agent '{name}': the first command element must be the program name, not a group");
935 }
936 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
937 bail!(
938 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
939 );
940 }
941
942 if let Some(raw) = fallback_raw {
943 if !raw.is_table() {
944 bail!(
945 "agent '{name}': fallback is a whole agent, so write it as a table:\n [agents.{name}.fallback]\n preset = \"cursor\""
946 );
947 }
948 let backup = build_spec(&format!("{name}-fallback"), &raw)?;
951 if backup.fallback.is_some() {
952 bail!(
953 "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
954 another full timeout on a call that has already failed once."
955 );
956 }
957 spec.fallback = Some(Box::new(backup));
958 }
959
960 Ok(spec)
961}
962
963#[derive(Debug, Clone)]
969pub struct OptionInfo {
970 pub section: &'static str,
971 pub key: String,
972 pub default: String,
973}
974
975pub fn known_options() -> Vec<OptionInfo> {
981 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
982 toml::to_string(value)
983 .unwrap_or_default()
984 .lines()
985 .filter_map(|line| line.split_once(" = "))
986 .map(|(key, default)| OptionInfo {
987 section,
988 key: key.trim().to_string(),
989 default: default.trim().to_string(),
990 })
991 .collect()
992 }
993 let mut out = lines("loop", &LoopCfg::default());
994 out.extend(lines("style", &StyleCfg::default()));
995 out.extend(lines(
996 "loop.effort_schedule",
997 &EffortSchedule {
998 round_1: Some("high".into()),
999 rest: Some("low".into()),
1000 triage: Some("low".into()),
1001 implement: Some("high".into()),
1002 review_1: Some("high".into()),
1003 review_rest: Some("low".into()),
1004 respond: Some("high".into()),
1005 close: Some("low".into()),
1006 screen: Some("low".into()),
1007 checkin: Some("high".into()),
1008 split: Some("high".into()),
1009 },
1010 ));
1011 out
1012}
1013
1014pub fn mentions(config_text: &str, key: &str) -> bool {
1016 config_text.lines().any(|line| {
1017 let bare = line.trim_start().trim_start_matches('#').trim_start();
1018 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1019 })
1020}
1021
1022pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
1025 known_options()
1026 .into_iter()
1027 .filter(|o| !mentions(config_text, &o.key))
1028 .collect()
1029}
1030
1031pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
1032
1033pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1036 if let Some(path) = explicit {
1037 if !path.is_file() {
1038 bail!("config not found: {}", path.display());
1039 }
1040 return Ok(Some(path.to_path_buf()));
1041 }
1042 for name in CONFIG_NAMES {
1043 let path = PathBuf::from(name);
1044 if path.is_file() {
1045 return Ok(Some(path));
1046 }
1047 }
1048 if let Some(home) = home_dir() {
1049 let path = home.join(".config").join("spar").join("spar.toml");
1050 if path.is_file() {
1051 return Ok(Some(path));
1052 }
1053 }
1054 Ok(None)
1055}
1056
1057pub fn load(explicit: Option<&Path>) -> Result<Config> {
1058 let Some(path) = find_config(explicit)? else {
1059 bail!(
1060 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
1061 );
1062 };
1063 let text = std::fs::read_to_string(&path)
1064 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1065 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
1066 cfg.source = Some(path);
1067 Ok(cfg)
1068}
1069
1070pub fn parse(text: &str) -> Result<Config> {
1071 let raw: RawConfig = toml::from_str(text)?;
1072
1073 if raw.agents.len() != 2 {
1074 bail!(
1075 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
1076 raw.agents.len()
1077 );
1078 }
1079
1080 let mut agents = Vec::new();
1081 for (name, value) in raw.agents.iter() {
1082 agents.push(build_spec(name, value)?);
1083 }
1084
1085 let loop_cfg = raw.loop_cfg.unwrap_or_default();
1086 let style = raw.style.unwrap_or_default().to_style();
1087
1088 if loop_cfg.max_rounds == 0 {
1089 bail!("max_rounds must be at least 1");
1090 }
1091 if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
1096 bail!(
1097 "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
1098 ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
1099 to have it promoted when the review converges, or turn auto_merge off."
1100 );
1101 }
1102
1103 let alternate = loop_cfg
1110 .first_implementor
1111 .as_deref()
1112 .is_some_and(|name| name.trim().eq_ignore_ascii_case("alternate"));
1113 let first = match &loop_cfg.first_implementor {
1114 Some(name) if !name.trim().is_empty() && !alternate => name.trim().to_string(),
1115 _ => agents[0].name.clone(),
1116 };
1117 if !agents.iter().any(|a| a.name == first) {
1118 bail!(
1119 "first_implementor '{first}' is not a configured agent ({})",
1120 agents
1121 .iter()
1122 .map(|a| a.name.as_str())
1123 .collect::<Vec<_>>()
1124 .join(", ")
1125 );
1126 }
1127
1128 Ok(Config {
1129 agents,
1130 loop_cfg,
1131 style,
1132 first_implementor: first,
1133 alternate_first: alternate,
1134 source: None,
1135 })
1136}
1137
1138pub fn resolve_search_path(raw: &str) -> PathBuf {
1140 expand_tilde(raw)
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::*;
1146
1147 const TWO_AGENTS: &str = r#"
1148[agents.claude]
1149preset = "claude"
1150model = "fable"
1151
1152[agents.codex]
1153preset = "codex"
1154model = "gpt-5.6-sol"
1155"#;
1156
1157 #[test]
1160 fn a_fallback_is_a_whole_agent_with_its_own_preset() {
1161 let text = format!(
1162 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
1163 );
1164 let cfg = parse(&text).expect("parses");
1165 assert_eq!(2, cfg.agents.len());
1167 let codex = cfg.spec("codex").expect("codex");
1168 let backup = codex.fallback.as_ref().expect("fallback");
1169 assert_eq!("codex-fallback", backup.name);
1170 assert_eq!(Some("kimi-k3"), backup.model.as_deref());
1171 assert_eq!(
1172 Some(&CommandPart::One("cursor-agent".into())),
1173 backup.command.first()
1174 );
1175 }
1176
1177 #[test]
1178 fn the_agent_without_a_fallback_does_not_grow_one() {
1179 let cfg = parse(TWO_AGENTS).expect("parses");
1180 assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
1181 }
1182
1183 #[test]
1184 fn a_fallback_may_not_have_one_of_its_own() {
1185 let text = format!(
1186 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
1187 [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
1188 );
1189 let err = parse(&text).expect_err("rejected");
1190 assert!(err.message().contains("may not have a fallback"), "{err}");
1191 }
1192
1193 #[test]
1194 fn a_fallback_written_as_a_string_says_what_it_should_be() {
1195 let text = "[agents.claude]\npreset = \"claude\"\n\n\
1196 [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
1197 let err = parse(text).expect_err("rejected");
1198 assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
1199 }
1200
1201 #[test]
1206 fn a_partial_block_keeps_the_defaults_it_did_not_name() {
1207 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
1208 let cfg = parse(&text).expect("parses");
1209
1210 assert_eq!(9, cfg.loop_cfg.max_rounds);
1211 assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
1212 assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
1213
1214 assert!(!cfg.style.terse);
1215 assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
1216 assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
1217 }
1218
1219 #[test]
1223 fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
1224 assert_eq!(Style::default(), StyleCfg::default().to_style());
1225 }
1226
1227 #[test]
1230 fn pull_requests_are_not_drafts_unless_asked_for() {
1231 assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1232 }
1233
1234 #[test]
1235 fn each_draft_setting_parses() {
1236 for (text, want) in [
1237 ("never", Drafts::Never),
1238 ("until_approved", Drafts::UntilApproved),
1239 ("always", Drafts::Always),
1240 ] {
1241 let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1242 .unwrap_or_else(|e| panic!("{text}: {e}"));
1243 assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1244 }
1245 }
1246
1247 #[test]
1251 fn auto_merge_and_a_permanent_draft_are_refused_together() {
1252 let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1253 let err = parse(&text).expect_err("refused");
1254 assert!(err.message().contains("auto_merge"), "{err}");
1255 assert!(
1256 err.message().contains("until_approved"),
1257 "says the way out: {err}"
1258 );
1259 }
1260
1261 #[test]
1264 fn auto_merge_is_fine_with_a_draft_that_clears() {
1265 let text =
1266 format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1267 assert!(parse(&text).is_ok());
1268 }
1269
1270 #[test]
1271 fn every_builtin_preset_parses() {
1272 for (name, _) in BUILTIN_PRESETS {
1273 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1274 assert!(value.get("command").is_some(), "{name} has no command");
1275 }
1276 }
1277
1278 #[test]
1279 fn every_builtin_preset_builds_a_spec() {
1280 for (name, _) in BUILTIN_PRESETS {
1281 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1282 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1283 }
1284 }
1285
1286 #[test]
1290 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1291 let spec = build_spec(
1292 "claude",
1293 &parse_document("preset = \"claude\"", "test").unwrap(),
1294 )
1295 .unwrap();
1296 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1297 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1298 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1299 }
1300
1301 #[test]
1302 fn codex_preset_declares_where_its_answer_lives() {
1303 let spec = build_spec(
1304 "codex",
1305 &parse_document("preset = \"codex\"", "test").unwrap(),
1306 )
1307 .unwrap();
1308 assert_eq!(OutputMode::Jsonl, spec.output);
1309 assert_eq!(Some("item.text"), spec.message_path.as_deref());
1310 assert!(!spec.message_match.is_empty());
1311 }
1312
1313 #[test]
1314 fn agent_order_follows_declaration_order() {
1315 let cfg = parse(TWO_AGENTS).unwrap();
1316 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1317 assert_eq!("claude", cfg.first_implementor);
1318 }
1319
1320 #[test]
1321 fn other_alternates() {
1322 let cfg = parse(TWO_AGENTS).unwrap();
1323 assert_eq!("codex", cfg.other("claude"));
1324 assert_eq!("claude", cfg.other("codex"));
1325 }
1326
1327 #[test]
1328 fn a_config_block_overrides_one_preset_field() {
1329 let cfg = parse(TWO_AGENTS).unwrap();
1330 let claude = cfg.spec("claude").unwrap();
1331 assert_eq!(Some("fable"), claude.model.as_deref());
1332 assert!(claude.command.len() > 1, "the preset command survived");
1333 }
1334
1335 #[test]
1336 fn exactly_two_agents_are_required() {
1337 let one = "[agents.claude]\npreset = \"claude\"\n";
1338 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1339 }
1340
1341 #[test]
1342 fn an_unknown_agent_option_is_named() {
1343 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1344 let err = parse(text).unwrap_err().to_string();
1345 assert!(err.contains("widget"), "{err}");
1346 }
1347
1348 #[test]
1349 fn an_unknown_loop_option_is_named() {
1350 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1351 let err = parse(&text).unwrap_err().to_string();
1352 assert!(err.contains("max_round"), "{err}");
1353 }
1354
1355 #[test]
1356 fn an_agent_with_no_command_and_no_preset_is_rejected() {
1357 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1358 let err = parse(text).unwrap_err().to_string();
1359 assert!(err.contains("no command and no preset"), "{err}");
1360 }
1361
1362 #[test]
1363 fn jsonl_without_a_message_path_is_rejected() {
1364 let text =
1365 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1366 let err = parse(text).unwrap_err().to_string();
1367 assert!(err.contains("message_path"), "{err}");
1368 }
1369
1370 #[test]
1375 fn a_check_is_read_as_an_argv_either_way_it_is_written() {
1376 let line = format!("{TWO_AGENTS}\n[loop]\ncheck = \"cargo test --all\"\n");
1377 let cfg = parse(&line).unwrap();
1378 assert!(cfg.loop_cfg.check.is_set());
1379 assert_eq!(vec!["cargo", "test", "--all"], cfg.loop_cfg.check.argv());
1380 assert_eq!("cargo test --all", cfg.loop_cfg.check.describe());
1381
1382 let listed =
1383 format!("{TWO_AGENTS}\n[loop]\ncheck = [\"npm\", \"run\", \"test -- --ci\"]\n");
1384 let cfg = parse(&listed).unwrap();
1385 assert_eq!(
1386 vec!["npm", "run", "test -- --ci"],
1387 cfg.loop_cfg.check.argv(),
1388 "a list is taken as it is, spaces and all"
1389 );
1390
1391 let bare = parse(TWO_AGENTS).unwrap();
1393 assert!(!bare.loop_cfg.check.is_set());
1394 assert!(bare.loop_cfg.check.argv().is_empty());
1395 }
1396
1397 #[test]
1398 fn alternating_swaps_the_author_from_issue_to_issue() {
1399 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"alternate\"\n");
1400 let cfg = parse(&text).unwrap();
1401
1402 assert!(cfg.alternate_first);
1403 assert_eq!(
1404 "claude", cfg.first_implementor,
1405 "declaration order still leads"
1406 );
1407 assert_eq!("claude", cfg.first_implementor_for(0));
1408 assert_eq!("codex", cfg.first_implementor_for(1));
1409 assert_eq!("claude", cfg.first_implementor_for(2));
1410 assert!(cfg.first_implementor_policy().contains("alternating"));
1411 }
1412
1413 #[test]
1415 fn a_named_first_implementor_never_swaps() {
1416 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"codex\"\n");
1417 let cfg = parse(&text).unwrap();
1418 assert!(!cfg.alternate_first);
1419 for nth in 0..4 {
1420 assert_eq!("codex", cfg.first_implementor_for(nth));
1421 }
1422 assert_eq!("codex", cfg.first_implementor_policy());
1423 }
1424
1425 #[test]
1426 fn first_implementor_must_name_a_configured_agent() {
1427 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1428 let err = parse(&text).unwrap_err().to_string();
1429 assert!(err.contains("not a configured agent"), "{err}");
1430 }
1431
1432 #[test]
1433 fn defaults_are_the_conservative_ones() {
1434 let cfg = parse(TWO_AGENTS).unwrap();
1435 assert!(
1436 !cfg.loop_cfg.auto_merge,
1437 "auto_merge must be off by default"
1438 );
1439 assert!(cfg.loop_cfg.worktrees);
1440 assert!(
1441 !cfg.loop_cfg.file_nits,
1442 "a filed nit is somebody else's triage queue"
1443 );
1444 assert_eq!(3, cfg.loop_cfg.max_rounds);
1445 assert_eq!(
1446 Followups::Local,
1447 cfg.loop_cfg.followups,
1448 "the tracker is somebody's queue; the default must not write to it"
1449 );
1450 assert!(
1451 !cfg.loop_cfg.file_non_blocking,
1452 "a suggestion is not a tracker item"
1453 );
1454 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1455 assert!(cfg.style.terse);
1456 }
1457
1458 #[test]
1459 fn effort_schedule_splits_round_one_from_the_rest() {
1460 let text =
1461 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1462 let cfg = parse(&text).unwrap();
1463 let spec = cfg.spec("claude").unwrap();
1464 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1465 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1466 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1467 }
1468
1469 #[test]
1476 fn an_agent_can_carry_its_own_schedule_in_its_own_words() {
1477 let text = format!(
1478 "{TWO_AGENTS}\n[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
1479 [loop.effort_schedule]\nreview_1 = \"ultra\"\n"
1480 );
1481 let cfg = parse(&text).unwrap();
1482 assert_eq!(
1483 Some("xhigh".into()),
1484 cfg.effort_for(cfg.spec("claude").unwrap(), Call::Review(1)),
1485 "the agent's own schedule has to win"
1486 );
1487 assert_eq!(
1488 Some("ultra".into()),
1489 cfg.effort_for(cfg.spec("codex").unwrap(), Call::Review(1))
1490 );
1491 }
1492
1493 #[test]
1497 fn each_kind_of_call_reads_its_own_key_and_falls_back_to_the_old_pair() {
1498 let text = format!(
1499 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"low\"\n\
1500 triage = \"minimal\"\n"
1501 );
1502 let cfg = parse(&text).unwrap();
1503 let spec = cfg.spec("claude").unwrap();
1504
1505 assert_eq!(Some("minimal".into()), cfg.effort_for(spec, Call::Triage));
1506 for call in [Call::Implement, Call::Review(1), Call::Screen] {
1508 assert_eq!(Some("ultra".into()), cfg.effort_for(spec, call), "{call:?}");
1509 }
1510 for call in [Call::Review(2), Call::Close, Call::Respond(3)] {
1511 assert_eq!(Some("low".into()), cfg.effort_for(spec, call), "{call:?}");
1512 }
1513 }
1514
1515 #[test]
1516 fn effort_falls_back_to_the_agents_own_setting() {
1517 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1518 let cfg = parse(&text).unwrap();
1519 let spec = cfg.spec("codex").unwrap();
1520 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1521 }
1522
1523 #[test]
1524 fn an_unset_model_and_an_empty_model_normalise_the_same() {
1525 let a = AgentSpec {
1526 name: "a".into(),
1527 command: vec![CommandPart::One("x".into())],
1528 model: None,
1529 effort: None,
1530 effort_schedule: EffortSchedule::default(),
1531 output: OutputMode::Text,
1532 message_match: BTreeMap::new(),
1533 message_path: None,
1534 search_paths: vec![],
1535 system_via: SystemVia::Prompt,
1536 timeout: 60,
1537 fallback: None,
1538 models: vec![],
1539 efforts: vec![],
1540 options_note: None,
1541 };
1542 let b = AgentSpec {
1543 model: Some(" ".into()),
1544 ..a.clone()
1545 };
1546 assert_eq!(a.model_key(), b.model_key());
1547 }
1548
1549 #[test]
1550 fn max_rounds_zero_is_rejected() {
1551 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1552 assert!(parse(&text).is_err());
1553 }
1554
1555 #[test]
1556 fn an_inline_command_needs_no_preset() {
1557 let text = r#"
1558[agents.custom]
1559command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1560output = "text"
1561
1562[agents.other]
1563command = ["othertool", "{prompt}"]
1564"#;
1565 let cfg = parse(text).unwrap();
1566 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1567 }
1568
1569 #[test]
1570 fn style_budgets_are_configurable() {
1571 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1572 let cfg = parse(&text).unwrap();
1573 assert!(!cfg.style.terse);
1574 assert_eq!(40, cfg.style.max_detail_chars);
1575 }
1576}