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 Code,
320 Triage,
322 Implement,
324 Review(u32),
326 Respond(u32),
328 Close,
330 Screen,
332 Checkin(u32),
334 Split(u32),
336 Brainstorm(u32),
339 Recheck,
341 Answer(u32),
344}
345
346impl Call {
347 pub fn key(self) -> &'static str {
349 match self {
350 Call::Code => "code",
351 Call::Triage => "triage",
352 Call::Implement => "implement",
353 Call::Review(round) if round <= 1 => "review_1",
354 Call::Review(_) => "review_rest",
355 Call::Respond(_) => "respond",
356 Call::Close => "close",
357 Call::Screen => "screen",
358 Call::Checkin(_) => "checkin",
359 Call::Split(_) => "split",
360 Call::Brainstorm(_) => "brainstorm",
361 Call::Recheck => "recheck",
362 Call::Answer(_) => "answer",
363 }
364 }
365
366 fn legacy_round(self) -> u32 {
369 match self {
370 Call::Code | Call::Triage | Call::Implement | Call::Screen => 1,
371 Call::Review(round) | Call::Respond(round) => round,
372 Call::Checkin(round)
373 | Call::Split(round)
374 | Call::Brainstorm(round)
375 | Call::Answer(round) => round,
376 Call::Close | Call::Recheck => 2,
377 }
378 }
379
380 pub fn every() -> [Call; 13] {
382 [
383 Call::Code,
384 Call::Triage,
385 Call::Implement,
386 Call::Review(1),
387 Call::Review(2),
388 Call::Recheck,
389 Call::Respond(1),
390 Call::Close,
391 Call::Screen,
392 Call::Checkin(1),
393 Call::Split(1),
394 Call::Brainstorm(1),
395 Call::Answer(1),
396 ]
397 }
398}
399
400#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401#[serde(deny_unknown_fields)]
402pub struct EffortSchedule {
403 pub code: Option<String>,
405 pub round_1: Option<String>,
410 pub rest: Option<String>,
413 pub triage: Option<String>,
416 pub implement: Option<String>,
417 pub review_1: Option<String>,
418 pub review_rest: Option<String>,
419 pub respond: Option<String>,
420 pub close: Option<String>,
421 pub screen: Option<String>,
422 pub checkin: Option<String>,
423 pub split: Option<String>,
424 pub brainstorm: Option<String>,
428 pub recheck: Option<String>,
432 pub answer: Option<String>,
434}
435
436fn set(value: &Option<String>) -> Option<String> {
438 value.clone().filter(|s| !s.trim().is_empty())
439}
440
441impl EffortSchedule {
442 pub fn get(&self, call: Call) -> Option<String> {
444 let value = match call.key() {
445 "code" => &self.code,
446 "triage" => &self.triage,
447 "implement" => &self.implement,
448 "review_1" => &self.review_1,
449 "review_rest" => &self.review_rest,
450 "respond" => &self.respond,
451 "close" => &self.close,
452 "screen" => &self.screen,
453 "checkin" => &self.checkin,
454 "brainstorm" => &self.brainstorm,
455 "answer" => &self.answer,
456 "recheck" => {
457 return set(&self.recheck).or_else(|| set(&self.review_rest));
458 }
459 _ => &self.split,
460 };
461 set(value)
462 }
463
464 fn legacy(&self, call: Call) -> Option<String> {
466 let value = if call.legacy_round() <= 1 {
467 &self.round_1
468 } else {
469 &self.rest
470 };
471 value.clone().filter(|s| !s.trim().is_empty())
472 }
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(untagged)]
484pub enum CheckCommand {
485 Line(String),
486 Argv(Vec<String>),
487}
488
489impl Default for CheckCommand {
490 fn default() -> Self {
493 CheckCommand::Argv(Vec::new())
494 }
495}
496
497impl CheckCommand {
498 pub fn argv(&self) -> Vec<String> {
499 match self {
500 CheckCommand::Line(line) => line.split_whitespace().map(str::to_string).collect(),
501 CheckCommand::Argv(argv) => argv
502 .iter()
503 .map(|a| a.trim().to_string())
504 .filter(|a| !a.is_empty())
505 .collect(),
506 }
507 }
508
509 pub fn is_set(&self) -> bool {
510 !self.argv().is_empty()
511 }
512
513 pub fn describe(&self) -> String {
515 self.argv().join(" ")
516 }
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
523#[serde(default, deny_unknown_fields)]
524pub struct LoopCfg {
525 pub max_rounds: u32,
528 pub auto_merge: bool,
529 pub first_implementor: Option<String>,
530 pub base_branch: String,
531 pub worktrees: bool,
532 pub keep_worktrees: bool,
533 pub state_store: StateStore,
534 pub branch_prefix: String,
535 pub followups: Followups,
536 pub file_non_blocking: bool,
544 pub max_followups: usize,
547 pub max_split_parts: usize,
555 pub file_nits: bool,
559 pub close_skipped: bool,
562 pub parallel_triage: bool,
565 pub min_number: i64,
573 pub absorb_new_issues: u32,
580 pub decompose_trackers: bool,
592 pub max_tracker_children: usize,
595 pub tick_criteria: bool,
603 pub drafts: Drafts,
605 pub fork_when_denied: bool,
613 pub instructions: String,
625 pub max_issue_chars: usize,
634 pub max_triage_chars: usize,
643 pub checkin_trust: Trust,
651 pub checkin_resolve: bool,
656 pub max_checkin_comments: usize,
661 pub setup: CheckCommand,
672 pub check: CheckCommand,
682 pub effort_schedule: EffortSchedule,
683}
684
685impl Default for LoopCfg {
686 fn default() -> Self {
687 Self {
688 max_rounds: 3,
689 auto_merge: false,
690 first_implementor: None,
691 base_branch: "main".into(),
692 worktrees: true,
693 keep_worktrees: false,
694 state_store: StateStore::Local,
695 branch_prefix: String::new(),
696 followups: Followups::Local,
697 file_non_blocking: false,
698 max_followups: 5,
699 max_split_parts: 4,
700 file_nits: false,
701 close_skipped: true,
702 parallel_triage: true,
703 min_number: 0,
704 absorb_new_issues: 0,
705 decompose_trackers: false,
706 max_tracker_children: 5,
707 tick_criteria: true,
708 drafts: Drafts::Never,
709 fork_when_denied: true,
710 instructions: String::new(),
711 max_issue_chars: 60_000,
712 max_triage_chars: 200_000,
713 checkin_trust: Trust::Write,
714 checkin_resolve: true,
715 max_checkin_comments: 20,
716 setup: CheckCommand::default(),
717 check: CheckCommand::default(),
718 effort_schedule: EffortSchedule::default(),
719 }
720 }
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
724#[serde(default, deny_unknown_fields)]
725pub struct StyleCfg {
726 pub ban_em_dash: bool,
727 pub ban_ai_attribution: bool,
728 pub terse: bool,
729 pub max_detail_chars: usize,
730 pub max_summary_chars: usize,
731 pub max_body_chars: usize,
732 pub max_issue_body_chars: usize,
736 pub max_title_chars: usize,
737 pub pr_comments: PrComments,
738 pub inline_comments: bool,
739}
740
741impl Default for StyleCfg {
742 fn default() -> Self {
752 let style = Style::default();
753 Self {
754 ban_em_dash: style.ban_em_dash,
755 ban_ai_attribution: style.ban_ai_attribution,
756 terse: style.terse,
757 max_detail_chars: style.max_detail_chars,
758 max_summary_chars: style.max_summary_chars,
759 max_body_chars: style.max_body_chars,
760 max_issue_body_chars: style.max_issue_body_chars,
761 max_title_chars: style.max_title_chars,
762 pr_comments: style.pr_comments,
763 inline_comments: style.inline_comments,
764 }
765 }
766}
767
768impl StyleCfg {
769 pub fn to_style(&self) -> Style {
770 Style {
771 ban_em_dash: self.ban_em_dash,
772 ban_ai_attribution: self.ban_ai_attribution,
773 terse: self.terse,
774 max_detail_chars: self.max_detail_chars,
775 max_summary_chars: self.max_summary_chars,
776 max_body_chars: self.max_body_chars,
777 max_issue_body_chars: self.max_issue_body_chars,
778 max_title_chars: self.max_title_chars,
779 pr_comments: self.pr_comments,
780 inline_comments: self.inline_comments,
781 }
782 }
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(rename_all = "lowercase")]
792pub enum ReviewOrIgnore {
793 Review,
795 Ignore,
797}
798
799impl std::fmt::Display for ReviewOrIgnore {
800 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801 f.write_str(match self {
802 ReviewOrIgnore::Review => "review",
803 ReviewOrIgnore::Ignore => "ignore",
804 })
805 }
806}
807
808#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(rename_all = "lowercase")]
813pub enum OwnPr {
814 Checkin,
817 Reply,
819 Ignore,
821}
822
823impl std::fmt::Display for OwnPr {
824 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
825 f.write_str(match self {
826 OwnPr::Checkin => "checkin",
827 OwnPr::Reply => "reply",
828 OwnPr::Ignore => "ignore",
829 })
830 }
831}
832
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
835#[serde(rename_all = "lowercase")]
836pub enum RunOrIgnore {
837 Run,
840 Ignore,
842}
843
844impl std::fmt::Display for RunOrIgnore {
845 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
846 f.write_str(match self {
847 RunOrIgnore::Run => "run",
848 RunOrIgnore::Ignore => "ignore",
849 })
850 }
851}
852
853#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
856#[serde(rename_all = "lowercase")]
857pub enum OnMention {
858 Answer,
861 Draft,
863 Ignore,
865}
866
867impl std::fmt::Display for OnMention {
868 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869 f.write_str(match self {
870 OnMention::Answer => "answer",
871 OnMention::Draft => "draft",
872 OnMention::Ignore => "ignore",
873 })
874 }
875}
876
877#[derive(Debug, Clone, Serialize, Deserialize)]
881#[serde(default, deny_unknown_fields)]
882pub struct ListenCfg {
883 pub interval: u64,
886 pub sweep: u64,
891 pub settle: u64,
895 pub max_wait: u64,
897 pub max_rechecks: u32,
901 pub on_review_requested: ReviewOrIgnore,
902 pub on_assigned: ReviewOrIgnore,
903 pub on_assigned_issue: RunOrIgnore,
906 pub on_own_pr: OwnPr,
907 pub verdicts: bool,
910 pub on_mention: OnMention,
911 pub max_answers: u32,
915}
916
917impl Default for ListenCfg {
918 fn default() -> Self {
919 Self {
920 interval: 60,
921 sweep: 300,
922 settle: 120,
923 max_wait: 900,
924 max_rechecks: 5,
925 on_review_requested: ReviewOrIgnore::Review,
926 on_assigned: ReviewOrIgnore::Review,
927 on_assigned_issue: RunOrIgnore::Run,
928 on_own_pr: OwnPr::Checkin,
929 verdicts: true,
930 on_mention: OnMention::Answer,
931 max_answers: 3,
932 }
933 }
934}
935
936#[derive(Debug, Clone)]
941pub struct Config {
942 pub agents: Vec<AgentSpec>,
944 pub loop_cfg: LoopCfg,
945 pub style: Style,
946 pub listen: ListenCfg,
947 pub first_implementor: String,
950 pub alternate_first: bool,
952 pub source: Option<PathBuf>,
954}
955
956impl Config {
957 pub fn agent_names(&self) -> Vec<String> {
958 self.agents.iter().map(|a| a.name.clone()).collect()
959 }
960
961 pub fn has_agent(&self, name: &str) -> bool {
962 self.agents.iter().any(|a| a.name == name)
963 }
964
965 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
966 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
967 spar_err!(
968 "no agent named '{name}' ({})",
969 self.agent_names().join(", ")
970 )
971 })
972 }
973
974 pub fn other(&self, name: &str) -> String {
977 let names = self.agent_names();
978 if names.first().map(String::as_str) == Some(name) {
979 names.get(1).cloned().unwrap_or_else(|| name.to_string())
980 } else {
981 names.first().cloned().unwrap_or_else(|| name.to_string())
982 }
983 }
984
985 pub fn effort_for(&self, spec: &AgentSpec, call: Call) -> Option<String> {
993 spec.effort_schedule
994 .get(call)
995 .or_else(|| self.loop_cfg.effort_schedule.get(call))
996 .or_else(|| self.loop_cfg.effort_schedule.legacy(call))
997 .or_else(|| spec.effort.clone())
998 }
999
1000 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
1003 self.effort_for(spec, Call::Review(round))
1004 }
1005
1006 pub fn first_implementor_for(&self, nth: usize) -> String {
1011 if !self.alternate_first || nth % 2 == 0 {
1012 return self.first_implementor.clone();
1013 }
1014 self.other(&self.first_implementor)
1015 }
1016
1017 pub fn first_implementor_policy(&self) -> String {
1019 if self.alternate_first {
1020 format!("alternating, {} first", self.first_implementor)
1021 } else {
1022 self.first_implementor.clone()
1023 }
1024 }
1025
1026 pub fn base_branch(&self) -> &str {
1027 &self.loop_cfg.base_branch
1028 }
1029}
1030
1031#[derive(Debug, Deserialize)]
1032#[serde(deny_unknown_fields)]
1033struct RawConfig {
1034 #[serde(default)]
1035 agents: toml::Table,
1036 #[serde(default)]
1037 #[serde(rename = "loop")]
1038 loop_cfg: Option<LoopCfg>,
1039 #[serde(default)]
1040 style: Option<StyleCfg>,
1041 #[serde(default)]
1042 listen: Option<ListenCfg>,
1043}
1044
1045pub fn preset_dirs() -> Vec<PathBuf> {
1058 let mut dirs = Vec::new();
1059 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
1060 dirs.push(PathBuf::from(custom));
1061 }
1062 dirs.push(PathBuf::from(".spar").join("presets"));
1063 if let Some(home) = home_dir() {
1064 dirs.push(home.join(".config").join("spar").join("presets"));
1065 }
1066 dirs
1067}
1068
1069pub fn available_presets() -> Vec<String> {
1071 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
1072 for dir in preset_dirs() {
1073 if let Ok(entries) = std::fs::read_dir(&dir) {
1074 for entry in entries.flatten() {
1075 let path = entry.path();
1076 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
1077 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
1078 names.push(stem.to_string());
1079 }
1080 }
1081 }
1082 }
1083 }
1084 names.sort();
1085 names.dedup();
1086 names
1087}
1088
1089fn parse_document(text: &str, what: &str) -> Result<Value> {
1094 let table: toml::Table =
1095 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
1096 Ok(Value::Table(table))
1097}
1098
1099pub fn load_preset(name: &str) -> Result<Value> {
1102 for dir in preset_dirs() {
1103 let path = dir.join(format!("{name}.toml"));
1104 if path.is_file() {
1105 let text = std::fs::read_to_string(&path)
1106 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
1107 return parse_document(&text, &format!("preset {}", path.display()));
1108 }
1109 }
1110 for (builtin, text) in BUILTIN_PRESETS {
1111 if *builtin == name {
1112 return parse_document(text, &format!("built in preset {name}"));
1113 }
1114 }
1115 Err(spar_err!(
1116 "unknown preset '{name}'. Available: {}",
1117 available_presets().join(", ")
1118 ))
1119}
1120
1121fn merge(base: &Value, over: &Value) -> Value {
1124 match (base, over) {
1125 (Value::Table(b), Value::Table(o)) => {
1126 let mut out = b.clone();
1127 for (key, value) in o {
1128 let merged = match out.get(key) {
1129 Some(existing) => merge(existing, value),
1130 None => value.clone(),
1131 };
1132 out.insert(key.clone(), merged);
1133 }
1134 Value::Table(out)
1135 }
1136 _ => over.clone(),
1137 }
1138}
1139
1140fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
1141 let table = raw
1142 .as_table()
1143 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1144
1145 let merged = match table.get("preset").and_then(Value::as_str) {
1146 Some(preset) => merge(&load_preset(preset)?, raw),
1147 None => raw.clone(),
1148 };
1149
1150 let mut merged_table = merged
1151 .as_table()
1152 .cloned()
1153 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1154 merged_table.remove("preset");
1155 let fallback_raw = merged_table.remove("fallback");
1158
1159 if !merged_table.contains_key("command") {
1160 bail!(
1161 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
1162 available_presets().join(", ")
1163 );
1164 }
1165
1166 let mut spec: AgentSpec = Value::Table(merged_table)
1167 .try_into()
1168 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
1169 spec.name = name.to_string();
1170
1171 if spec.command.is_empty() {
1172 bail!("agent '{name}' has an empty command");
1173 }
1174 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
1175 bail!("agent '{name}': the first command element must be the program name, not a group");
1176 }
1177 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
1178 bail!(
1179 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
1180 );
1181 }
1182
1183 if let Some(raw) = fallback_raw {
1184 if !raw.is_table() {
1185 bail!(
1186 "agent '{name}': fallback is a whole agent, so write it as a table:\n [agents.{name}.fallback]\n preset = \"cursor\""
1187 );
1188 }
1189 let backup = build_spec(&format!("{name}-fallback"), &raw)?;
1192 if backup.fallback.is_some() {
1193 bail!(
1194 "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
1195 another full timeout on a call that has already failed once."
1196 );
1197 }
1198 spec.fallback = Some(Box::new(backup));
1199 }
1200
1201 Ok(spec)
1202}
1203
1204#[derive(Debug, Clone)]
1210pub struct OptionInfo {
1211 pub section: &'static str,
1212 pub key: String,
1213 pub default: String,
1214}
1215
1216pub fn known_options() -> Vec<OptionInfo> {
1222 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
1223 toml::to_string(value)
1224 .unwrap_or_default()
1225 .lines()
1226 .filter_map(|line| line.split_once(" = "))
1227 .map(|(key, default)| OptionInfo {
1228 section,
1229 key: key.trim().to_string(),
1230 default: default.trim().to_string(),
1231 })
1232 .collect()
1233 }
1234 let mut out = lines("loop", &LoopCfg::default());
1235 out.extend(lines("style", &StyleCfg::default()));
1236 out.extend(lines(
1237 "loop.effort_schedule",
1238 &EffortSchedule {
1239 round_1: Some("high".into()),
1240 rest: Some("low".into()),
1241 code: Some("high".into()),
1242 triage: Some("low".into()),
1243 implement: Some("high".into()),
1244 review_1: Some("high".into()),
1245 review_rest: Some("low".into()),
1246 respond: Some("high".into()),
1247 close: Some("low".into()),
1248 screen: Some("low".into()),
1249 checkin: Some("high".into()),
1250 split: Some("high".into()),
1251 brainstorm: Some("high".into()),
1252 recheck: Some("low".into()),
1253 answer: Some("high".into()),
1254 },
1255 ));
1256 out.extend(lines("listen", &ListenCfg::default()));
1257 out
1258}
1259
1260pub fn mentions(config_text: &str, key: &str) -> bool {
1262 config_text.lines().any(|line| {
1263 let bare = line.trim_start().trim_start_matches('#').trim_start();
1264 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1265 })
1266}
1267
1268pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
1271 known_options()
1272 .into_iter()
1273 .filter(|o| !mentions(config_text, &o.key))
1274 .collect()
1275}
1276
1277pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
1278
1279pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1282 if let Some(path) = explicit {
1283 if !path.is_file() {
1284 bail!("config not found: {}", path.display());
1285 }
1286 return Ok(Some(path.to_path_buf()));
1287 }
1288 for name in CONFIG_NAMES {
1289 let path = PathBuf::from(name);
1290 if path.is_file() {
1291 return Ok(Some(path));
1292 }
1293 }
1294 if let Some(home) = home_dir() {
1295 let path = home.join(".config").join("spar").join("spar.toml");
1296 if path.is_file() {
1297 return Ok(Some(path));
1298 }
1299 }
1300 Ok(None)
1301}
1302
1303pub fn load(explicit: Option<&Path>) -> Result<Config> {
1304 let Some(path) = find_config(explicit)? else {
1305 bail!(
1306 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
1307 );
1308 };
1309 let text = std::fs::read_to_string(&path)
1310 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1311 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
1312 cfg.source = Some(path);
1313 Ok(cfg)
1314}
1315
1316pub fn parse(text: &str) -> Result<Config> {
1317 let raw: RawConfig = toml::from_str(text)?;
1318
1319 if raw.agents.len() != 2 {
1320 bail!(
1321 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
1322 raw.agents.len()
1323 );
1324 }
1325
1326 let mut agents = Vec::new();
1327 for (name, value) in raw.agents.iter() {
1328 agents.push(build_spec(name, value)?);
1329 }
1330
1331 let loop_cfg = raw.loop_cfg.unwrap_or_default();
1332 let style = raw.style.unwrap_or_default().to_style();
1333 let listen = raw.listen.unwrap_or_default();
1334
1335 if loop_cfg.max_rounds == 0 {
1336 bail!("max_rounds must be at least 1");
1337 }
1338 if listen.settle > listen.max_wait {
1339 bail!(
1340 "[listen] settle ({}) cannot be longer than max_wait ({}): an item that kept \
1341 changing would never be acted on. Raise max_wait or lower settle.",
1342 listen.settle,
1343 listen.max_wait
1344 );
1345 }
1346 if listen.interval == 0 || listen.sweep == 0 {
1347 bail!(
1348 "[listen] interval and sweep are seconds between polls and must be at least 1. \
1349 Polling never goes below 15 seconds whatever they say."
1350 );
1351 }
1352 if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
1357 bail!(
1358 "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
1359 ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
1360 to have it promoted when the review converges, or turn auto_merge off."
1361 );
1362 }
1363
1364 let alternate = loop_cfg
1371 .first_implementor
1372 .as_deref()
1373 .is_some_and(|name| name.trim().eq_ignore_ascii_case("alternate"));
1374 let first = match &loop_cfg.first_implementor {
1375 Some(name) if !name.trim().is_empty() && !alternate => name.trim().to_string(),
1376 _ => agents[0].name.clone(),
1377 };
1378 if !agents.iter().any(|a| a.name == first) {
1379 bail!(
1380 "first_implementor '{first}' is not a configured agent ({})",
1381 agents
1382 .iter()
1383 .map(|a| a.name.as_str())
1384 .collect::<Vec<_>>()
1385 .join(", ")
1386 );
1387 }
1388
1389 Ok(Config {
1390 agents,
1391 loop_cfg,
1392 style,
1393 listen,
1394 first_implementor: first,
1395 alternate_first: alternate,
1396 source: None,
1397 })
1398}
1399
1400pub fn resolve_search_path(raw: &str) -> PathBuf {
1402 expand_tilde(raw)
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407 use super::*;
1408
1409 const TWO_AGENTS: &str = r#"
1410[agents.claude]
1411preset = "claude"
1412model = "fable"
1413
1414[agents.codex]
1415preset = "codex"
1416model = "gpt-5.6-sol"
1417"#;
1418
1419 #[test]
1422 fn a_fallback_is_a_whole_agent_with_its_own_preset() {
1423 let text = format!(
1424 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
1425 );
1426 let cfg = parse(&text).expect("parses");
1427 assert_eq!(2, cfg.agents.len());
1429 let codex = cfg.spec("codex").expect("codex");
1430 let backup = codex.fallback.as_ref().expect("fallback");
1431 assert_eq!("codex-fallback", backup.name);
1432 assert_eq!(Some("kimi-k3"), backup.model.as_deref());
1433 assert_eq!(
1434 Some(&CommandPart::One("cursor-agent".into())),
1435 backup.command.first()
1436 );
1437 }
1438
1439 #[test]
1440 fn the_agent_without_a_fallback_does_not_grow_one() {
1441 let cfg = parse(TWO_AGENTS).expect("parses");
1442 assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
1443 }
1444
1445 #[test]
1446 fn a_fallback_may_not_have_one_of_its_own() {
1447 let text = format!(
1448 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
1449 [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
1450 );
1451 let err = parse(&text).expect_err("rejected");
1452 assert!(err.message().contains("may not have a fallback"), "{err}");
1453 }
1454
1455 #[test]
1456 fn a_fallback_written_as_a_string_says_what_it_should_be() {
1457 let text = "[agents.claude]\npreset = \"claude\"\n\n\
1458 [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
1459 let err = parse(text).expect_err("rejected");
1460 assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
1461 }
1462
1463 #[test]
1468 fn a_partial_block_keeps_the_defaults_it_did_not_name() {
1469 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
1470 let cfg = parse(&text).expect("parses");
1471
1472 assert_eq!(9, cfg.loop_cfg.max_rounds);
1473 assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
1474 assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
1475
1476 assert!(!cfg.style.terse);
1477 assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
1478 assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
1479 }
1480
1481 #[test]
1485 fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
1486 assert_eq!(Style::default(), StyleCfg::default().to_style());
1487 }
1488
1489 #[test]
1492 fn pull_requests_are_not_drafts_unless_asked_for() {
1493 assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1494 }
1495
1496 #[test]
1497 fn each_draft_setting_parses() {
1498 for (text, want) in [
1499 ("never", Drafts::Never),
1500 ("until_approved", Drafts::UntilApproved),
1501 ("always", Drafts::Always),
1502 ] {
1503 let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1504 .unwrap_or_else(|e| panic!("{text}: {e}"));
1505 assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1506 }
1507 }
1508
1509 #[test]
1513 fn auto_merge_and_a_permanent_draft_are_refused_together() {
1514 let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1515 let err = parse(&text).expect_err("refused");
1516 assert!(err.message().contains("auto_merge"), "{err}");
1517 assert!(
1518 err.message().contains("until_approved"),
1519 "says the way out: {err}"
1520 );
1521 }
1522
1523 #[test]
1526 fn auto_merge_is_fine_with_a_draft_that_clears() {
1527 let text =
1528 format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1529 assert!(parse(&text).is_ok());
1530 }
1531
1532 #[test]
1533 fn every_builtin_preset_parses() {
1534 for (name, _) in BUILTIN_PRESETS {
1535 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1536 assert!(value.get("command").is_some(), "{name} has no command");
1537 }
1538 }
1539
1540 #[test]
1541 fn every_builtin_preset_builds_a_spec() {
1542 for (name, _) in BUILTIN_PRESETS {
1543 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1544 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1545 }
1546 }
1547
1548 #[test]
1552 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1553 let spec = build_spec(
1554 "claude",
1555 &parse_document("preset = \"claude\"", "test").unwrap(),
1556 )
1557 .unwrap();
1558 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1559 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1560 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1561 }
1562
1563 #[test]
1564 fn codex_preset_declares_where_its_answer_lives() {
1565 let spec = build_spec(
1566 "codex",
1567 &parse_document("preset = \"codex\"", "test").unwrap(),
1568 )
1569 .unwrap();
1570 assert_eq!(OutputMode::Jsonl, spec.output);
1571 assert_eq!(Some("item.text"), spec.message_path.as_deref());
1572 assert!(!spec.message_match.is_empty());
1573 }
1574
1575 #[test]
1579 fn codex_preset_looks_where_either_chatgpt_layout_keeps_the_cli() {
1580 let spec = build_spec(
1581 "codex",
1582 &parse_document("preset = \"codex\"", "test").unwrap(),
1583 )
1584 .unwrap();
1585 let at = |path: &str| spec.search_paths.iter().position(|p| p == path);
1586 let current = at("/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin");
1587 let older = at("/Applications/ChatGPT.app/Contents/Resources");
1588 assert!(current.is_some(), "{:?}", spec.search_paths);
1589 assert!(older.is_some(), "{:?}", spec.search_paths);
1590 assert!(current < older, "{:?}", spec.search_paths);
1591 }
1592
1593 #[test]
1594 fn agent_order_follows_declaration_order() {
1595 let cfg = parse(TWO_AGENTS).unwrap();
1596 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1597 assert_eq!("claude", cfg.first_implementor);
1598 }
1599
1600 #[test]
1601 fn other_alternates() {
1602 let cfg = parse(TWO_AGENTS).unwrap();
1603 assert_eq!("codex", cfg.other("claude"));
1604 assert_eq!("claude", cfg.other("codex"));
1605 }
1606
1607 #[test]
1608 fn a_config_block_overrides_one_preset_field() {
1609 let cfg = parse(TWO_AGENTS).unwrap();
1610 let claude = cfg.spec("claude").unwrap();
1611 assert_eq!(Some("fable"), claude.model.as_deref());
1612 assert!(claude.command.len() > 1, "the preset command survived");
1613 }
1614
1615 #[test]
1616 fn exactly_two_agents_are_required() {
1617 let one = "[agents.claude]\npreset = \"claude\"\n";
1618 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1619 }
1620
1621 #[test]
1622 fn an_unknown_agent_option_is_named() {
1623 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1624 let err = parse(text).unwrap_err().to_string();
1625 assert!(err.contains("widget"), "{err}");
1626 }
1627
1628 #[test]
1629 fn an_unknown_loop_option_is_named() {
1630 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1631 let err = parse(&text).unwrap_err().to_string();
1632 assert!(err.contains("max_round"), "{err}");
1633 }
1634
1635 #[test]
1636 fn an_unknown_listen_option_is_named() {
1637 let text = format!("{TWO_AGENTS}\n[listen]\ninterval_secs = 30\n");
1638 let err = parse(&text).unwrap_err().to_string();
1639 assert!(err.contains("interval_secs"), "{err}");
1640 }
1641
1642 #[test]
1643 fn a_listen_block_keeps_the_defaults_it_did_not_name() {
1644 let text = format!("{TWO_AGENTS}\n[listen]\nsweep = 30\n");
1645 let cfg = parse(&text).expect("parses");
1646 assert_eq!(30, cfg.listen.sweep);
1647 assert_eq!(ListenCfg::default().interval, cfg.listen.interval);
1648 assert_eq!(ListenCfg::default().settle, cfg.listen.settle);
1649 assert_eq!(OwnPr::Checkin, cfg.listen.on_own_pr);
1650 assert!(cfg.listen.verdicts);
1651
1652 let bare = parse(TWO_AGENTS).unwrap();
1653 assert_eq!(60, bare.listen.interval);
1654 assert_eq!(300, bare.listen.sweep);
1655 assert_eq!(120, bare.listen.settle);
1656 assert_eq!(900, bare.listen.max_wait);
1657 assert_eq!(5, bare.listen.max_rechecks);
1658 assert_eq!(ReviewOrIgnore::Review, bare.listen.on_review_requested);
1659 assert_eq!(ReviewOrIgnore::Review, bare.listen.on_assigned);
1660 assert_eq!(RunOrIgnore::Run, bare.listen.on_assigned_issue);
1661 }
1662
1663 #[test]
1664 fn settle_longer_than_max_wait_is_refused_with_a_reason() {
1665 let text = format!("{TWO_AGENTS}\n[listen]\nsettle = 1000\nmax_wait = 900\n");
1666 let err = parse(&text).unwrap_err().to_string();
1667 assert!(err.contains("settle"), "{err}");
1668 assert!(err.contains("Raise max_wait"), "{err}");
1669
1670 let text = format!("{TWO_AGENTS}\n[listen]\ninterval = 0\n");
1671 let err = parse(&text).unwrap_err().to_string();
1672 assert!(err.contains("at least 1"), "{err}");
1673 }
1674
1675 #[test]
1676 fn each_listen_action_word_parses() {
1677 for (text, want) in [
1678 ("review", ReviewOrIgnore::Review),
1679 ("ignore", ReviewOrIgnore::Ignore),
1680 ] {
1681 let cfg = parse(&format!(
1682 "{TWO_AGENTS}\n[listen]\non_review_requested = \"{text}\"\non_assigned = \"{text}\"\n"
1683 ))
1684 .unwrap();
1685 assert_eq!(want, cfg.listen.on_review_requested);
1686 assert_eq!(want, cfg.listen.on_assigned);
1687 assert_eq!(text, want.to_string());
1688 }
1689 for (text, want) in [
1690 ("checkin", OwnPr::Checkin),
1691 ("reply", OwnPr::Reply),
1692 ("ignore", OwnPr::Ignore),
1693 ] {
1694 let cfg = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"{text}\"\n")).unwrap();
1695 assert_eq!(want, cfg.listen.on_own_pr);
1696 assert_eq!(text, want.to_string());
1697 }
1698 for (text, want) in [
1699 ("answer", OnMention::Answer),
1700 ("draft", OnMention::Draft),
1701 ("ignore", OnMention::Ignore),
1702 ] {
1703 let cfg = parse(&format!(
1704 "{TWO_AGENTS}\n[listen]\non_mention = \"{text}\"\n"
1705 ))
1706 .unwrap();
1707 assert_eq!(want, cfg.listen.on_mention);
1708 assert_eq!(text, want.to_string());
1709 }
1710 assert_eq!(OnMention::Answer, ListenCfg::default().on_mention);
1711 assert_eq!(3, ListenCfg::default().max_answers);
1712 for (text, want) in [("run", RunOrIgnore::Run), ("ignore", RunOrIgnore::Ignore)] {
1713 let cfg = parse(&format!(
1714 "{TWO_AGENTS}\n[listen]\non_assigned_issue = \"{text}\"\n"
1715 ))
1716 .unwrap();
1717 assert_eq!(want, cfg.listen.on_assigned_issue);
1718 assert_eq!(text, want.to_string());
1719 }
1720 let err = parse(&format!(
1721 "{TWO_AGENTS}\n[listen]\non_assigned_issue = \"review\"\n"
1722 ))
1723 .unwrap_err()
1724 .to_string();
1725 assert!(
1726 err.contains("on_assigned_issue") || err.contains("review"),
1727 "{err}"
1728 );
1729 let err = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"push\"\n"))
1730 .unwrap_err()
1731 .to_string();
1732 assert!(err.contains("on_own_pr") || err.contains("push"), "{err}");
1733 }
1734
1735 #[test]
1736 fn a_recheck_reads_its_own_key_before_review_rest_and_falls_back_to_it() {
1737 let text = format!(
1738 "{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\nrecheck = \"high\"\n"
1739 );
1740 let cfg = parse(&text).unwrap();
1741 let spec = cfg.spec("claude").unwrap();
1742 assert_eq!(
1743 Some("high".to_string()),
1744 cfg.effort_for(spec, Call::Recheck)
1745 );
1746
1747 let text = format!("{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\n");
1748 let cfg = parse(&text).unwrap();
1749 let spec = cfg.spec("claude").unwrap();
1750 assert_eq!(Some("low".to_string()), cfg.effort_for(spec, Call::Recheck));
1751
1752 let text = format!(
1753 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"medium\"\n"
1754 );
1755 let cfg = parse(&text).unwrap();
1756 let spec = cfg.spec("claude").unwrap();
1757 assert_eq!(
1758 Some("medium".to_string()),
1759 cfg.effort_for(spec, Call::Recheck)
1760 );
1761 }
1762
1763 #[test]
1764 fn every_call_kind_is_in_every() {
1765 let every = Call::every();
1766 assert_eq!(13, every.len());
1767 assert!(every.contains(&Call::Code));
1768 assert!(every.contains(&Call::Recheck));
1769 assert!(every.contains(&Call::Answer(1)));
1770 assert_eq!("recheck", Call::Recheck.key());
1771 assert_eq!("answer", Call::Answer(2).key());
1772 }
1773
1774 #[test]
1775 fn an_agent_with_no_command_and_no_preset_is_rejected() {
1776 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1777 let err = parse(text).unwrap_err().to_string();
1778 assert!(err.contains("no command and no preset"), "{err}");
1779 }
1780
1781 #[test]
1782 fn jsonl_without_a_message_path_is_rejected() {
1783 let text =
1784 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1785 let err = parse(text).unwrap_err().to_string();
1786 assert!(err.contains("message_path"), "{err}");
1787 }
1788
1789 #[test]
1794 fn a_check_is_read_as_an_argv_either_way_it_is_written() {
1795 let line = format!("{TWO_AGENTS}\n[loop]\ncheck = \"cargo test --all\"\n");
1796 let cfg = parse(&line).unwrap();
1797 assert!(cfg.loop_cfg.check.is_set());
1798 assert_eq!(vec!["cargo", "test", "--all"], cfg.loop_cfg.check.argv());
1799 assert_eq!("cargo test --all", cfg.loop_cfg.check.describe());
1800
1801 let listed =
1802 format!("{TWO_AGENTS}\n[loop]\ncheck = [\"npm\", \"run\", \"test -- --ci\"]\n");
1803 let cfg = parse(&listed).unwrap();
1804 assert_eq!(
1805 vec!["npm", "run", "test -- --ci"],
1806 cfg.loop_cfg.check.argv(),
1807 "a list is taken as it is, spaces and all"
1808 );
1809
1810 let bare = parse(TWO_AGENTS).unwrap();
1812 assert!(!bare.loop_cfg.check.is_set());
1813 assert!(bare.loop_cfg.check.argv().is_empty());
1814 }
1815
1816 #[test]
1817 fn a_setup_is_read_like_a_check_and_runs_nothing_unset() {
1818 let line = format!("{TWO_AGENTS}\n[loop]\nsetup = \"npm ci --no-audit\"\n");
1819 let cfg = parse(&line).unwrap();
1820 assert_eq!(vec!["npm", "ci", "--no-audit"], cfg.loop_cfg.setup.argv());
1821 assert!(
1822 !cfg.loop_cfg.check.is_set(),
1823 "setting up a worktree is not checking it"
1824 );
1825
1826 let bare = parse(TWO_AGENTS).unwrap();
1827 assert!(!bare.loop_cfg.setup.is_set());
1828 }
1829
1830 #[test]
1831 fn alternating_swaps_the_author_from_issue_to_issue() {
1832 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"alternate\"\n");
1833 let cfg = parse(&text).unwrap();
1834
1835 assert!(cfg.alternate_first);
1836 assert_eq!(
1837 "claude", cfg.first_implementor,
1838 "declaration order still leads"
1839 );
1840 assert_eq!("claude", cfg.first_implementor_for(0));
1841 assert_eq!("codex", cfg.first_implementor_for(1));
1842 assert_eq!("claude", cfg.first_implementor_for(2));
1843 assert!(cfg.first_implementor_policy().contains("alternating"));
1844 }
1845
1846 #[test]
1848 fn a_named_first_implementor_never_swaps() {
1849 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"codex\"\n");
1850 let cfg = parse(&text).unwrap();
1851 assert!(!cfg.alternate_first);
1852 for nth in 0..4 {
1853 assert_eq!("codex", cfg.first_implementor_for(nth));
1854 }
1855 assert_eq!("codex", cfg.first_implementor_policy());
1856 }
1857
1858 #[test]
1859 fn first_implementor_must_name_a_configured_agent() {
1860 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1861 let err = parse(&text).unwrap_err().to_string();
1862 assert!(err.contains("not a configured agent"), "{err}");
1863 }
1864
1865 #[test]
1866 fn defaults_are_the_conservative_ones() {
1867 let cfg = parse(TWO_AGENTS).unwrap();
1868 assert!(
1869 !cfg.loop_cfg.auto_merge,
1870 "auto_merge must be off by default"
1871 );
1872 assert!(cfg.loop_cfg.worktrees);
1873 assert!(
1874 !cfg.loop_cfg.file_nits,
1875 "a filed nit is somebody else's triage queue"
1876 );
1877 assert_eq!(3, cfg.loop_cfg.max_rounds);
1878 assert_eq!(
1879 Followups::Local,
1880 cfg.loop_cfg.followups,
1881 "the tracker is somebody's queue; the default must not write to it"
1882 );
1883 assert!(
1884 !cfg.loop_cfg.file_non_blocking,
1885 "a suggestion is not a tracker item"
1886 );
1887 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1888 assert!(cfg.style.terse);
1889 }
1890
1891 #[test]
1892 fn effort_schedule_splits_round_one_from_the_rest() {
1893 let text =
1894 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1895 let cfg = parse(&text).unwrap();
1896 let spec = cfg.spec("claude").unwrap();
1897 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1898 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1899 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1900 }
1901
1902 #[test]
1909 fn an_agent_can_carry_its_own_schedule_in_its_own_words() {
1910 let text = format!(
1911 "{TWO_AGENTS}\n[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
1912 [loop.effort_schedule]\nreview_1 = \"ultra\"\n"
1913 );
1914 let cfg = parse(&text).unwrap();
1915 assert_eq!(
1916 Some("xhigh".into()),
1917 cfg.effort_for(cfg.spec("claude").unwrap(), Call::Review(1)),
1918 "the agent's own schedule has to win"
1919 );
1920 assert_eq!(
1921 Some("ultra".into()),
1922 cfg.effort_for(cfg.spec("codex").unwrap(), Call::Review(1))
1923 );
1924 }
1925
1926 #[test]
1930 fn each_kind_of_call_reads_its_own_key_and_falls_back_to_the_old_pair() {
1931 let text = format!(
1932 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"low\"\n\
1933 triage = \"minimal\"\n"
1934 );
1935 let cfg = parse(&text).unwrap();
1936 let spec = cfg.spec("claude").unwrap();
1937
1938 assert_eq!(Some("minimal".into()), cfg.effort_for(spec, Call::Triage));
1939 for call in [Call::Implement, Call::Review(1), Call::Screen] {
1941 assert_eq!(Some("ultra".into()), cfg.effort_for(spec, call), "{call:?}");
1942 }
1943 for call in [Call::Review(2), Call::Close, Call::Respond(3)] {
1944 assert_eq!(Some("low".into()), cfg.effort_for(spec, call), "{call:?}");
1945 }
1946 }
1947
1948 #[test]
1949 fn effort_falls_back_to_the_agents_own_setting() {
1950 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1951 let cfg = parse(&text).unwrap();
1952 let spec = cfg.spec("codex").unwrap();
1953 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1954 }
1955
1956 #[test]
1957 fn an_unset_model_and_an_empty_model_normalise_the_same() {
1958 let a = AgentSpec {
1959 name: "a".into(),
1960 command: vec![CommandPart::One("x".into())],
1961 model: None,
1962 effort: None,
1963 effort_schedule: EffortSchedule::default(),
1964 output: OutputMode::Text,
1965 message_match: BTreeMap::new(),
1966 message_path: None,
1967 search_paths: vec![],
1968 system_via: SystemVia::Prompt,
1969 timeout: 60,
1970 fallback: None,
1971 models: vec![],
1972 efforts: vec![],
1973 options_note: None,
1974 };
1975 let b = AgentSpec {
1976 model: Some(" ".into()),
1977 ..a.clone()
1978 };
1979 assert_eq!(a.model_key(), b.model_key());
1980 }
1981
1982 #[test]
1983 fn max_rounds_zero_is_rejected() {
1984 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1985 assert!(parse(&text).is_err());
1986 }
1987
1988 #[test]
1989 fn an_inline_command_needs_no_preset() {
1990 let text = r#"
1991[agents.custom]
1992command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1993output = "text"
1994
1995[agents.other]
1996command = ["othertool", "{prompt}"]
1997"#;
1998 let cfg = parse(text).unwrap();
1999 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
2000 }
2001
2002 #[test]
2003 fn style_budgets_are_configurable() {
2004 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
2005 let cfg = parse(&text).unwrap();
2006 assert!(!cfg.style.terse);
2007 assert_eq!(40, cfg.style.max_detail_chars);
2008 }
2009}