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 Recheck,
339}
340
341impl Call {
342 pub fn key(self) -> &'static str {
344 match self {
345 Call::Triage => "triage",
346 Call::Implement => "implement",
347 Call::Review(round) if round <= 1 => "review_1",
348 Call::Review(_) => "review_rest",
349 Call::Respond(_) => "respond",
350 Call::Close => "close",
351 Call::Screen => "screen",
352 Call::Checkin(_) => "checkin",
353 Call::Split(_) => "split",
354 Call::Brainstorm(_) => "brainstorm",
355 Call::Recheck => "recheck",
356 }
357 }
358
359 fn legacy_round(self) -> u32 {
362 match self {
363 Call::Triage | Call::Implement | Call::Screen => 1,
364 Call::Review(round) | Call::Respond(round) => round,
365 Call::Checkin(round) | Call::Split(round) | Call::Brainstorm(round) => round,
366 Call::Close | Call::Recheck => 2,
367 }
368 }
369
370 pub fn every() -> [Call; 11] {
372 [
373 Call::Triage,
374 Call::Implement,
375 Call::Review(1),
376 Call::Review(2),
377 Call::Recheck,
378 Call::Respond(1),
379 Call::Close,
380 Call::Screen,
381 Call::Checkin(1),
382 Call::Split(1),
383 Call::Brainstorm(1),
384 ]
385 }
386}
387
388#[derive(Debug, Clone, Default, Serialize, Deserialize)]
389#[serde(deny_unknown_fields)]
390pub struct EffortSchedule {
391 pub round_1: Option<String>,
396 pub rest: Option<String>,
399 pub triage: Option<String>,
402 pub implement: Option<String>,
403 pub review_1: Option<String>,
404 pub review_rest: Option<String>,
405 pub respond: Option<String>,
406 pub close: Option<String>,
407 pub screen: Option<String>,
408 pub checkin: Option<String>,
409 pub split: Option<String>,
410 pub brainstorm: Option<String>,
414 pub recheck: Option<String>,
418}
419
420fn set(value: &Option<String>) -> Option<String> {
422 value.clone().filter(|s| !s.trim().is_empty())
423}
424
425impl EffortSchedule {
426 pub fn get(&self, call: Call) -> Option<String> {
428 let value = match call.key() {
429 "triage" => &self.triage,
430 "implement" => &self.implement,
431 "review_1" => &self.review_1,
432 "review_rest" => &self.review_rest,
433 "respond" => &self.respond,
434 "close" => &self.close,
435 "screen" => &self.screen,
436 "checkin" => &self.checkin,
437 "brainstorm" => &self.brainstorm,
438 "recheck" => {
439 return set(&self.recheck).or_else(|| set(&self.review_rest));
440 }
441 _ => &self.split,
442 };
443 set(value)
444 }
445
446 fn legacy(&self, call: Call) -> Option<String> {
448 let value = if call.legacy_round() <= 1 {
449 &self.round_1
450 } else {
451 &self.rest
452 };
453 value.clone().filter(|s| !s.trim().is_empty())
454 }
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(untagged)]
465pub enum CheckCommand {
466 Line(String),
467 Argv(Vec<String>),
468}
469
470impl Default for CheckCommand {
471 fn default() -> Self {
474 CheckCommand::Argv(Vec::new())
475 }
476}
477
478impl CheckCommand {
479 pub fn argv(&self) -> Vec<String> {
480 match self {
481 CheckCommand::Line(line) => line.split_whitespace().map(str::to_string).collect(),
482 CheckCommand::Argv(argv) => argv
483 .iter()
484 .map(|a| a.trim().to_string())
485 .filter(|a| !a.is_empty())
486 .collect(),
487 }
488 }
489
490 pub fn is_set(&self) -> bool {
491 !self.argv().is_empty()
492 }
493
494 pub fn describe(&self) -> String {
496 self.argv().join(" ")
497 }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(default, deny_unknown_fields)]
505pub struct LoopCfg {
506 pub max_rounds: u32,
509 pub auto_merge: bool,
510 pub first_implementor: Option<String>,
511 pub base_branch: String,
512 pub worktrees: bool,
513 pub keep_worktrees: bool,
514 pub state_store: StateStore,
515 pub branch_prefix: String,
516 pub followups: Followups,
517 pub file_non_blocking: bool,
525 pub max_followups: usize,
528 pub max_split_parts: usize,
536 pub file_nits: bool,
540 pub close_skipped: bool,
543 pub parallel_triage: bool,
546 pub min_number: i64,
554 pub absorb_new_issues: u32,
561 pub decompose_trackers: bool,
573 pub max_tracker_children: usize,
576 pub drafts: Drafts,
578 pub fork_when_denied: bool,
586 pub instructions: String,
598 pub max_issue_chars: usize,
607 pub max_triage_chars: usize,
616 pub checkin_trust: Trust,
624 pub checkin_resolve: bool,
629 pub max_checkin_comments: usize,
634 pub check: CheckCommand,
644 pub effort_schedule: EffortSchedule,
645}
646
647impl Default for LoopCfg {
648 fn default() -> Self {
649 Self {
650 max_rounds: 3,
651 auto_merge: false,
652 first_implementor: None,
653 base_branch: "main".into(),
654 worktrees: true,
655 keep_worktrees: false,
656 state_store: StateStore::Local,
657 branch_prefix: String::new(),
658 followups: Followups::Local,
659 file_non_blocking: false,
660 max_followups: 5,
661 max_split_parts: 4,
662 file_nits: false,
663 close_skipped: true,
664 parallel_triage: true,
665 min_number: 0,
666 absorb_new_issues: 0,
667 decompose_trackers: false,
668 max_tracker_children: 5,
669 drafts: Drafts::Never,
670 fork_when_denied: true,
671 instructions: String::new(),
672 max_issue_chars: 60_000,
673 max_triage_chars: 200_000,
674 checkin_trust: Trust::Write,
675 checkin_resolve: true,
676 max_checkin_comments: 20,
677 check: CheckCommand::default(),
678 effort_schedule: EffortSchedule::default(),
679 }
680 }
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize)]
684#[serde(default, deny_unknown_fields)]
685pub struct StyleCfg {
686 pub ban_em_dash: bool,
687 pub ban_ai_attribution: bool,
688 pub terse: bool,
689 pub max_detail_chars: usize,
690 pub max_summary_chars: usize,
691 pub max_body_chars: usize,
692 pub max_issue_body_chars: usize,
696 pub max_title_chars: usize,
697 pub pr_comments: PrComments,
698}
699
700impl Default for StyleCfg {
701 fn default() -> Self {
711 let style = Style::default();
712 Self {
713 ban_em_dash: style.ban_em_dash,
714 ban_ai_attribution: style.ban_ai_attribution,
715 terse: style.terse,
716 max_detail_chars: style.max_detail_chars,
717 max_summary_chars: style.max_summary_chars,
718 max_body_chars: style.max_body_chars,
719 max_issue_body_chars: style.max_issue_body_chars,
720 max_title_chars: style.max_title_chars,
721 pr_comments: style.pr_comments,
722 }
723 }
724}
725
726impl StyleCfg {
727 pub fn to_style(&self) -> Style {
728 Style {
729 ban_em_dash: self.ban_em_dash,
730 ban_ai_attribution: self.ban_ai_attribution,
731 terse: self.terse,
732 max_detail_chars: self.max_detail_chars,
733 max_summary_chars: self.max_summary_chars,
734 max_body_chars: self.max_body_chars,
735 max_issue_body_chars: self.max_issue_body_chars,
736 max_title_chars: self.max_title_chars,
737 pr_comments: self.pr_comments,
738 }
739 }
740}
741
742#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
748#[serde(rename_all = "lowercase")]
749pub enum ReviewOrIgnore {
750 Review,
752 Ignore,
754}
755
756impl std::fmt::Display for ReviewOrIgnore {
757 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758 f.write_str(match self {
759 ReviewOrIgnore::Review => "review",
760 ReviewOrIgnore::Ignore => "ignore",
761 })
762 }
763}
764
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
769#[serde(rename_all = "lowercase")]
770pub enum OwnPr {
771 Checkin,
774 Reply,
776 Ignore,
778}
779
780impl std::fmt::Display for OwnPr {
781 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
782 f.write_str(match self {
783 OwnPr::Checkin => "checkin",
784 OwnPr::Reply => "reply",
785 OwnPr::Ignore => "ignore",
786 })
787 }
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize)]
794#[serde(default, deny_unknown_fields)]
795pub struct ListenCfg {
796 pub interval: u64,
799 pub sweep: u64,
803 pub settle: u64,
807 pub max_wait: u64,
809 pub max_rechecks: u32,
813 pub on_review_requested: ReviewOrIgnore,
814 pub on_assigned: ReviewOrIgnore,
815 pub on_own_pr: OwnPr,
816 pub verdicts: bool,
819}
820
821impl Default for ListenCfg {
822 fn default() -> Self {
823 Self {
824 interval: 60,
825 sweep: 300,
826 settle: 120,
827 max_wait: 900,
828 max_rechecks: 5,
829 on_review_requested: ReviewOrIgnore::Review,
830 on_assigned: ReviewOrIgnore::Review,
831 on_own_pr: OwnPr::Checkin,
832 verdicts: true,
833 }
834 }
835}
836
837#[derive(Debug, Clone)]
842pub struct Config {
843 pub agents: Vec<AgentSpec>,
845 pub loop_cfg: LoopCfg,
846 pub style: Style,
847 pub listen: ListenCfg,
848 pub first_implementor: String,
851 pub alternate_first: bool,
853 pub source: Option<PathBuf>,
855}
856
857impl Config {
858 pub fn agent_names(&self) -> Vec<String> {
859 self.agents.iter().map(|a| a.name.clone()).collect()
860 }
861
862 pub fn has_agent(&self, name: &str) -> bool {
863 self.agents.iter().any(|a| a.name == name)
864 }
865
866 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
867 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
868 spar_err!(
869 "no agent named '{name}' ({})",
870 self.agent_names().join(", ")
871 )
872 })
873 }
874
875 pub fn other(&self, name: &str) -> String {
878 let names = self.agent_names();
879 if names.first().map(String::as_str) == Some(name) {
880 names.get(1).cloned().unwrap_or_else(|| name.to_string())
881 } else {
882 names.first().cloned().unwrap_or_else(|| name.to_string())
883 }
884 }
885
886 pub fn effort_for(&self, spec: &AgentSpec, call: Call) -> Option<String> {
894 spec.effort_schedule
895 .get(call)
896 .or_else(|| self.loop_cfg.effort_schedule.get(call))
897 .or_else(|| self.loop_cfg.effort_schedule.legacy(call))
898 .or_else(|| spec.effort.clone())
899 }
900
901 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
904 self.effort_for(spec, Call::Review(round))
905 }
906
907 pub fn first_implementor_for(&self, nth: usize) -> String {
912 if !self.alternate_first || nth % 2 == 0 {
913 return self.first_implementor.clone();
914 }
915 self.other(&self.first_implementor)
916 }
917
918 pub fn first_implementor_policy(&self) -> String {
920 if self.alternate_first {
921 format!("alternating, {} first", self.first_implementor)
922 } else {
923 self.first_implementor.clone()
924 }
925 }
926
927 pub fn base_branch(&self) -> &str {
928 &self.loop_cfg.base_branch
929 }
930}
931
932#[derive(Debug, Deserialize)]
933#[serde(deny_unknown_fields)]
934struct RawConfig {
935 #[serde(default)]
936 agents: toml::Table,
937 #[serde(default)]
938 #[serde(rename = "loop")]
939 loop_cfg: Option<LoopCfg>,
940 #[serde(default)]
941 style: Option<StyleCfg>,
942 #[serde(default)]
943 listen: Option<ListenCfg>,
944}
945
946pub fn preset_dirs() -> Vec<PathBuf> {
959 let mut dirs = Vec::new();
960 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
961 dirs.push(PathBuf::from(custom));
962 }
963 dirs.push(PathBuf::from(".spar").join("presets"));
964 if let Some(home) = home_dir() {
965 dirs.push(home.join(".config").join("spar").join("presets"));
966 }
967 dirs
968}
969
970pub fn available_presets() -> Vec<String> {
972 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
973 for dir in preset_dirs() {
974 if let Ok(entries) = std::fs::read_dir(&dir) {
975 for entry in entries.flatten() {
976 let path = entry.path();
977 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
978 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
979 names.push(stem.to_string());
980 }
981 }
982 }
983 }
984 }
985 names.sort();
986 names.dedup();
987 names
988}
989
990fn parse_document(text: &str, what: &str) -> Result<Value> {
995 let table: toml::Table =
996 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
997 Ok(Value::Table(table))
998}
999
1000pub fn load_preset(name: &str) -> Result<Value> {
1003 for dir in preset_dirs() {
1004 let path = dir.join(format!("{name}.toml"));
1005 if path.is_file() {
1006 let text = std::fs::read_to_string(&path)
1007 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
1008 return parse_document(&text, &format!("preset {}", path.display()));
1009 }
1010 }
1011 for (builtin, text) in BUILTIN_PRESETS {
1012 if *builtin == name {
1013 return parse_document(text, &format!("built in preset {name}"));
1014 }
1015 }
1016 Err(spar_err!(
1017 "unknown preset '{name}'. Available: {}",
1018 available_presets().join(", ")
1019 ))
1020}
1021
1022fn merge(base: &Value, over: &Value) -> Value {
1025 match (base, over) {
1026 (Value::Table(b), Value::Table(o)) => {
1027 let mut out = b.clone();
1028 for (key, value) in o {
1029 let merged = match out.get(key) {
1030 Some(existing) => merge(existing, value),
1031 None => value.clone(),
1032 };
1033 out.insert(key.clone(), merged);
1034 }
1035 Value::Table(out)
1036 }
1037 _ => over.clone(),
1038 }
1039}
1040
1041fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
1042 let table = raw
1043 .as_table()
1044 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1045
1046 let merged = match table.get("preset").and_then(Value::as_str) {
1047 Some(preset) => merge(&load_preset(preset)?, raw),
1048 None => raw.clone(),
1049 };
1050
1051 let mut merged_table = merged
1052 .as_table()
1053 .cloned()
1054 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1055 merged_table.remove("preset");
1056 let fallback_raw = merged_table.remove("fallback");
1059
1060 if !merged_table.contains_key("command") {
1061 bail!(
1062 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
1063 available_presets().join(", ")
1064 );
1065 }
1066
1067 let mut spec: AgentSpec = Value::Table(merged_table)
1068 .try_into()
1069 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
1070 spec.name = name.to_string();
1071
1072 if spec.command.is_empty() {
1073 bail!("agent '{name}' has an empty command");
1074 }
1075 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
1076 bail!("agent '{name}': the first command element must be the program name, not a group");
1077 }
1078 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
1079 bail!(
1080 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
1081 );
1082 }
1083
1084 if let Some(raw) = fallback_raw {
1085 if !raw.is_table() {
1086 bail!(
1087 "agent '{name}': fallback is a whole agent, so write it as a table:\n [agents.{name}.fallback]\n preset = \"cursor\""
1088 );
1089 }
1090 let backup = build_spec(&format!("{name}-fallback"), &raw)?;
1093 if backup.fallback.is_some() {
1094 bail!(
1095 "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
1096 another full timeout on a call that has already failed once."
1097 );
1098 }
1099 spec.fallback = Some(Box::new(backup));
1100 }
1101
1102 Ok(spec)
1103}
1104
1105#[derive(Debug, Clone)]
1111pub struct OptionInfo {
1112 pub section: &'static str,
1113 pub key: String,
1114 pub default: String,
1115}
1116
1117pub fn known_options() -> Vec<OptionInfo> {
1123 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
1124 toml::to_string(value)
1125 .unwrap_or_default()
1126 .lines()
1127 .filter_map(|line| line.split_once(" = "))
1128 .map(|(key, default)| OptionInfo {
1129 section,
1130 key: key.trim().to_string(),
1131 default: default.trim().to_string(),
1132 })
1133 .collect()
1134 }
1135 let mut out = lines("loop", &LoopCfg::default());
1136 out.extend(lines("style", &StyleCfg::default()));
1137 out.extend(lines(
1138 "loop.effort_schedule",
1139 &EffortSchedule {
1140 round_1: Some("high".into()),
1141 rest: Some("low".into()),
1142 triage: Some("low".into()),
1143 implement: Some("high".into()),
1144 review_1: Some("high".into()),
1145 review_rest: Some("low".into()),
1146 respond: Some("high".into()),
1147 close: Some("low".into()),
1148 screen: Some("low".into()),
1149 checkin: Some("high".into()),
1150 split: Some("high".into()),
1151 brainstorm: Some("high".into()),
1152 recheck: Some("low".into()),
1153 },
1154 ));
1155 out.extend(lines("listen", &ListenCfg::default()));
1156 out
1157}
1158
1159pub fn mentions(config_text: &str, key: &str) -> bool {
1161 config_text.lines().any(|line| {
1162 let bare = line.trim_start().trim_start_matches('#').trim_start();
1163 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1164 })
1165}
1166
1167pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
1170 known_options()
1171 .into_iter()
1172 .filter(|o| !mentions(config_text, &o.key))
1173 .collect()
1174}
1175
1176pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
1177
1178pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1181 if let Some(path) = explicit {
1182 if !path.is_file() {
1183 bail!("config not found: {}", path.display());
1184 }
1185 return Ok(Some(path.to_path_buf()));
1186 }
1187 for name in CONFIG_NAMES {
1188 let path = PathBuf::from(name);
1189 if path.is_file() {
1190 return Ok(Some(path));
1191 }
1192 }
1193 if let Some(home) = home_dir() {
1194 let path = home.join(".config").join("spar").join("spar.toml");
1195 if path.is_file() {
1196 return Ok(Some(path));
1197 }
1198 }
1199 Ok(None)
1200}
1201
1202pub fn load(explicit: Option<&Path>) -> Result<Config> {
1203 let Some(path) = find_config(explicit)? else {
1204 bail!(
1205 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
1206 );
1207 };
1208 let text = std::fs::read_to_string(&path)
1209 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1210 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
1211 cfg.source = Some(path);
1212 Ok(cfg)
1213}
1214
1215pub fn parse(text: &str) -> Result<Config> {
1216 let raw: RawConfig = toml::from_str(text)?;
1217
1218 if raw.agents.len() != 2 {
1219 bail!(
1220 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
1221 raw.agents.len()
1222 );
1223 }
1224
1225 let mut agents = Vec::new();
1226 for (name, value) in raw.agents.iter() {
1227 agents.push(build_spec(name, value)?);
1228 }
1229
1230 let loop_cfg = raw.loop_cfg.unwrap_or_default();
1231 let style = raw.style.unwrap_or_default().to_style();
1232 let listen = raw.listen.unwrap_or_default();
1233
1234 if loop_cfg.max_rounds == 0 {
1235 bail!("max_rounds must be at least 1");
1236 }
1237 if listen.settle > listen.max_wait {
1238 bail!(
1239 "[listen] settle ({}) cannot be longer than max_wait ({}): an item that kept \
1240 changing would never be acted on. Raise max_wait or lower settle.",
1241 listen.settle,
1242 listen.max_wait
1243 );
1244 }
1245 if listen.interval == 0 || listen.sweep == 0 {
1246 bail!(
1247 "[listen] interval and sweep are seconds between polls and must be at least 1. \
1248 Polling never goes below 15 seconds whatever they say."
1249 );
1250 }
1251 if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
1256 bail!(
1257 "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
1258 ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
1259 to have it promoted when the review converges, or turn auto_merge off."
1260 );
1261 }
1262
1263 let alternate = loop_cfg
1270 .first_implementor
1271 .as_deref()
1272 .is_some_and(|name| name.trim().eq_ignore_ascii_case("alternate"));
1273 let first = match &loop_cfg.first_implementor {
1274 Some(name) if !name.trim().is_empty() && !alternate => name.trim().to_string(),
1275 _ => agents[0].name.clone(),
1276 };
1277 if !agents.iter().any(|a| a.name == first) {
1278 bail!(
1279 "first_implementor '{first}' is not a configured agent ({})",
1280 agents
1281 .iter()
1282 .map(|a| a.name.as_str())
1283 .collect::<Vec<_>>()
1284 .join(", ")
1285 );
1286 }
1287
1288 Ok(Config {
1289 agents,
1290 loop_cfg,
1291 style,
1292 listen,
1293 first_implementor: first,
1294 alternate_first: alternate,
1295 source: None,
1296 })
1297}
1298
1299pub fn resolve_search_path(raw: &str) -> PathBuf {
1301 expand_tilde(raw)
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306 use super::*;
1307
1308 const TWO_AGENTS: &str = r#"
1309[agents.claude]
1310preset = "claude"
1311model = "fable"
1312
1313[agents.codex]
1314preset = "codex"
1315model = "gpt-5.6-sol"
1316"#;
1317
1318 #[test]
1321 fn a_fallback_is_a_whole_agent_with_its_own_preset() {
1322 let text = format!(
1323 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
1324 );
1325 let cfg = parse(&text).expect("parses");
1326 assert_eq!(2, cfg.agents.len());
1328 let codex = cfg.spec("codex").expect("codex");
1329 let backup = codex.fallback.as_ref().expect("fallback");
1330 assert_eq!("codex-fallback", backup.name);
1331 assert_eq!(Some("kimi-k3"), backup.model.as_deref());
1332 assert_eq!(
1333 Some(&CommandPart::One("cursor-agent".into())),
1334 backup.command.first()
1335 );
1336 }
1337
1338 #[test]
1339 fn the_agent_without_a_fallback_does_not_grow_one() {
1340 let cfg = parse(TWO_AGENTS).expect("parses");
1341 assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
1342 }
1343
1344 #[test]
1345 fn a_fallback_may_not_have_one_of_its_own() {
1346 let text = format!(
1347 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
1348 [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
1349 );
1350 let err = parse(&text).expect_err("rejected");
1351 assert!(err.message().contains("may not have a fallback"), "{err}");
1352 }
1353
1354 #[test]
1355 fn a_fallback_written_as_a_string_says_what_it_should_be() {
1356 let text = "[agents.claude]\npreset = \"claude\"\n\n\
1357 [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
1358 let err = parse(text).expect_err("rejected");
1359 assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
1360 }
1361
1362 #[test]
1367 fn a_partial_block_keeps_the_defaults_it_did_not_name() {
1368 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
1369 let cfg = parse(&text).expect("parses");
1370
1371 assert_eq!(9, cfg.loop_cfg.max_rounds);
1372 assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
1373 assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
1374
1375 assert!(!cfg.style.terse);
1376 assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
1377 assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
1378 }
1379
1380 #[test]
1384 fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
1385 assert_eq!(Style::default(), StyleCfg::default().to_style());
1386 }
1387
1388 #[test]
1391 fn pull_requests_are_not_drafts_unless_asked_for() {
1392 assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1393 }
1394
1395 #[test]
1396 fn each_draft_setting_parses() {
1397 for (text, want) in [
1398 ("never", Drafts::Never),
1399 ("until_approved", Drafts::UntilApproved),
1400 ("always", Drafts::Always),
1401 ] {
1402 let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1403 .unwrap_or_else(|e| panic!("{text}: {e}"));
1404 assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1405 }
1406 }
1407
1408 #[test]
1412 fn auto_merge_and_a_permanent_draft_are_refused_together() {
1413 let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1414 let err = parse(&text).expect_err("refused");
1415 assert!(err.message().contains("auto_merge"), "{err}");
1416 assert!(
1417 err.message().contains("until_approved"),
1418 "says the way out: {err}"
1419 );
1420 }
1421
1422 #[test]
1425 fn auto_merge_is_fine_with_a_draft_that_clears() {
1426 let text =
1427 format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1428 assert!(parse(&text).is_ok());
1429 }
1430
1431 #[test]
1432 fn every_builtin_preset_parses() {
1433 for (name, _) in BUILTIN_PRESETS {
1434 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1435 assert!(value.get("command").is_some(), "{name} has no command");
1436 }
1437 }
1438
1439 #[test]
1440 fn every_builtin_preset_builds_a_spec() {
1441 for (name, _) in BUILTIN_PRESETS {
1442 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1443 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1444 }
1445 }
1446
1447 #[test]
1451 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1452 let spec = build_spec(
1453 "claude",
1454 &parse_document("preset = \"claude\"", "test").unwrap(),
1455 )
1456 .unwrap();
1457 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1458 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1459 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1460 }
1461
1462 #[test]
1463 fn codex_preset_declares_where_its_answer_lives() {
1464 let spec = build_spec(
1465 "codex",
1466 &parse_document("preset = \"codex\"", "test").unwrap(),
1467 )
1468 .unwrap();
1469 assert_eq!(OutputMode::Jsonl, spec.output);
1470 assert_eq!(Some("item.text"), spec.message_path.as_deref());
1471 assert!(!spec.message_match.is_empty());
1472 }
1473
1474 #[test]
1478 fn codex_preset_looks_where_either_chatgpt_layout_keeps_the_cli() {
1479 let spec = build_spec(
1480 "codex",
1481 &parse_document("preset = \"codex\"", "test").unwrap(),
1482 )
1483 .unwrap();
1484 let at = |path: &str| spec.search_paths.iter().position(|p| p == path);
1485 let current = at("/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin");
1486 let older = at("/Applications/ChatGPT.app/Contents/Resources");
1487 assert!(current.is_some(), "{:?}", spec.search_paths);
1488 assert!(older.is_some(), "{:?}", spec.search_paths);
1489 assert!(current < older, "{:?}", spec.search_paths);
1490 }
1491
1492 #[test]
1493 fn agent_order_follows_declaration_order() {
1494 let cfg = parse(TWO_AGENTS).unwrap();
1495 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1496 assert_eq!("claude", cfg.first_implementor);
1497 }
1498
1499 #[test]
1500 fn other_alternates() {
1501 let cfg = parse(TWO_AGENTS).unwrap();
1502 assert_eq!("codex", cfg.other("claude"));
1503 assert_eq!("claude", cfg.other("codex"));
1504 }
1505
1506 #[test]
1507 fn a_config_block_overrides_one_preset_field() {
1508 let cfg = parse(TWO_AGENTS).unwrap();
1509 let claude = cfg.spec("claude").unwrap();
1510 assert_eq!(Some("fable"), claude.model.as_deref());
1511 assert!(claude.command.len() > 1, "the preset command survived");
1512 }
1513
1514 #[test]
1515 fn exactly_two_agents_are_required() {
1516 let one = "[agents.claude]\npreset = \"claude\"\n";
1517 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1518 }
1519
1520 #[test]
1521 fn an_unknown_agent_option_is_named() {
1522 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1523 let err = parse(text).unwrap_err().to_string();
1524 assert!(err.contains("widget"), "{err}");
1525 }
1526
1527 #[test]
1528 fn an_unknown_loop_option_is_named() {
1529 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1530 let err = parse(&text).unwrap_err().to_string();
1531 assert!(err.contains("max_round"), "{err}");
1532 }
1533
1534 #[test]
1535 fn an_unknown_listen_option_is_named() {
1536 let text = format!("{TWO_AGENTS}\n[listen]\ninterval_secs = 30\n");
1537 let err = parse(&text).unwrap_err().to_string();
1538 assert!(err.contains("interval_secs"), "{err}");
1539 }
1540
1541 #[test]
1542 fn a_listen_block_keeps_the_defaults_it_did_not_name() {
1543 let text = format!("{TWO_AGENTS}\n[listen]\nsweep = 30\n");
1544 let cfg = parse(&text).expect("parses");
1545 assert_eq!(30, cfg.listen.sweep);
1546 assert_eq!(ListenCfg::default().interval, cfg.listen.interval);
1547 assert_eq!(ListenCfg::default().settle, cfg.listen.settle);
1548 assert_eq!(OwnPr::Checkin, cfg.listen.on_own_pr);
1549 assert!(cfg.listen.verdicts);
1550
1551 let bare = parse(TWO_AGENTS).unwrap();
1552 assert_eq!(60, bare.listen.interval);
1553 assert_eq!(300, bare.listen.sweep);
1554 assert_eq!(120, bare.listen.settle);
1555 assert_eq!(900, bare.listen.max_wait);
1556 assert_eq!(5, bare.listen.max_rechecks);
1557 assert_eq!(ReviewOrIgnore::Review, bare.listen.on_review_requested);
1558 assert_eq!(ReviewOrIgnore::Review, bare.listen.on_assigned);
1559 }
1560
1561 #[test]
1562 fn settle_longer_than_max_wait_is_refused_with_a_reason() {
1563 let text = format!("{TWO_AGENTS}\n[listen]\nsettle = 1000\nmax_wait = 900\n");
1564 let err = parse(&text).unwrap_err().to_string();
1565 assert!(err.contains("settle"), "{err}");
1566 assert!(err.contains("Raise max_wait"), "{err}");
1567
1568 let text = format!("{TWO_AGENTS}\n[listen]\ninterval = 0\n");
1569 let err = parse(&text).unwrap_err().to_string();
1570 assert!(err.contains("at least 1"), "{err}");
1571 }
1572
1573 #[test]
1574 fn each_listen_action_word_parses() {
1575 for (text, want) in [
1576 ("review", ReviewOrIgnore::Review),
1577 ("ignore", ReviewOrIgnore::Ignore),
1578 ] {
1579 let cfg = parse(&format!(
1580 "{TWO_AGENTS}\n[listen]\non_review_requested = \"{text}\"\non_assigned = \"{text}\"\n"
1581 ))
1582 .unwrap();
1583 assert_eq!(want, cfg.listen.on_review_requested);
1584 assert_eq!(want, cfg.listen.on_assigned);
1585 assert_eq!(text, want.to_string());
1586 }
1587 for (text, want) in [
1588 ("checkin", OwnPr::Checkin),
1589 ("reply", OwnPr::Reply),
1590 ("ignore", OwnPr::Ignore),
1591 ] {
1592 let cfg = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"{text}\"\n")).unwrap();
1593 assert_eq!(want, cfg.listen.on_own_pr);
1594 assert_eq!(text, want.to_string());
1595 }
1596 let err = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"push\"\n"))
1597 .unwrap_err()
1598 .to_string();
1599 assert!(err.contains("on_own_pr") || err.contains("push"), "{err}");
1600 }
1601
1602 #[test]
1603 fn a_recheck_reads_its_own_key_before_review_rest_and_falls_back_to_it() {
1604 let text = format!(
1605 "{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\nrecheck = \"high\"\n"
1606 );
1607 let cfg = parse(&text).unwrap();
1608 let spec = cfg.spec("claude").unwrap();
1609 assert_eq!(
1610 Some("high".to_string()),
1611 cfg.effort_for(spec, Call::Recheck)
1612 );
1613
1614 let text = format!("{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\n");
1615 let cfg = parse(&text).unwrap();
1616 let spec = cfg.spec("claude").unwrap();
1617 assert_eq!(Some("low".to_string()), cfg.effort_for(spec, Call::Recheck));
1618
1619 let text = format!(
1620 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"medium\"\n"
1621 );
1622 let cfg = parse(&text).unwrap();
1623 let spec = cfg.spec("claude").unwrap();
1624 assert_eq!(
1625 Some("medium".to_string()),
1626 cfg.effort_for(spec, Call::Recheck)
1627 );
1628 }
1629
1630 #[test]
1631 fn every_call_kind_is_in_every() {
1632 let every = Call::every();
1633 assert_eq!(11, every.len());
1634 assert!(every.contains(&Call::Recheck));
1635 assert_eq!("recheck", Call::Recheck.key());
1636 }
1637
1638 #[test]
1639 fn an_agent_with_no_command_and_no_preset_is_rejected() {
1640 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1641 let err = parse(text).unwrap_err().to_string();
1642 assert!(err.contains("no command and no preset"), "{err}");
1643 }
1644
1645 #[test]
1646 fn jsonl_without_a_message_path_is_rejected() {
1647 let text =
1648 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1649 let err = parse(text).unwrap_err().to_string();
1650 assert!(err.contains("message_path"), "{err}");
1651 }
1652
1653 #[test]
1658 fn a_check_is_read_as_an_argv_either_way_it_is_written() {
1659 let line = format!("{TWO_AGENTS}\n[loop]\ncheck = \"cargo test --all\"\n");
1660 let cfg = parse(&line).unwrap();
1661 assert!(cfg.loop_cfg.check.is_set());
1662 assert_eq!(vec!["cargo", "test", "--all"], cfg.loop_cfg.check.argv());
1663 assert_eq!("cargo test --all", cfg.loop_cfg.check.describe());
1664
1665 let listed =
1666 format!("{TWO_AGENTS}\n[loop]\ncheck = [\"npm\", \"run\", \"test -- --ci\"]\n");
1667 let cfg = parse(&listed).unwrap();
1668 assert_eq!(
1669 vec!["npm", "run", "test -- --ci"],
1670 cfg.loop_cfg.check.argv(),
1671 "a list is taken as it is, spaces and all"
1672 );
1673
1674 let bare = parse(TWO_AGENTS).unwrap();
1676 assert!(!bare.loop_cfg.check.is_set());
1677 assert!(bare.loop_cfg.check.argv().is_empty());
1678 }
1679
1680 #[test]
1681 fn alternating_swaps_the_author_from_issue_to_issue() {
1682 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"alternate\"\n");
1683 let cfg = parse(&text).unwrap();
1684
1685 assert!(cfg.alternate_first);
1686 assert_eq!(
1687 "claude", cfg.first_implementor,
1688 "declaration order still leads"
1689 );
1690 assert_eq!("claude", cfg.first_implementor_for(0));
1691 assert_eq!("codex", cfg.first_implementor_for(1));
1692 assert_eq!("claude", cfg.first_implementor_for(2));
1693 assert!(cfg.first_implementor_policy().contains("alternating"));
1694 }
1695
1696 #[test]
1698 fn a_named_first_implementor_never_swaps() {
1699 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"codex\"\n");
1700 let cfg = parse(&text).unwrap();
1701 assert!(!cfg.alternate_first);
1702 for nth in 0..4 {
1703 assert_eq!("codex", cfg.first_implementor_for(nth));
1704 }
1705 assert_eq!("codex", cfg.first_implementor_policy());
1706 }
1707
1708 #[test]
1709 fn first_implementor_must_name_a_configured_agent() {
1710 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1711 let err = parse(&text).unwrap_err().to_string();
1712 assert!(err.contains("not a configured agent"), "{err}");
1713 }
1714
1715 #[test]
1716 fn defaults_are_the_conservative_ones() {
1717 let cfg = parse(TWO_AGENTS).unwrap();
1718 assert!(
1719 !cfg.loop_cfg.auto_merge,
1720 "auto_merge must be off by default"
1721 );
1722 assert!(cfg.loop_cfg.worktrees);
1723 assert!(
1724 !cfg.loop_cfg.file_nits,
1725 "a filed nit is somebody else's triage queue"
1726 );
1727 assert_eq!(3, cfg.loop_cfg.max_rounds);
1728 assert_eq!(
1729 Followups::Local,
1730 cfg.loop_cfg.followups,
1731 "the tracker is somebody's queue; the default must not write to it"
1732 );
1733 assert!(
1734 !cfg.loop_cfg.file_non_blocking,
1735 "a suggestion is not a tracker item"
1736 );
1737 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1738 assert!(cfg.style.terse);
1739 }
1740
1741 #[test]
1742 fn effort_schedule_splits_round_one_from_the_rest() {
1743 let text =
1744 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1745 let cfg = parse(&text).unwrap();
1746 let spec = cfg.spec("claude").unwrap();
1747 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1748 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1749 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1750 }
1751
1752 #[test]
1759 fn an_agent_can_carry_its_own_schedule_in_its_own_words() {
1760 let text = format!(
1761 "{TWO_AGENTS}\n[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
1762 [loop.effort_schedule]\nreview_1 = \"ultra\"\n"
1763 );
1764 let cfg = parse(&text).unwrap();
1765 assert_eq!(
1766 Some("xhigh".into()),
1767 cfg.effort_for(cfg.spec("claude").unwrap(), Call::Review(1)),
1768 "the agent's own schedule has to win"
1769 );
1770 assert_eq!(
1771 Some("ultra".into()),
1772 cfg.effort_for(cfg.spec("codex").unwrap(), Call::Review(1))
1773 );
1774 }
1775
1776 #[test]
1780 fn each_kind_of_call_reads_its_own_key_and_falls_back_to_the_old_pair() {
1781 let text = format!(
1782 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"low\"\n\
1783 triage = \"minimal\"\n"
1784 );
1785 let cfg = parse(&text).unwrap();
1786 let spec = cfg.spec("claude").unwrap();
1787
1788 assert_eq!(Some("minimal".into()), cfg.effort_for(spec, Call::Triage));
1789 for call in [Call::Implement, Call::Review(1), Call::Screen] {
1791 assert_eq!(Some("ultra".into()), cfg.effort_for(spec, call), "{call:?}");
1792 }
1793 for call in [Call::Review(2), Call::Close, Call::Respond(3)] {
1794 assert_eq!(Some("low".into()), cfg.effort_for(spec, call), "{call:?}");
1795 }
1796 }
1797
1798 #[test]
1799 fn effort_falls_back_to_the_agents_own_setting() {
1800 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1801 let cfg = parse(&text).unwrap();
1802 let spec = cfg.spec("codex").unwrap();
1803 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1804 }
1805
1806 #[test]
1807 fn an_unset_model_and_an_empty_model_normalise_the_same() {
1808 let a = AgentSpec {
1809 name: "a".into(),
1810 command: vec![CommandPart::One("x".into())],
1811 model: None,
1812 effort: None,
1813 effort_schedule: EffortSchedule::default(),
1814 output: OutputMode::Text,
1815 message_match: BTreeMap::new(),
1816 message_path: None,
1817 search_paths: vec![],
1818 system_via: SystemVia::Prompt,
1819 timeout: 60,
1820 fallback: None,
1821 models: vec![],
1822 efforts: vec![],
1823 options_note: None,
1824 };
1825 let b = AgentSpec {
1826 model: Some(" ".into()),
1827 ..a.clone()
1828 };
1829 assert_eq!(a.model_key(), b.model_key());
1830 }
1831
1832 #[test]
1833 fn max_rounds_zero_is_rejected() {
1834 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1835 assert!(parse(&text).is_err());
1836 }
1837
1838 #[test]
1839 fn an_inline_command_needs_no_preset() {
1840 let text = r#"
1841[agents.custom]
1842command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1843output = "text"
1844
1845[agents.other]
1846command = ["othertool", "{prompt}"]
1847"#;
1848 let cfg = parse(text).unwrap();
1849 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1850 }
1851
1852 #[test]
1853 fn style_budgets_are_configurable() {
1854 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1855 let cfg = parse(&text).unwrap();
1856 assert!(!cfg.style.terse);
1857 assert_eq!(40, cfg.style.max_detail_chars);
1858 }
1859}