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}
342
343impl Call {
344 pub fn key(self) -> &'static str {
346 match self {
347 Call::Code => "code",
348 Call::Triage => "triage",
349 Call::Implement => "implement",
350 Call::Review(round) if round <= 1 => "review_1",
351 Call::Review(_) => "review_rest",
352 Call::Respond(_) => "respond",
353 Call::Close => "close",
354 Call::Screen => "screen",
355 Call::Checkin(_) => "checkin",
356 Call::Split(_) => "split",
357 Call::Brainstorm(_) => "brainstorm",
358 Call::Recheck => "recheck",
359 }
360 }
361
362 fn legacy_round(self) -> u32 {
365 match self {
366 Call::Code | Call::Triage | Call::Implement | Call::Screen => 1,
367 Call::Review(round) | Call::Respond(round) => round,
368 Call::Checkin(round) | Call::Split(round) | Call::Brainstorm(round) => round,
369 Call::Close | Call::Recheck => 2,
370 }
371 }
372
373 pub fn every() -> [Call; 12] {
375 [
376 Call::Code,
377 Call::Triage,
378 Call::Implement,
379 Call::Review(1),
380 Call::Review(2),
381 Call::Recheck,
382 Call::Respond(1),
383 Call::Close,
384 Call::Screen,
385 Call::Checkin(1),
386 Call::Split(1),
387 Call::Brainstorm(1),
388 ]
389 }
390}
391
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393#[serde(deny_unknown_fields)]
394pub struct EffortSchedule {
395 pub code: Option<String>,
397 pub round_1: Option<String>,
402 pub rest: Option<String>,
405 pub triage: Option<String>,
408 pub implement: Option<String>,
409 pub review_1: Option<String>,
410 pub review_rest: Option<String>,
411 pub respond: Option<String>,
412 pub close: Option<String>,
413 pub screen: Option<String>,
414 pub checkin: Option<String>,
415 pub split: Option<String>,
416 pub brainstorm: Option<String>,
420 pub recheck: Option<String>,
424}
425
426fn set(value: &Option<String>) -> Option<String> {
428 value.clone().filter(|s| !s.trim().is_empty())
429}
430
431impl EffortSchedule {
432 pub fn get(&self, call: Call) -> Option<String> {
434 let value = match call.key() {
435 "code" => &self.code,
436 "triage" => &self.triage,
437 "implement" => &self.implement,
438 "review_1" => &self.review_1,
439 "review_rest" => &self.review_rest,
440 "respond" => &self.respond,
441 "close" => &self.close,
442 "screen" => &self.screen,
443 "checkin" => &self.checkin,
444 "brainstorm" => &self.brainstorm,
445 "recheck" => {
446 return set(&self.recheck).or_else(|| set(&self.review_rest));
447 }
448 _ => &self.split,
449 };
450 set(value)
451 }
452
453 fn legacy(&self, call: Call) -> Option<String> {
455 let value = if call.legacy_round() <= 1 {
456 &self.round_1
457 } else {
458 &self.rest
459 };
460 value.clone().filter(|s| !s.trim().is_empty())
461 }
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(untagged)]
473pub enum CheckCommand {
474 Line(String),
475 Argv(Vec<String>),
476}
477
478impl Default for CheckCommand {
479 fn default() -> Self {
482 CheckCommand::Argv(Vec::new())
483 }
484}
485
486impl CheckCommand {
487 pub fn argv(&self) -> Vec<String> {
488 match self {
489 CheckCommand::Line(line) => line.split_whitespace().map(str::to_string).collect(),
490 CheckCommand::Argv(argv) => argv
491 .iter()
492 .map(|a| a.trim().to_string())
493 .filter(|a| !a.is_empty())
494 .collect(),
495 }
496 }
497
498 pub fn is_set(&self) -> bool {
499 !self.argv().is_empty()
500 }
501
502 pub fn describe(&self) -> String {
504 self.argv().join(" ")
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default, deny_unknown_fields)]
513pub struct LoopCfg {
514 pub max_rounds: u32,
517 pub auto_merge: bool,
518 pub first_implementor: Option<String>,
519 pub base_branch: String,
520 pub worktrees: bool,
521 pub keep_worktrees: bool,
522 pub state_store: StateStore,
523 pub branch_prefix: String,
524 pub followups: Followups,
525 pub file_non_blocking: bool,
533 pub max_followups: usize,
536 pub max_split_parts: usize,
544 pub file_nits: bool,
548 pub close_skipped: bool,
551 pub parallel_triage: bool,
554 pub min_number: i64,
562 pub absorb_new_issues: u32,
569 pub decompose_trackers: bool,
581 pub max_tracker_children: usize,
584 pub drafts: Drafts,
586 pub fork_when_denied: bool,
594 pub instructions: String,
606 pub max_issue_chars: usize,
615 pub max_triage_chars: usize,
624 pub checkin_trust: Trust,
632 pub checkin_resolve: bool,
637 pub max_checkin_comments: usize,
642 pub setup: CheckCommand,
653 pub check: CheckCommand,
663 pub effort_schedule: EffortSchedule,
664}
665
666impl Default for LoopCfg {
667 fn default() -> Self {
668 Self {
669 max_rounds: 3,
670 auto_merge: false,
671 first_implementor: None,
672 base_branch: "main".into(),
673 worktrees: true,
674 keep_worktrees: false,
675 state_store: StateStore::Local,
676 branch_prefix: String::new(),
677 followups: Followups::Local,
678 file_non_blocking: false,
679 max_followups: 5,
680 max_split_parts: 4,
681 file_nits: false,
682 close_skipped: true,
683 parallel_triage: true,
684 min_number: 0,
685 absorb_new_issues: 0,
686 decompose_trackers: false,
687 max_tracker_children: 5,
688 drafts: Drafts::Never,
689 fork_when_denied: true,
690 instructions: String::new(),
691 max_issue_chars: 60_000,
692 max_triage_chars: 200_000,
693 checkin_trust: Trust::Write,
694 checkin_resolve: true,
695 max_checkin_comments: 20,
696 setup: CheckCommand::default(),
697 check: CheckCommand::default(),
698 effort_schedule: EffortSchedule::default(),
699 }
700 }
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
704#[serde(default, deny_unknown_fields)]
705pub struct StyleCfg {
706 pub ban_em_dash: bool,
707 pub ban_ai_attribution: bool,
708 pub terse: bool,
709 pub max_detail_chars: usize,
710 pub max_summary_chars: usize,
711 pub max_body_chars: usize,
712 pub max_issue_body_chars: usize,
716 pub max_title_chars: usize,
717 pub pr_comments: PrComments,
718 pub inline_comments: bool,
719}
720
721impl Default for StyleCfg {
722 fn default() -> Self {
732 let style = Style::default();
733 Self {
734 ban_em_dash: style.ban_em_dash,
735 ban_ai_attribution: style.ban_ai_attribution,
736 terse: style.terse,
737 max_detail_chars: style.max_detail_chars,
738 max_summary_chars: style.max_summary_chars,
739 max_body_chars: style.max_body_chars,
740 max_issue_body_chars: style.max_issue_body_chars,
741 max_title_chars: style.max_title_chars,
742 pr_comments: style.pr_comments,
743 inline_comments: style.inline_comments,
744 }
745 }
746}
747
748impl StyleCfg {
749 pub fn to_style(&self) -> Style {
750 Style {
751 ban_em_dash: self.ban_em_dash,
752 ban_ai_attribution: self.ban_ai_attribution,
753 terse: self.terse,
754 max_detail_chars: self.max_detail_chars,
755 max_summary_chars: self.max_summary_chars,
756 max_body_chars: self.max_body_chars,
757 max_issue_body_chars: self.max_issue_body_chars,
758 max_title_chars: self.max_title_chars,
759 pr_comments: self.pr_comments,
760 inline_comments: self.inline_comments,
761 }
762 }
763}
764
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
771#[serde(rename_all = "lowercase")]
772pub enum ReviewOrIgnore {
773 Review,
775 Ignore,
777}
778
779impl std::fmt::Display for ReviewOrIgnore {
780 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
781 f.write_str(match self {
782 ReviewOrIgnore::Review => "review",
783 ReviewOrIgnore::Ignore => "ignore",
784 })
785 }
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
792#[serde(rename_all = "lowercase")]
793pub enum OwnPr {
794 Checkin,
797 Reply,
799 Ignore,
801}
802
803impl std::fmt::Display for OwnPr {
804 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
805 f.write_str(match self {
806 OwnPr::Checkin => "checkin",
807 OwnPr::Reply => "reply",
808 OwnPr::Ignore => "ignore",
809 })
810 }
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize)]
817#[serde(default, deny_unknown_fields)]
818pub struct ListenCfg {
819 pub interval: u64,
822 pub sweep: u64,
826 pub settle: u64,
830 pub max_wait: u64,
832 pub max_rechecks: u32,
836 pub on_review_requested: ReviewOrIgnore,
837 pub on_assigned: ReviewOrIgnore,
838 pub on_own_pr: OwnPr,
839 pub verdicts: bool,
842}
843
844impl Default for ListenCfg {
845 fn default() -> Self {
846 Self {
847 interval: 60,
848 sweep: 300,
849 settle: 120,
850 max_wait: 900,
851 max_rechecks: 5,
852 on_review_requested: ReviewOrIgnore::Review,
853 on_assigned: ReviewOrIgnore::Review,
854 on_own_pr: OwnPr::Checkin,
855 verdicts: true,
856 }
857 }
858}
859
860#[derive(Debug, Clone)]
865pub struct Config {
866 pub agents: Vec<AgentSpec>,
868 pub loop_cfg: LoopCfg,
869 pub style: Style,
870 pub listen: ListenCfg,
871 pub first_implementor: String,
874 pub alternate_first: bool,
876 pub source: Option<PathBuf>,
878}
879
880impl Config {
881 pub fn agent_names(&self) -> Vec<String> {
882 self.agents.iter().map(|a| a.name.clone()).collect()
883 }
884
885 pub fn has_agent(&self, name: &str) -> bool {
886 self.agents.iter().any(|a| a.name == name)
887 }
888
889 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
890 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
891 spar_err!(
892 "no agent named '{name}' ({})",
893 self.agent_names().join(", ")
894 )
895 })
896 }
897
898 pub fn other(&self, name: &str) -> String {
901 let names = self.agent_names();
902 if names.first().map(String::as_str) == Some(name) {
903 names.get(1).cloned().unwrap_or_else(|| name.to_string())
904 } else {
905 names.first().cloned().unwrap_or_else(|| name.to_string())
906 }
907 }
908
909 pub fn effort_for(&self, spec: &AgentSpec, call: Call) -> Option<String> {
917 spec.effort_schedule
918 .get(call)
919 .or_else(|| self.loop_cfg.effort_schedule.get(call))
920 .or_else(|| self.loop_cfg.effort_schedule.legacy(call))
921 .or_else(|| spec.effort.clone())
922 }
923
924 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
927 self.effort_for(spec, Call::Review(round))
928 }
929
930 pub fn first_implementor_for(&self, nth: usize) -> String {
935 if !self.alternate_first || nth % 2 == 0 {
936 return self.first_implementor.clone();
937 }
938 self.other(&self.first_implementor)
939 }
940
941 pub fn first_implementor_policy(&self) -> String {
943 if self.alternate_first {
944 format!("alternating, {} first", self.first_implementor)
945 } else {
946 self.first_implementor.clone()
947 }
948 }
949
950 pub fn base_branch(&self) -> &str {
951 &self.loop_cfg.base_branch
952 }
953}
954
955#[derive(Debug, Deserialize)]
956#[serde(deny_unknown_fields)]
957struct RawConfig {
958 #[serde(default)]
959 agents: toml::Table,
960 #[serde(default)]
961 #[serde(rename = "loop")]
962 loop_cfg: Option<LoopCfg>,
963 #[serde(default)]
964 style: Option<StyleCfg>,
965 #[serde(default)]
966 listen: Option<ListenCfg>,
967}
968
969pub fn preset_dirs() -> Vec<PathBuf> {
982 let mut dirs = Vec::new();
983 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
984 dirs.push(PathBuf::from(custom));
985 }
986 dirs.push(PathBuf::from(".spar").join("presets"));
987 if let Some(home) = home_dir() {
988 dirs.push(home.join(".config").join("spar").join("presets"));
989 }
990 dirs
991}
992
993pub fn available_presets() -> Vec<String> {
995 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
996 for dir in preset_dirs() {
997 if let Ok(entries) = std::fs::read_dir(&dir) {
998 for entry in entries.flatten() {
999 let path = entry.path();
1000 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
1001 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
1002 names.push(stem.to_string());
1003 }
1004 }
1005 }
1006 }
1007 }
1008 names.sort();
1009 names.dedup();
1010 names
1011}
1012
1013fn parse_document(text: &str, what: &str) -> Result<Value> {
1018 let table: toml::Table =
1019 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
1020 Ok(Value::Table(table))
1021}
1022
1023pub fn load_preset(name: &str) -> Result<Value> {
1026 for dir in preset_dirs() {
1027 let path = dir.join(format!("{name}.toml"));
1028 if path.is_file() {
1029 let text = std::fs::read_to_string(&path)
1030 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
1031 return parse_document(&text, &format!("preset {}", path.display()));
1032 }
1033 }
1034 for (builtin, text) in BUILTIN_PRESETS {
1035 if *builtin == name {
1036 return parse_document(text, &format!("built in preset {name}"));
1037 }
1038 }
1039 Err(spar_err!(
1040 "unknown preset '{name}'. Available: {}",
1041 available_presets().join(", ")
1042 ))
1043}
1044
1045fn merge(base: &Value, over: &Value) -> Value {
1048 match (base, over) {
1049 (Value::Table(b), Value::Table(o)) => {
1050 let mut out = b.clone();
1051 for (key, value) in o {
1052 let merged = match out.get(key) {
1053 Some(existing) => merge(existing, value),
1054 None => value.clone(),
1055 };
1056 out.insert(key.clone(), merged);
1057 }
1058 Value::Table(out)
1059 }
1060 _ => over.clone(),
1061 }
1062}
1063
1064fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
1065 let table = raw
1066 .as_table()
1067 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1068
1069 let merged = match table.get("preset").and_then(Value::as_str) {
1070 Some(preset) => merge(&load_preset(preset)?, raw),
1071 None => raw.clone(),
1072 };
1073
1074 let mut merged_table = merged
1075 .as_table()
1076 .cloned()
1077 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1078 merged_table.remove("preset");
1079 let fallback_raw = merged_table.remove("fallback");
1082
1083 if !merged_table.contains_key("command") {
1084 bail!(
1085 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
1086 available_presets().join(", ")
1087 );
1088 }
1089
1090 let mut spec: AgentSpec = Value::Table(merged_table)
1091 .try_into()
1092 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
1093 spec.name = name.to_string();
1094
1095 if spec.command.is_empty() {
1096 bail!("agent '{name}' has an empty command");
1097 }
1098 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
1099 bail!("agent '{name}': the first command element must be the program name, not a group");
1100 }
1101 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
1102 bail!(
1103 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
1104 );
1105 }
1106
1107 if let Some(raw) = fallback_raw {
1108 if !raw.is_table() {
1109 bail!(
1110 "agent '{name}': fallback is a whole agent, so write it as a table:\n [agents.{name}.fallback]\n preset = \"cursor\""
1111 );
1112 }
1113 let backup = build_spec(&format!("{name}-fallback"), &raw)?;
1116 if backup.fallback.is_some() {
1117 bail!(
1118 "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
1119 another full timeout on a call that has already failed once."
1120 );
1121 }
1122 spec.fallback = Some(Box::new(backup));
1123 }
1124
1125 Ok(spec)
1126}
1127
1128#[derive(Debug, Clone)]
1134pub struct OptionInfo {
1135 pub section: &'static str,
1136 pub key: String,
1137 pub default: String,
1138}
1139
1140pub fn known_options() -> Vec<OptionInfo> {
1146 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
1147 toml::to_string(value)
1148 .unwrap_or_default()
1149 .lines()
1150 .filter_map(|line| line.split_once(" = "))
1151 .map(|(key, default)| OptionInfo {
1152 section,
1153 key: key.trim().to_string(),
1154 default: default.trim().to_string(),
1155 })
1156 .collect()
1157 }
1158 let mut out = lines("loop", &LoopCfg::default());
1159 out.extend(lines("style", &StyleCfg::default()));
1160 out.extend(lines(
1161 "loop.effort_schedule",
1162 &EffortSchedule {
1163 round_1: Some("high".into()),
1164 rest: Some("low".into()),
1165 code: Some("high".into()),
1166 triage: Some("low".into()),
1167 implement: Some("high".into()),
1168 review_1: Some("high".into()),
1169 review_rest: Some("low".into()),
1170 respond: Some("high".into()),
1171 close: Some("low".into()),
1172 screen: Some("low".into()),
1173 checkin: Some("high".into()),
1174 split: Some("high".into()),
1175 brainstorm: Some("high".into()),
1176 recheck: Some("low".into()),
1177 },
1178 ));
1179 out.extend(lines("listen", &ListenCfg::default()));
1180 out
1181}
1182
1183pub fn mentions(config_text: &str, key: &str) -> bool {
1185 config_text.lines().any(|line| {
1186 let bare = line.trim_start().trim_start_matches('#').trim_start();
1187 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1188 })
1189}
1190
1191pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
1194 known_options()
1195 .into_iter()
1196 .filter(|o| !mentions(config_text, &o.key))
1197 .collect()
1198}
1199
1200pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
1201
1202pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1205 if let Some(path) = explicit {
1206 if !path.is_file() {
1207 bail!("config not found: {}", path.display());
1208 }
1209 return Ok(Some(path.to_path_buf()));
1210 }
1211 for name in CONFIG_NAMES {
1212 let path = PathBuf::from(name);
1213 if path.is_file() {
1214 return Ok(Some(path));
1215 }
1216 }
1217 if let Some(home) = home_dir() {
1218 let path = home.join(".config").join("spar").join("spar.toml");
1219 if path.is_file() {
1220 return Ok(Some(path));
1221 }
1222 }
1223 Ok(None)
1224}
1225
1226pub fn load(explicit: Option<&Path>) -> Result<Config> {
1227 let Some(path) = find_config(explicit)? else {
1228 bail!(
1229 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
1230 );
1231 };
1232 let text = std::fs::read_to_string(&path)
1233 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1234 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
1235 cfg.source = Some(path);
1236 Ok(cfg)
1237}
1238
1239pub fn parse(text: &str) -> Result<Config> {
1240 let raw: RawConfig = toml::from_str(text)?;
1241
1242 if raw.agents.len() != 2 {
1243 bail!(
1244 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
1245 raw.agents.len()
1246 );
1247 }
1248
1249 let mut agents = Vec::new();
1250 for (name, value) in raw.agents.iter() {
1251 agents.push(build_spec(name, value)?);
1252 }
1253
1254 let loop_cfg = raw.loop_cfg.unwrap_or_default();
1255 let style = raw.style.unwrap_or_default().to_style();
1256 let listen = raw.listen.unwrap_or_default();
1257
1258 if loop_cfg.max_rounds == 0 {
1259 bail!("max_rounds must be at least 1");
1260 }
1261 if listen.settle > listen.max_wait {
1262 bail!(
1263 "[listen] settle ({}) cannot be longer than max_wait ({}): an item that kept \
1264 changing would never be acted on. Raise max_wait or lower settle.",
1265 listen.settle,
1266 listen.max_wait
1267 );
1268 }
1269 if listen.interval == 0 || listen.sweep == 0 {
1270 bail!(
1271 "[listen] interval and sweep are seconds between polls and must be at least 1. \
1272 Polling never goes below 15 seconds whatever they say."
1273 );
1274 }
1275 if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
1280 bail!(
1281 "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
1282 ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
1283 to have it promoted when the review converges, or turn auto_merge off."
1284 );
1285 }
1286
1287 let alternate = loop_cfg
1294 .first_implementor
1295 .as_deref()
1296 .is_some_and(|name| name.trim().eq_ignore_ascii_case("alternate"));
1297 let first = match &loop_cfg.first_implementor {
1298 Some(name) if !name.trim().is_empty() && !alternate => name.trim().to_string(),
1299 _ => agents[0].name.clone(),
1300 };
1301 if !agents.iter().any(|a| a.name == first) {
1302 bail!(
1303 "first_implementor '{first}' is not a configured agent ({})",
1304 agents
1305 .iter()
1306 .map(|a| a.name.as_str())
1307 .collect::<Vec<_>>()
1308 .join(", ")
1309 );
1310 }
1311
1312 Ok(Config {
1313 agents,
1314 loop_cfg,
1315 style,
1316 listen,
1317 first_implementor: first,
1318 alternate_first: alternate,
1319 source: None,
1320 })
1321}
1322
1323pub fn resolve_search_path(raw: &str) -> PathBuf {
1325 expand_tilde(raw)
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::*;
1331
1332 const TWO_AGENTS: &str = r#"
1333[agents.claude]
1334preset = "claude"
1335model = "fable"
1336
1337[agents.codex]
1338preset = "codex"
1339model = "gpt-5.6-sol"
1340"#;
1341
1342 #[test]
1345 fn a_fallback_is_a_whole_agent_with_its_own_preset() {
1346 let text = format!(
1347 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
1348 );
1349 let cfg = parse(&text).expect("parses");
1350 assert_eq!(2, cfg.agents.len());
1352 let codex = cfg.spec("codex").expect("codex");
1353 let backup = codex.fallback.as_ref().expect("fallback");
1354 assert_eq!("codex-fallback", backup.name);
1355 assert_eq!(Some("kimi-k3"), backup.model.as_deref());
1356 assert_eq!(
1357 Some(&CommandPart::One("cursor-agent".into())),
1358 backup.command.first()
1359 );
1360 }
1361
1362 #[test]
1363 fn the_agent_without_a_fallback_does_not_grow_one() {
1364 let cfg = parse(TWO_AGENTS).expect("parses");
1365 assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
1366 }
1367
1368 #[test]
1369 fn a_fallback_may_not_have_one_of_its_own() {
1370 let text = format!(
1371 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
1372 [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
1373 );
1374 let err = parse(&text).expect_err("rejected");
1375 assert!(err.message().contains("may not have a fallback"), "{err}");
1376 }
1377
1378 #[test]
1379 fn a_fallback_written_as_a_string_says_what_it_should_be() {
1380 let text = "[agents.claude]\npreset = \"claude\"\n\n\
1381 [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
1382 let err = parse(text).expect_err("rejected");
1383 assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
1384 }
1385
1386 #[test]
1391 fn a_partial_block_keeps_the_defaults_it_did_not_name() {
1392 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
1393 let cfg = parse(&text).expect("parses");
1394
1395 assert_eq!(9, cfg.loop_cfg.max_rounds);
1396 assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
1397 assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
1398
1399 assert!(!cfg.style.terse);
1400 assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
1401 assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
1402 }
1403
1404 #[test]
1408 fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
1409 assert_eq!(Style::default(), StyleCfg::default().to_style());
1410 }
1411
1412 #[test]
1415 fn pull_requests_are_not_drafts_unless_asked_for() {
1416 assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1417 }
1418
1419 #[test]
1420 fn each_draft_setting_parses() {
1421 for (text, want) in [
1422 ("never", Drafts::Never),
1423 ("until_approved", Drafts::UntilApproved),
1424 ("always", Drafts::Always),
1425 ] {
1426 let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1427 .unwrap_or_else(|e| panic!("{text}: {e}"));
1428 assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1429 }
1430 }
1431
1432 #[test]
1436 fn auto_merge_and_a_permanent_draft_are_refused_together() {
1437 let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1438 let err = parse(&text).expect_err("refused");
1439 assert!(err.message().contains("auto_merge"), "{err}");
1440 assert!(
1441 err.message().contains("until_approved"),
1442 "says the way out: {err}"
1443 );
1444 }
1445
1446 #[test]
1449 fn auto_merge_is_fine_with_a_draft_that_clears() {
1450 let text =
1451 format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1452 assert!(parse(&text).is_ok());
1453 }
1454
1455 #[test]
1456 fn every_builtin_preset_parses() {
1457 for (name, _) in BUILTIN_PRESETS {
1458 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1459 assert!(value.get("command").is_some(), "{name} has no command");
1460 }
1461 }
1462
1463 #[test]
1464 fn every_builtin_preset_builds_a_spec() {
1465 for (name, _) in BUILTIN_PRESETS {
1466 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1467 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1468 }
1469 }
1470
1471 #[test]
1475 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1476 let spec = build_spec(
1477 "claude",
1478 &parse_document("preset = \"claude\"", "test").unwrap(),
1479 )
1480 .unwrap();
1481 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1482 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1483 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1484 }
1485
1486 #[test]
1487 fn codex_preset_declares_where_its_answer_lives() {
1488 let spec = build_spec(
1489 "codex",
1490 &parse_document("preset = \"codex\"", "test").unwrap(),
1491 )
1492 .unwrap();
1493 assert_eq!(OutputMode::Jsonl, spec.output);
1494 assert_eq!(Some("item.text"), spec.message_path.as_deref());
1495 assert!(!spec.message_match.is_empty());
1496 }
1497
1498 #[test]
1502 fn codex_preset_looks_where_either_chatgpt_layout_keeps_the_cli() {
1503 let spec = build_spec(
1504 "codex",
1505 &parse_document("preset = \"codex\"", "test").unwrap(),
1506 )
1507 .unwrap();
1508 let at = |path: &str| spec.search_paths.iter().position(|p| p == path);
1509 let current = at("/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin");
1510 let older = at("/Applications/ChatGPT.app/Contents/Resources");
1511 assert!(current.is_some(), "{:?}", spec.search_paths);
1512 assert!(older.is_some(), "{:?}", spec.search_paths);
1513 assert!(current < older, "{:?}", spec.search_paths);
1514 }
1515
1516 #[test]
1517 fn agent_order_follows_declaration_order() {
1518 let cfg = parse(TWO_AGENTS).unwrap();
1519 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1520 assert_eq!("claude", cfg.first_implementor);
1521 }
1522
1523 #[test]
1524 fn other_alternates() {
1525 let cfg = parse(TWO_AGENTS).unwrap();
1526 assert_eq!("codex", cfg.other("claude"));
1527 assert_eq!("claude", cfg.other("codex"));
1528 }
1529
1530 #[test]
1531 fn a_config_block_overrides_one_preset_field() {
1532 let cfg = parse(TWO_AGENTS).unwrap();
1533 let claude = cfg.spec("claude").unwrap();
1534 assert_eq!(Some("fable"), claude.model.as_deref());
1535 assert!(claude.command.len() > 1, "the preset command survived");
1536 }
1537
1538 #[test]
1539 fn exactly_two_agents_are_required() {
1540 let one = "[agents.claude]\npreset = \"claude\"\n";
1541 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1542 }
1543
1544 #[test]
1545 fn an_unknown_agent_option_is_named() {
1546 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1547 let err = parse(text).unwrap_err().to_string();
1548 assert!(err.contains("widget"), "{err}");
1549 }
1550
1551 #[test]
1552 fn an_unknown_loop_option_is_named() {
1553 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1554 let err = parse(&text).unwrap_err().to_string();
1555 assert!(err.contains("max_round"), "{err}");
1556 }
1557
1558 #[test]
1559 fn an_unknown_listen_option_is_named() {
1560 let text = format!("{TWO_AGENTS}\n[listen]\ninterval_secs = 30\n");
1561 let err = parse(&text).unwrap_err().to_string();
1562 assert!(err.contains("interval_secs"), "{err}");
1563 }
1564
1565 #[test]
1566 fn a_listen_block_keeps_the_defaults_it_did_not_name() {
1567 let text = format!("{TWO_AGENTS}\n[listen]\nsweep = 30\n");
1568 let cfg = parse(&text).expect("parses");
1569 assert_eq!(30, cfg.listen.sweep);
1570 assert_eq!(ListenCfg::default().interval, cfg.listen.interval);
1571 assert_eq!(ListenCfg::default().settle, cfg.listen.settle);
1572 assert_eq!(OwnPr::Checkin, cfg.listen.on_own_pr);
1573 assert!(cfg.listen.verdicts);
1574
1575 let bare = parse(TWO_AGENTS).unwrap();
1576 assert_eq!(60, bare.listen.interval);
1577 assert_eq!(300, bare.listen.sweep);
1578 assert_eq!(120, bare.listen.settle);
1579 assert_eq!(900, bare.listen.max_wait);
1580 assert_eq!(5, bare.listen.max_rechecks);
1581 assert_eq!(ReviewOrIgnore::Review, bare.listen.on_review_requested);
1582 assert_eq!(ReviewOrIgnore::Review, bare.listen.on_assigned);
1583 }
1584
1585 #[test]
1586 fn settle_longer_than_max_wait_is_refused_with_a_reason() {
1587 let text = format!("{TWO_AGENTS}\n[listen]\nsettle = 1000\nmax_wait = 900\n");
1588 let err = parse(&text).unwrap_err().to_string();
1589 assert!(err.contains("settle"), "{err}");
1590 assert!(err.contains("Raise max_wait"), "{err}");
1591
1592 let text = format!("{TWO_AGENTS}\n[listen]\ninterval = 0\n");
1593 let err = parse(&text).unwrap_err().to_string();
1594 assert!(err.contains("at least 1"), "{err}");
1595 }
1596
1597 #[test]
1598 fn each_listen_action_word_parses() {
1599 for (text, want) in [
1600 ("review", ReviewOrIgnore::Review),
1601 ("ignore", ReviewOrIgnore::Ignore),
1602 ] {
1603 let cfg = parse(&format!(
1604 "{TWO_AGENTS}\n[listen]\non_review_requested = \"{text}\"\non_assigned = \"{text}\"\n"
1605 ))
1606 .unwrap();
1607 assert_eq!(want, cfg.listen.on_review_requested);
1608 assert_eq!(want, cfg.listen.on_assigned);
1609 assert_eq!(text, want.to_string());
1610 }
1611 for (text, want) in [
1612 ("checkin", OwnPr::Checkin),
1613 ("reply", OwnPr::Reply),
1614 ("ignore", OwnPr::Ignore),
1615 ] {
1616 let cfg = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"{text}\"\n")).unwrap();
1617 assert_eq!(want, cfg.listen.on_own_pr);
1618 assert_eq!(text, want.to_string());
1619 }
1620 let err = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"push\"\n"))
1621 .unwrap_err()
1622 .to_string();
1623 assert!(err.contains("on_own_pr") || err.contains("push"), "{err}");
1624 }
1625
1626 #[test]
1627 fn a_recheck_reads_its_own_key_before_review_rest_and_falls_back_to_it() {
1628 let text = format!(
1629 "{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\nrecheck = \"high\"\n"
1630 );
1631 let cfg = parse(&text).unwrap();
1632 let spec = cfg.spec("claude").unwrap();
1633 assert_eq!(
1634 Some("high".to_string()),
1635 cfg.effort_for(spec, Call::Recheck)
1636 );
1637
1638 let text = format!("{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\n");
1639 let cfg = parse(&text).unwrap();
1640 let spec = cfg.spec("claude").unwrap();
1641 assert_eq!(Some("low".to_string()), cfg.effort_for(spec, Call::Recheck));
1642
1643 let text = format!(
1644 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"medium\"\n"
1645 );
1646 let cfg = parse(&text).unwrap();
1647 let spec = cfg.spec("claude").unwrap();
1648 assert_eq!(
1649 Some("medium".to_string()),
1650 cfg.effort_for(spec, Call::Recheck)
1651 );
1652 }
1653
1654 #[test]
1655 fn every_call_kind_is_in_every() {
1656 let every = Call::every();
1657 assert_eq!(12, every.len());
1658 assert!(every.contains(&Call::Code));
1659 assert!(every.contains(&Call::Recheck));
1660 assert_eq!("recheck", Call::Recheck.key());
1661 }
1662
1663 #[test]
1664 fn an_agent_with_no_command_and_no_preset_is_rejected() {
1665 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1666 let err = parse(text).unwrap_err().to_string();
1667 assert!(err.contains("no command and no preset"), "{err}");
1668 }
1669
1670 #[test]
1671 fn jsonl_without_a_message_path_is_rejected() {
1672 let text =
1673 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1674 let err = parse(text).unwrap_err().to_string();
1675 assert!(err.contains("message_path"), "{err}");
1676 }
1677
1678 #[test]
1683 fn a_check_is_read_as_an_argv_either_way_it_is_written() {
1684 let line = format!("{TWO_AGENTS}\n[loop]\ncheck = \"cargo test --all\"\n");
1685 let cfg = parse(&line).unwrap();
1686 assert!(cfg.loop_cfg.check.is_set());
1687 assert_eq!(vec!["cargo", "test", "--all"], cfg.loop_cfg.check.argv());
1688 assert_eq!("cargo test --all", cfg.loop_cfg.check.describe());
1689
1690 let listed =
1691 format!("{TWO_AGENTS}\n[loop]\ncheck = [\"npm\", \"run\", \"test -- --ci\"]\n");
1692 let cfg = parse(&listed).unwrap();
1693 assert_eq!(
1694 vec!["npm", "run", "test -- --ci"],
1695 cfg.loop_cfg.check.argv(),
1696 "a list is taken as it is, spaces and all"
1697 );
1698
1699 let bare = parse(TWO_AGENTS).unwrap();
1701 assert!(!bare.loop_cfg.check.is_set());
1702 assert!(bare.loop_cfg.check.argv().is_empty());
1703 }
1704
1705 #[test]
1706 fn a_setup_is_read_like_a_check_and_runs_nothing_unset() {
1707 let line = format!("{TWO_AGENTS}\n[loop]\nsetup = \"npm ci --no-audit\"\n");
1708 let cfg = parse(&line).unwrap();
1709 assert_eq!(vec!["npm", "ci", "--no-audit"], cfg.loop_cfg.setup.argv());
1710 assert!(
1711 !cfg.loop_cfg.check.is_set(),
1712 "setting up a worktree is not checking it"
1713 );
1714
1715 let bare = parse(TWO_AGENTS).unwrap();
1716 assert!(!bare.loop_cfg.setup.is_set());
1717 }
1718
1719 #[test]
1720 fn alternating_swaps_the_author_from_issue_to_issue() {
1721 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"alternate\"\n");
1722 let cfg = parse(&text).unwrap();
1723
1724 assert!(cfg.alternate_first);
1725 assert_eq!(
1726 "claude", cfg.first_implementor,
1727 "declaration order still leads"
1728 );
1729 assert_eq!("claude", cfg.first_implementor_for(0));
1730 assert_eq!("codex", cfg.first_implementor_for(1));
1731 assert_eq!("claude", cfg.first_implementor_for(2));
1732 assert!(cfg.first_implementor_policy().contains("alternating"));
1733 }
1734
1735 #[test]
1737 fn a_named_first_implementor_never_swaps() {
1738 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"codex\"\n");
1739 let cfg = parse(&text).unwrap();
1740 assert!(!cfg.alternate_first);
1741 for nth in 0..4 {
1742 assert_eq!("codex", cfg.first_implementor_for(nth));
1743 }
1744 assert_eq!("codex", cfg.first_implementor_policy());
1745 }
1746
1747 #[test]
1748 fn first_implementor_must_name_a_configured_agent() {
1749 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1750 let err = parse(&text).unwrap_err().to_string();
1751 assert!(err.contains("not a configured agent"), "{err}");
1752 }
1753
1754 #[test]
1755 fn defaults_are_the_conservative_ones() {
1756 let cfg = parse(TWO_AGENTS).unwrap();
1757 assert!(
1758 !cfg.loop_cfg.auto_merge,
1759 "auto_merge must be off by default"
1760 );
1761 assert!(cfg.loop_cfg.worktrees);
1762 assert!(
1763 !cfg.loop_cfg.file_nits,
1764 "a filed nit is somebody else's triage queue"
1765 );
1766 assert_eq!(3, cfg.loop_cfg.max_rounds);
1767 assert_eq!(
1768 Followups::Local,
1769 cfg.loop_cfg.followups,
1770 "the tracker is somebody's queue; the default must not write to it"
1771 );
1772 assert!(
1773 !cfg.loop_cfg.file_non_blocking,
1774 "a suggestion is not a tracker item"
1775 );
1776 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1777 assert!(cfg.style.terse);
1778 }
1779
1780 #[test]
1781 fn effort_schedule_splits_round_one_from_the_rest() {
1782 let text =
1783 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1784 let cfg = parse(&text).unwrap();
1785 let spec = cfg.spec("claude").unwrap();
1786 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1787 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1788 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1789 }
1790
1791 #[test]
1798 fn an_agent_can_carry_its_own_schedule_in_its_own_words() {
1799 let text = format!(
1800 "{TWO_AGENTS}\n[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
1801 [loop.effort_schedule]\nreview_1 = \"ultra\"\n"
1802 );
1803 let cfg = parse(&text).unwrap();
1804 assert_eq!(
1805 Some("xhigh".into()),
1806 cfg.effort_for(cfg.spec("claude").unwrap(), Call::Review(1)),
1807 "the agent's own schedule has to win"
1808 );
1809 assert_eq!(
1810 Some("ultra".into()),
1811 cfg.effort_for(cfg.spec("codex").unwrap(), Call::Review(1))
1812 );
1813 }
1814
1815 #[test]
1819 fn each_kind_of_call_reads_its_own_key_and_falls_back_to_the_old_pair() {
1820 let text = format!(
1821 "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"low\"\n\
1822 triage = \"minimal\"\n"
1823 );
1824 let cfg = parse(&text).unwrap();
1825 let spec = cfg.spec("claude").unwrap();
1826
1827 assert_eq!(Some("minimal".into()), cfg.effort_for(spec, Call::Triage));
1828 for call in [Call::Implement, Call::Review(1), Call::Screen] {
1830 assert_eq!(Some("ultra".into()), cfg.effort_for(spec, call), "{call:?}");
1831 }
1832 for call in [Call::Review(2), Call::Close, Call::Respond(3)] {
1833 assert_eq!(Some("low".into()), cfg.effort_for(spec, call), "{call:?}");
1834 }
1835 }
1836
1837 #[test]
1838 fn effort_falls_back_to_the_agents_own_setting() {
1839 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1840 let cfg = parse(&text).unwrap();
1841 let spec = cfg.spec("codex").unwrap();
1842 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1843 }
1844
1845 #[test]
1846 fn an_unset_model_and_an_empty_model_normalise_the_same() {
1847 let a = AgentSpec {
1848 name: "a".into(),
1849 command: vec![CommandPart::One("x".into())],
1850 model: None,
1851 effort: None,
1852 effort_schedule: EffortSchedule::default(),
1853 output: OutputMode::Text,
1854 message_match: BTreeMap::new(),
1855 message_path: None,
1856 search_paths: vec![],
1857 system_via: SystemVia::Prompt,
1858 timeout: 60,
1859 fallback: None,
1860 models: vec![],
1861 efforts: vec![],
1862 options_note: None,
1863 };
1864 let b = AgentSpec {
1865 model: Some(" ".into()),
1866 ..a.clone()
1867 };
1868 assert_eq!(a.model_key(), b.model_key());
1869 }
1870
1871 #[test]
1872 fn max_rounds_zero_is_rejected() {
1873 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1874 assert!(parse(&text).is_err());
1875 }
1876
1877 #[test]
1878 fn an_inline_command_needs_no_preset() {
1879 let text = r#"
1880[agents.custom]
1881command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1882output = "text"
1883
1884[agents.other]
1885command = ["othertool", "{prompt}"]
1886"#;
1887 let cfg = parse(text).unwrap();
1888 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1889 }
1890
1891 #[test]
1892 fn style_budgets_are_configurable() {
1893 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1894 let cfg = parse(&text).unwrap();
1895 assert!(!cfg.style.terse);
1896 assert_eq!(40, cfg.style.max_detail_chars);
1897 }
1898}