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)]
277#[serde(rename_all = "lowercase")]
278pub enum Trust {
279 Write,
281 Anyone,
283}
284
285impl std::fmt::Display for Trust {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 f.write_str(match self {
288 Trust::Write => "write",
289 Trust::Anyone => "anyone",
290 })
291 }
292}
293
294impl Trust {
295 pub fn may_act_on(self, association: &str) -> bool {
298 match self {
299 Trust::Anyone => true,
300 Trust::Write => matches!(
301 association.trim().to_uppercase().as_str(),
302 "OWNER" | "MEMBER" | "COLLABORATOR"
303 ),
304 }
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum Call {
318 Triage,
320 Implement,
322 Review(u32),
324 Respond(u32),
326 Close,
328 Screen,
330 Checkin(u32),
332 Split(u32),
334 Brainstorm(u32),
337}
338
339impl Call {
340 pub fn key(self) -> &'static str {
342 match self {
343 Call::Triage => "triage",
344 Call::Implement => "implement",
345 Call::Review(round) if round <= 1 => "review_1",
346 Call::Review(_) => "review_rest",
347 Call::Respond(_) => "respond",
348 Call::Close => "close",
349 Call::Screen => "screen",
350 Call::Checkin(_) => "checkin",
351 Call::Split(_) => "split",
352 Call::Brainstorm(_) => "brainstorm",
353 }
354 }
355
356 fn legacy_round(self) -> u32 {
359 match self {
360 Call::Triage | Call::Implement | Call::Screen => 1,
361 Call::Review(round) | Call::Respond(round) => round,
362 Call::Checkin(round) | Call::Split(round) | Call::Brainstorm(round) => round,
363 Call::Close => 2,
364 }
365 }
366
367 pub fn every() -> [Call; 10] {
369 [
370 Call::Triage,
371 Call::Implement,
372 Call::Review(1),
373 Call::Review(2),
374 Call::Respond(1),
375 Call::Close,
376 Call::Screen,
377 Call::Checkin(1),
378 Call::Split(1),
379 Call::Brainstorm(1),
380 ]
381 }
382}
383
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385#[serde(deny_unknown_fields)]
386pub struct EffortSchedule {
387 pub round_1: Option<String>,
392 pub rest: Option<String>,
395 pub triage: Option<String>,
398 pub implement: Option<String>,
399 pub review_1: Option<String>,
400 pub review_rest: Option<String>,
401 pub respond: Option<String>,
402 pub close: Option<String>,
403 pub screen: Option<String>,
404 pub checkin: Option<String>,
405 pub split: Option<String>,
406 pub brainstorm: Option<String>,
410}
411
412impl EffortSchedule {
413 pub fn get(&self, call: Call) -> Option<String> {
415 let value = match call.key() {
416 "triage" => &self.triage,
417 "implement" => &self.implement,
418 "review_1" => &self.review_1,
419 "review_rest" => &self.review_rest,
420 "respond" => &self.respond,
421 "close" => &self.close,
422 "screen" => &self.screen,
423 "checkin" => &self.checkin,
424 "brainstorm" => &self.brainstorm,
425 _ => &self.split,
426 };
427 value.clone().filter(|s| !s.trim().is_empty())
428 }
429
430 fn legacy(&self, call: Call) -> Option<String> {
432 let value = if call.legacy_round() <= 1 {
433 &self.round_1
434 } else {
435 &self.rest
436 };
437 value.clone().filter(|s| !s.trim().is_empty())
438 }
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(untagged)]
449pub enum CheckCommand {
450 Line(String),
451 Argv(Vec<String>),
452}
453
454impl Default for CheckCommand {
455 fn default() -> Self {
458 CheckCommand::Argv(Vec::new())
459 }
460}
461
462impl CheckCommand {
463 pub fn argv(&self) -> Vec<String> {
464 match self {
465 CheckCommand::Line(line) => line.split_whitespace().map(str::to_string).collect(),
466 CheckCommand::Argv(argv) => argv
467 .iter()
468 .map(|a| a.trim().to_string())
469 .filter(|a| !a.is_empty())
470 .collect(),
471 }
472 }
473
474 pub fn is_set(&self) -> bool {
475 !self.argv().is_empty()
476 }
477
478 pub fn describe(&self) -> String {
480 self.argv().join(" ")
481 }
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
488#[serde(default, deny_unknown_fields)]
489pub struct LoopCfg {
490 pub max_rounds: u32,
493 pub auto_merge: bool,
494 pub first_implementor: Option<String>,
495 pub base_branch: String,
496 pub worktrees: bool,
497 pub keep_worktrees: bool,
498 pub state_store: StateStore,
499 pub branch_prefix: String,
500 pub followups: Followups,
501 pub file_non_blocking: bool,
509 pub max_followups: usize,
512 pub max_split_parts: usize,
520 pub file_nits: bool,
524 pub close_skipped: bool,
527 pub parallel_triage: bool,
530 pub min_number: i64,
538 pub absorb_new_issues: u32,
545 pub decompose_trackers: bool,
557 pub max_tracker_children: usize,
560 pub drafts: Drafts,
562 pub instructions: String,
574 pub max_issue_chars: usize,
583 pub max_triage_chars: usize,
592 pub checkin_trust: Trust,
599 pub checkin_resolve: bool,
604 pub max_checkin_comments: usize,
609 pub check: CheckCommand,
619 pub effort_schedule: EffortSchedule,
620}
621
622impl Default for LoopCfg {
623 fn default() -> Self {
624 Self {
625 max_rounds: 3,
626 auto_merge: false,
627 first_implementor: None,
628 base_branch: "main".into(),
629 worktrees: true,
630 keep_worktrees: false,
631 state_store: StateStore::Local,
632 branch_prefix: String::new(),
633 followups: Followups::Local,
634 file_non_blocking: false,
635 max_followups: 5,
636 max_split_parts: 4,
637 file_nits: false,
638 close_skipped: true,
639 parallel_triage: true,
640 min_number: 0,
641 absorb_new_issues: 0,
642 decompose_trackers: false,
643 max_tracker_children: 5,
644 drafts: Drafts::Never,
645 instructions: String::new(),
646 max_issue_chars: 60_000,
647 max_triage_chars: 200_000,
648 checkin_trust: Trust::Write,
649 checkin_resolve: true,
650 max_checkin_comments: 20,
651 check: CheckCommand::default(),
652 effort_schedule: EffortSchedule::default(),
653 }
654 }
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
658#[serde(default, deny_unknown_fields)]
659pub struct StyleCfg {
660 pub ban_em_dash: bool,
661 pub ban_ai_attribution: bool,
662 pub terse: bool,
663 pub max_detail_chars: usize,
664 pub max_summary_chars: usize,
665 pub max_body_chars: usize,
666 pub max_issue_body_chars: usize,
670 pub max_title_chars: usize,
671 pub pr_comments: PrComments,
672}
673
674impl Default for StyleCfg {
675 fn default() -> Self {
685 let style = Style::default();
686 Self {
687 ban_em_dash: style.ban_em_dash,
688 ban_ai_attribution: style.ban_ai_attribution,
689 terse: style.terse,
690 max_detail_chars: style.max_detail_chars,
691 max_summary_chars: style.max_summary_chars,
692 max_body_chars: style.max_body_chars,
693 max_issue_body_chars: style.max_issue_body_chars,
694 max_title_chars: style.max_title_chars,
695 pr_comments: style.pr_comments,
696 }
697 }
698}
699
700impl StyleCfg {
701 pub fn to_style(&self) -> Style {
702 Style {
703 ban_em_dash: self.ban_em_dash,
704 ban_ai_attribution: self.ban_ai_attribution,
705 terse: self.terse,
706 max_detail_chars: self.max_detail_chars,
707 max_summary_chars: self.max_summary_chars,
708 max_body_chars: self.max_body_chars,
709 max_issue_body_chars: self.max_issue_body_chars,
710 max_title_chars: self.max_title_chars,
711 pr_comments: self.pr_comments,
712 }
713 }
714}
715
716#[derive(Debug, Clone)]
721pub struct Config {
722 pub agents: Vec<AgentSpec>,
724 pub loop_cfg: LoopCfg,
725 pub style: Style,
726 pub first_implementor: String,
729 pub alternate_first: bool,
731 pub source: Option<PathBuf>,
733}
734
735impl Config {
736 pub fn agent_names(&self) -> Vec<String> {
737 self.agents.iter().map(|a| a.name.clone()).collect()
738 }
739
740 pub fn has_agent(&self, name: &str) -> bool {
741 self.agents.iter().any(|a| a.name == name)
742 }
743
744 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
745 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
746 spar_err!(
747 "no agent named '{name}' ({})",
748 self.agent_names().join(", ")
749 )
750 })
751 }
752
753 pub fn other(&self, name: &str) -> String {
756 let names = self.agent_names();
757 if names.first().map(String::as_str) == Some(name) {
758 names.get(1).cloned().unwrap_or_else(|| name.to_string())
759 } else {
760 names.first().cloned().unwrap_or_else(|| name.to_string())
761 }
762 }
763
764 pub fn effort_for(&self, spec: &AgentSpec, call: Call) -> Option<String> {
772 spec.effort_schedule
773 .get(call)
774 .or_else(|| self.loop_cfg.effort_schedule.get(call))
775 .or_else(|| self.loop_cfg.effort_schedule.legacy(call))
776 .or_else(|| spec.effort.clone())
777 }
778
779 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
782 self.effort_for(spec, Call::Review(round))
783 }
784
785 pub fn first_implementor_for(&self, nth: usize) -> String {
790 if !self.alternate_first || nth % 2 == 0 {
791 return self.first_implementor.clone();
792 }
793 self.other(&self.first_implementor)
794 }
795
796 pub fn first_implementor_policy(&self) -> String {
798 if self.alternate_first {
799 format!("alternating, {} first", self.first_implementor)
800 } else {
801 self.first_implementor.clone()
802 }
803 }
804
805 pub fn base_branch(&self) -> &str {
806 &self.loop_cfg.base_branch
807 }
808}
809
810#[derive(Debug, Deserialize)]
811#[serde(deny_unknown_fields)]
812struct RawConfig {
813 #[serde(default)]
814 agents: toml::Table,
815 #[serde(default)]
816 #[serde(rename = "loop")]
817 loop_cfg: Option<LoopCfg>,
818 #[serde(default)]
819 style: Option<StyleCfg>,
820}
821
822pub fn preset_dirs() -> Vec<PathBuf> {
835 let mut dirs = Vec::new();
836 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
837 dirs.push(PathBuf::from(custom));
838 }
839 dirs.push(PathBuf::from(".spar").join("presets"));
840 if let Some(home) = home_dir() {
841 dirs.push(home.join(".config").join("spar").join("presets"));
842 }
843 dirs
844}
845
846pub fn available_presets() -> Vec<String> {
848 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
849 for dir in preset_dirs() {
850 if let Ok(entries) = std::fs::read_dir(&dir) {
851 for entry in entries.flatten() {
852 let path = entry.path();
853 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
854 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
855 names.push(stem.to_string());
856 }
857 }
858 }
859 }
860 }
861 names.sort();
862 names.dedup();
863 names
864}
865
866fn parse_document(text: &str, what: &str) -> Result<Value> {
871 let table: toml::Table =
872 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
873 Ok(Value::Table(table))
874}
875
876pub fn load_preset(name: &str) -> Result<Value> {
879 for dir in preset_dirs() {
880 let path = dir.join(format!("{name}.toml"));
881 if path.is_file() {
882 let text = std::fs::read_to_string(&path)
883 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
884 return parse_document(&text, &format!("preset {}", path.display()));
885 }
886 }
887 for (builtin, text) in BUILTIN_PRESETS {
888 if *builtin == name {
889 return parse_document(text, &format!("built in preset {name}"));
890 }
891 }
892 Err(spar_err!(
893 "unknown preset '{name}'. Available: {}",
894 available_presets().join(", ")
895 ))
896}
897
898fn merge(base: &Value, over: &Value) -> Value {
901 match (base, over) {
902 (Value::Table(b), Value::Table(o)) => {
903 let mut out = b.clone();
904 for (key, value) in o {
905 let merged = match out.get(key) {
906 Some(existing) => merge(existing, value),
907 None => value.clone(),
908 };
909 out.insert(key.clone(), merged);
910 }
911 Value::Table(out)
912 }
913 _ => over.clone(),
914 }
915}
916
917fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
918 let table = raw
919 .as_table()
920 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
921
922 let merged = match table.get("preset").and_then(Value::as_str) {
923 Some(preset) => merge(&load_preset(preset)?, raw),
924 None => raw.clone(),
925 };
926
927 let mut merged_table = merged
928 .as_table()
929 .cloned()
930 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
931 merged_table.remove("preset");
932 let fallback_raw = merged_table.remove("fallback");
935
936 if !merged_table.contains_key("command") {
937 bail!(
938 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
939 available_presets().join(", ")
940 );
941 }
942
943 let mut spec: AgentSpec = Value::Table(merged_table)
944 .try_into()
945 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
946 spec.name = name.to_string();
947
948 if spec.command.is_empty() {
949 bail!("agent '{name}' has an empty command");
950 }
951 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
952 bail!("agent '{name}': the first command element must be the program name, not a group");
953 }
954 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
955 bail!(
956 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
957 );
958 }
959
960 if let Some(raw) = fallback_raw {
961 if !raw.is_table() {
962 bail!(
963 "agent '{name}': fallback is a whole agent, so write it as a table:\n [agents.{name}.fallback]\n preset = \"cursor\""
964 );
965 }
966 let backup = build_spec(&format!("{name}-fallback"), &raw)?;
969 if backup.fallback.is_some() {
970 bail!(
971 "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
972 another full timeout on a call that has already failed once."
973 );
974 }
975 spec.fallback = Some(Box::new(backup));
976 }
977
978 Ok(spec)
979}
980
981#[derive(Debug, Clone)]
987pub struct OptionInfo {
988 pub section: &'static str,
989 pub key: String,
990 pub default: String,
991}
992
993pub fn known_options() -> Vec<OptionInfo> {
999 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
1000 toml::to_string(value)
1001 .unwrap_or_default()
1002 .lines()
1003 .filter_map(|line| line.split_once(" = "))
1004 .map(|(key, default)| OptionInfo {
1005 section,
1006 key: key.trim().to_string(),
1007 default: default.trim().to_string(),
1008 })
1009 .collect()
1010 }
1011 let mut out = lines("loop", &LoopCfg::default());
1012 out.extend(lines("style", &StyleCfg::default()));
1013 out.extend(lines(
1014 "loop.effort_schedule",
1015 &EffortSchedule {
1016 round_1: Some("high".into()),
1017 rest: Some("low".into()),
1018 triage: Some("low".into()),
1019 implement: Some("high".into()),
1020 review_1: Some("high".into()),
1021 review_rest: Some("low".into()),
1022 respond: Some("high".into()),
1023 close: Some("low".into()),
1024 screen: Some("low".into()),
1025 checkin: Some("high".into()),
1026 split: Some("high".into()),
1027 brainstorm: Some("high".into()),
1028 },
1029 ));
1030 out
1031}
1032
1033pub fn mentions(config_text: &str, key: &str) -> bool {
1035 config_text.lines().any(|line| {
1036 let bare = line.trim_start().trim_start_matches('#').trim_start();
1037 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1038 })
1039}
1040
1041pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
1044 known_options()
1045 .into_iter()
1046 .filter(|o| !mentions(config_text, &o.key))
1047 .collect()
1048}
1049
1050pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
1051
1052pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1055 if let Some(path) = explicit {
1056 if !path.is_file() {
1057 bail!("config not found: {}", path.display());
1058 }
1059 return Ok(Some(path.to_path_buf()));
1060 }
1061 for name in CONFIG_NAMES {
1062 let path = PathBuf::from(name);
1063 if path.is_file() {
1064 return Ok(Some(path));
1065 }
1066 }
1067 if let Some(home) = home_dir() {
1068 let path = home.join(".config").join("spar").join("spar.toml");
1069 if path.is_file() {
1070 return Ok(Some(path));
1071 }
1072 }
1073 Ok(None)
1074}
1075
1076pub fn load(explicit: Option<&Path>) -> Result<Config> {
1077 let Some(path) = find_config(explicit)? else {
1078 bail!(
1079 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
1080 );
1081 };
1082 let text = std::fs::read_to_string(&path)
1083 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1084 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
1085 cfg.source = Some(path);
1086 Ok(cfg)
1087}
1088
1089pub fn parse(text: &str) -> Result<Config> {
1090 let raw: RawConfig = toml::from_str(text)?;
1091
1092 if raw.agents.len() != 2 {
1093 bail!(
1094 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
1095 raw.agents.len()
1096 );
1097 }
1098
1099 let mut agents = Vec::new();
1100 for (name, value) in raw.agents.iter() {
1101 agents.push(build_spec(name, value)?);
1102 }
1103
1104 let loop_cfg = raw.loop_cfg.unwrap_or_default();
1105 let style = raw.style.unwrap_or_default().to_style();
1106
1107 if loop_cfg.max_rounds == 0 {
1108 bail!("max_rounds must be at least 1");
1109 }
1110 if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
1115 bail!(
1116 "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
1117 ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
1118 to have it promoted when the review converges, or turn auto_merge off."
1119 );
1120 }
1121
1122 let alternate = loop_cfg
1129 .first_implementor
1130 .as_deref()
1131 .is_some_and(|name| name.trim().eq_ignore_ascii_case("alternate"));
1132 let first = match &loop_cfg.first_implementor {
1133 Some(name) if !name.trim().is_empty() && !alternate => name.trim().to_string(),
1134 _ => agents[0].name.clone(),
1135 };
1136 if !agents.iter().any(|a| a.name == first) {
1137 bail!(
1138 "first_implementor '{first}' is not a configured agent ({})",
1139 agents
1140 .iter()
1141 .map(|a| a.name.as_str())
1142 .collect::<Vec<_>>()
1143 .join(", ")
1144 );
1145 }
1146
1147 Ok(Config {
1148 agents,
1149 loop_cfg,
1150 style,
1151 first_implementor: first,
1152 alternate_first: alternate,
1153 source: None,
1154 })
1155}
1156
1157pub fn resolve_search_path(raw: &str) -> PathBuf {
1159 expand_tilde(raw)
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165
1166 const TWO_AGENTS: &str = r#"
1167[agents.claude]
1168preset = "claude"
1169model = "fable"
1170
1171[agents.codex]
1172preset = "codex"
1173model = "gpt-5.6-sol"
1174"#;
1175
1176 #[test]
1179 fn a_fallback_is_a_whole_agent_with_its_own_preset() {
1180 let text = format!(
1181 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
1182 );
1183 let cfg = parse(&text).expect("parses");
1184 assert_eq!(2, cfg.agents.len());
1186 let codex = cfg.spec("codex").expect("codex");
1187 let backup = codex.fallback.as_ref().expect("fallback");
1188 assert_eq!("codex-fallback", backup.name);
1189 assert_eq!(Some("kimi-k3"), backup.model.as_deref());
1190 assert_eq!(
1191 Some(&CommandPart::One("cursor-agent".into())),
1192 backup.command.first()
1193 );
1194 }
1195
1196 #[test]
1197 fn the_agent_without_a_fallback_does_not_grow_one() {
1198 let cfg = parse(TWO_AGENTS).expect("parses");
1199 assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
1200 }
1201
1202 #[test]
1203 fn a_fallback_may_not_have_one_of_its_own() {
1204 let text = format!(
1205 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
1206 [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
1207 );
1208 let err = parse(&text).expect_err("rejected");
1209 assert!(err.message().contains("may not have a fallback"), "{err}");
1210 }
1211
1212 #[test]
1213 fn a_fallback_written_as_a_string_says_what_it_should_be() {
1214 let text = "[agents.claude]\npreset = \"claude\"\n\n\
1215 [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
1216 let err = parse(text).expect_err("rejected");
1217 assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
1218 }
1219
1220 #[test]
1225 fn a_partial_block_keeps_the_defaults_it_did_not_name() {
1226 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
1227 let cfg = parse(&text).expect("parses");
1228
1229 assert_eq!(9, cfg.loop_cfg.max_rounds);
1230 assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
1231 assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
1232
1233 assert!(!cfg.style.terse);
1234 assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
1235 assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
1236 }
1237
1238 #[test]
1242 fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
1243 assert_eq!(Style::default(), StyleCfg::default().to_style());
1244 }
1245
1246 #[test]
1249 fn pull_requests_are_not_drafts_unless_asked_for() {
1250 assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1251 }
1252
1253 #[test]
1254 fn each_draft_setting_parses() {
1255 for (text, want) in [
1256 ("never", Drafts::Never),
1257 ("until_approved", Drafts::UntilApproved),
1258 ("always", Drafts::Always),
1259 ] {
1260 let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1261 .unwrap_or_else(|e| panic!("{text}: {e}"));
1262 assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1263 }
1264 }
1265
1266 #[test]
1270 fn auto_merge_and_a_permanent_draft_are_refused_together() {
1271 let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1272 let err = parse(&text).expect_err("refused");
1273 assert!(err.message().contains("auto_merge"), "{err}");
1274 assert!(
1275 err.message().contains("until_approved"),
1276 "says the way out: {err}"
1277 );
1278 }
1279
1280 #[test]
1283 fn auto_merge_is_fine_with_a_draft_that_clears() {
1284 let text =
1285 format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1286 assert!(parse(&text).is_ok());
1287 }
1288
1289 #[test]
1290 fn every_builtin_preset_parses() {
1291 for (name, _) in BUILTIN_PRESETS {
1292 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1293 assert!(value.get("command").is_some(), "{name} has no command");
1294 }
1295 }
1296
1297 #[test]
1298 fn every_builtin_preset_builds_a_spec() {
1299 for (name, _) in BUILTIN_PRESETS {
1300 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1301 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1302 }
1303 }
1304
1305 #[test]
1309 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1310 let spec = build_spec(
1311 "claude",
1312 &parse_document("preset = \"claude\"", "test").unwrap(),
1313 )
1314 .unwrap();
1315 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1316 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1317 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1318 }
1319
1320 #[test]
1321 fn codex_preset_declares_where_its_answer_lives() {
1322 let spec = build_spec(
1323 "codex",
1324 &parse_document("preset = \"codex\"", "test").unwrap(),
1325 )
1326 .unwrap();
1327 assert_eq!(OutputMode::Jsonl, spec.output);
1328 assert_eq!(Some("item.text"), spec.message_path.as_deref());
1329 assert!(!spec.message_match.is_empty());
1330 }
1331
1332 #[test]
1336 fn codex_preset_looks_where_either_chatgpt_layout_keeps_the_cli() {
1337 let spec = build_spec(
1338 "codex",
1339 &parse_document("preset = \"codex\"", "test").unwrap(),
1340 )
1341 .unwrap();
1342 let at = |path: &str| spec.search_paths.iter().position(|p| p == path);
1343 let current = at("/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin");
1344 let older = at("/Applications/ChatGPT.app/Contents/Resources");
1345 assert!(current.is_some(), "{:?}", spec.search_paths);
1346 assert!(older.is_some(), "{:?}", spec.search_paths);
1347 assert!(current < older, "{:?}", spec.search_paths);
1348 }
1349
1350 #[test]
1351 fn agent_order_follows_declaration_order() {
1352 let cfg = parse(TWO_AGENTS).unwrap();
1353 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1354 assert_eq!("claude", cfg.first_implementor);
1355 }
1356
1357 #[test]
1358 fn other_alternates() {
1359 let cfg = parse(TWO_AGENTS).unwrap();
1360 assert_eq!("codex", cfg.other("claude"));
1361 assert_eq!("claude", cfg.other("codex"));
1362 }
1363
1364 #[test]
1365 fn a_config_block_overrides_one_preset_field() {
1366 let cfg = parse(TWO_AGENTS).unwrap();
1367 let claude = cfg.spec("claude").unwrap();
1368 assert_eq!(Some("fable"), claude.model.as_deref());
1369 assert!(claude.command.len() > 1, "the preset command survived");
1370 }
1371
1372 #[test]
1373 fn exactly_two_agents_are_required() {
1374 let one = "[agents.claude]\npreset = \"claude\"\n";
1375 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1376 }
1377
1378 #[test]
1379 fn an_unknown_agent_option_is_named() {
1380 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1381 let err = parse(text).unwrap_err().to_string();
1382 assert!(err.contains("widget"), "{err}");
1383 }
1384
1385 #[test]
1386 fn an_unknown_loop_option_is_named() {
1387 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1388 let err = parse(&text).unwrap_err().to_string();
1389 assert!(err.contains("max_round"), "{err}");
1390 }
1391
1392 #[test]
1393 fn an_agent_with_no_command_and_no_preset_is_rejected() {
1394 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1395 let err = parse(text).unwrap_err().to_string();
1396 assert!(err.contains("no command and no preset"), "{err}");
1397 }
1398
1399 #[test]
1400 fn jsonl_without_a_message_path_is_rejected() {
1401 let text =
1402 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1403 let err = parse(text).unwrap_err().to_string();
1404 assert!(err.contains("message_path"), "{err}");
1405 }
1406
1407 #[test]
1412 fn a_check_is_read_as_an_argv_either_way_it_is_written() {
1413 let line = format!("{TWO_AGENTS}\n[loop]\ncheck = \"cargo test --all\"\n");
1414 let cfg = parse(&line).unwrap();
1415 assert!(cfg.loop_cfg.check.is_set());
1416 assert_eq!(vec!["cargo", "test", "--all"], cfg.loop_cfg.check.argv());
1417 assert_eq!("cargo test --all", cfg.loop_cfg.check.describe());
1418
1419 let listed =
1420 format!("{TWO_AGENTS}\n[loop]\ncheck = [\"npm\", \"run\", \"test -- --ci\"]\n");
1421 let cfg = parse(&listed).unwrap();
1422 assert_eq!(
1423 vec!["npm", "run", "test -- --ci"],
1424 cfg.loop_cfg.check.argv(),
1425 "a list is taken as it is, spaces and all"
1426 );
1427
1428 let bare = parse(TWO_AGENTS).unwrap();
1430 assert!(!bare.loop_cfg.check.is_set());
1431 assert!(bare.loop_cfg.check.argv().is_empty());
1432 }
1433
1434 #[test]
1435 fn alternating_swaps_the_author_from_issue_to_issue() {
1436 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"alternate\"\n");
1437 let cfg = parse(&text).unwrap();
1438
1439 assert!(cfg.alternate_first);
1440 assert_eq!(
1441 "claude", cfg.first_implementor,
1442 "declaration order still leads"
1443 );
1444 assert_eq!("claude", cfg.first_implementor_for(0));
1445 assert_eq!("codex", cfg.first_implementor_for(1));
1446 assert_eq!("claude", cfg.first_implementor_for(2));
1447 assert!(cfg.first_implementor_policy().contains("alternating"));
1448 }
1449
1450 #[test]
1452 fn a_named_first_implementor_never_swaps() {
1453 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"codex\"\n");
1454 let cfg = parse(&text).unwrap();
1455 assert!(!cfg.alternate_first);
1456 for nth in 0..4 {
1457 assert_eq!("codex", cfg.first_implementor_for(nth));
1458 }
1459 assert_eq!("codex", cfg.first_implementor_policy());
1460 }
1461
1462 #[test]
1463 fn first_implementor_must_name_a_configured_agent() {
1464 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1465 let err = parse(&text).unwrap_err().to_string();
1466 assert!(err.contains("not a configured agent"), "{err}");
1467 }
1468
1469 #[test]
1470 fn defaults_are_the_conservative_ones() {
1471 let cfg = parse(TWO_AGENTS).unwrap();
1472 assert!(
1473 !cfg.loop_cfg.auto_merge,
1474 "auto_merge must be off by default"
1475 );
1476 assert!(cfg.loop_cfg.worktrees);
1477 assert!(
1478 !cfg.loop_cfg.file_nits,
1479 "a filed nit is somebody else's triage queue"
1480 );
1481 assert_eq!(3, cfg.loop_cfg.max_rounds);
1482 assert_eq!(
1483 Followups::Local,
1484 cfg.loop_cfg.followups,
1485 "the tracker is somebody's queue; the default must not write to it"
1486 );
1487 assert!(
1488 !cfg.loop_cfg.file_non_blocking,
1489 "a suggestion is not a tracker item"
1490 );
1491 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1492 assert!(cfg.style.terse);
1493 }
1494
1495 #[test]
1496 fn effort_schedule_splits_round_one_from_the_rest() {
1497 let text =
1498 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1499 let cfg = parse(&text).unwrap();
1500 let spec = cfg.spec("claude").unwrap();
1501 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1502 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1503 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1504 }
1505
1506 #[test]
1513 fn an_agent_can_carry_its_own_schedule_in_its_own_words() {
1514 let text = format!(
1515 "{TWO_AGENTS}\n[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
1516 [loop.effort_schedule]\nreview_1 = \"ultra\"\n"
1517 );
1518 let cfg = parse(&text).unwrap();
1519 assert_eq!(
1520 Some("xhigh".into()),
1521 cfg.effort_for(cfg.spec("claude").unwrap(), Call::Review(1)),
1522 "the agent's own schedule has to win"
1523 );
1524 assert_eq!(
1525 Some("ultra".into()),
1526 cfg.effort_for(cfg.spec("codex").unwrap(), Call::Review(1))
1527 );
1528 }
1529
1530 #[test]
1534 fn each_kind_of_call_reads_its_own_key_and_falls_back_to_the_old_pair() {
1535 let text = format!(
1536 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"low\"\n\
1537 triage = \"minimal\"\n"
1538 );
1539 let cfg = parse(&text).unwrap();
1540 let spec = cfg.spec("claude").unwrap();
1541
1542 assert_eq!(Some("minimal".into()), cfg.effort_for(spec, Call::Triage));
1543 for call in [Call::Implement, Call::Review(1), Call::Screen] {
1545 assert_eq!(Some("ultra".into()), cfg.effort_for(spec, call), "{call:?}");
1546 }
1547 for call in [Call::Review(2), Call::Close, Call::Respond(3)] {
1548 assert_eq!(Some("low".into()), cfg.effort_for(spec, call), "{call:?}");
1549 }
1550 }
1551
1552 #[test]
1553 fn effort_falls_back_to_the_agents_own_setting() {
1554 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1555 let cfg = parse(&text).unwrap();
1556 let spec = cfg.spec("codex").unwrap();
1557 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1558 }
1559
1560 #[test]
1561 fn an_unset_model_and_an_empty_model_normalise_the_same() {
1562 let a = AgentSpec {
1563 name: "a".into(),
1564 command: vec![CommandPart::One("x".into())],
1565 model: None,
1566 effort: None,
1567 effort_schedule: EffortSchedule::default(),
1568 output: OutputMode::Text,
1569 message_match: BTreeMap::new(),
1570 message_path: None,
1571 search_paths: vec![],
1572 system_via: SystemVia::Prompt,
1573 timeout: 60,
1574 fallback: None,
1575 models: vec![],
1576 efforts: vec![],
1577 options_note: None,
1578 };
1579 let b = AgentSpec {
1580 model: Some(" ".into()),
1581 ..a.clone()
1582 };
1583 assert_eq!(a.model_key(), b.model_key());
1584 }
1585
1586 #[test]
1587 fn max_rounds_zero_is_rejected() {
1588 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1589 assert!(parse(&text).is_err());
1590 }
1591
1592 #[test]
1593 fn an_inline_command_needs_no_preset() {
1594 let text = r#"
1595[agents.custom]
1596command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1597output = "text"
1598
1599[agents.other]
1600command = ["othertool", "{prompt}"]
1601"#;
1602 let cfg = parse(text).unwrap();
1603 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1604 }
1605
1606 #[test]
1607 fn style_budgets_are_configurable() {
1608 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1609 let cfg = parse(&text).unwrap();
1610 assert!(!cfg.style.terse);
1611 assert_eq!(40, cfg.style.max_detail_chars);
1612 }
1613}