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 = "default_output")]
99 pub output: OutputMode,
100 #[serde(default)]
102 pub message_match: BTreeMap<String, String>,
103 #[serde(default)]
105 pub message_path: Option<String>,
106 #[serde(default)]
108 pub search_paths: Vec<String>,
109 #[serde(default = "default_system_via")]
110 pub system_via: SystemVia,
111 #[serde(default = "default_timeout")]
112 pub timeout: u64,
113 #[serde(skip)]
124 pub fallback: Option<Box<AgentSpec>>,
125
126 #[serde(default)]
134 pub models: Vec<String>,
135 #[serde(default)]
137 pub efforts: Vec<String>,
138 #[serde(default)]
140 pub options_note: Option<String>,
141}
142
143impl AgentSpec {
144 pub fn model_key(&self) -> String {
147 self.model.as_deref().unwrap_or("").trim().to_string()
148 }
149
150 pub fn describe(&self) -> String {
151 format!(
152 "{}/{}",
153 self.model.as_deref().unwrap_or("default model"),
154 self.effort.as_deref().unwrap_or("default effort")
155 )
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "lowercase")]
168pub enum Followups {
169 Issues,
170 Local,
171 None,
172}
173
174impl std::fmt::Display for Followups {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 f.write_str(match self {
177 Followups::Issues => "issues",
178 Followups::Local => "local",
179 Followups::None => "none",
180 })
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum Drafts {
195 Never,
197 UntilApproved,
199 Always,
201}
202
203impl std::fmt::Display for Drafts {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(match self {
206 Drafts::Never => "never",
207 Drafts::UntilApproved => "until_approved",
208 Drafts::Always => "always",
209 })
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "lowercase")]
221pub enum PrComments {
222 Outcome,
224 Rounds,
227 None,
229}
230
231impl std::fmt::Display for PrComments {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 f.write_str(match self {
234 PrComments::Outcome => "outcome",
235 PrComments::Rounds => "rounds",
236 PrComments::None => "none",
237 })
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "lowercase")]
246pub enum StateStore {
247 Local,
248 Pr,
249 Both,
250}
251
252impl StateStore {
253 pub fn writes_local(self) -> bool {
254 matches!(self, StateStore::Local | StateStore::Both)
255 }
256 pub fn writes_pr(self) -> bool {
257 matches!(self, StateStore::Pr | StateStore::Both)
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "lowercase")]
270pub enum Trust {
271 Write,
273 Anyone,
275}
276
277impl std::fmt::Display for Trust {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.write_str(match self {
280 Trust::Write => "write",
281 Trust::Anyone => "anyone",
282 })
283 }
284}
285
286impl Trust {
287 pub fn may_act_on(self, association: &str) -> bool {
290 match self {
291 Trust::Anyone => true,
292 Trust::Write => matches!(
293 association.trim().to_uppercase().as_str(),
294 "OWNER" | "MEMBER" | "COLLABORATOR"
295 ),
296 }
297 }
298}
299
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct EffortSchedule {
303 pub round_1: Option<String>,
305 pub rest: Option<String>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
314#[serde(default, deny_unknown_fields)]
315pub struct LoopCfg {
316 pub max_rounds: u32,
319 pub auto_merge: bool,
320 pub first_implementor: Option<String>,
321 pub base_branch: String,
322 pub worktrees: bool,
323 pub keep_worktrees: bool,
324 pub state_store: StateStore,
325 pub branch_prefix: String,
326 pub followups: Followups,
327 pub file_non_blocking: bool,
335 pub max_followups: usize,
338 pub max_split_parts: usize,
346 pub file_nits: bool,
350 pub close_skipped: bool,
353 pub parallel_triage: bool,
356 pub min_number: i64,
364 pub absorb_new_issues: u32,
371 pub decompose_trackers: bool,
383 pub max_tracker_children: usize,
386 pub drafts: Drafts,
388 pub instructions: String,
400 pub max_issue_chars: usize,
408 pub max_triage_chars: usize,
416 pub checkin_trust: Trust,
418 pub checkin_resolve: bool,
423 pub max_checkin_comments: usize,
428 pub effort_schedule: EffortSchedule,
429}
430
431impl Default for LoopCfg {
432 fn default() -> Self {
433 Self {
434 max_rounds: 3,
435 auto_merge: false,
436 first_implementor: None,
437 base_branch: "main".into(),
438 worktrees: true,
439 keep_worktrees: false,
440 state_store: StateStore::Local,
441 branch_prefix: String::new(),
442 followups: Followups::Local,
443 file_non_blocking: false,
444 max_followups: 5,
445 max_split_parts: 4,
446 file_nits: false,
447 close_skipped: true,
448 parallel_triage: true,
449 min_number: 0,
450 absorb_new_issues: 0,
451 decompose_trackers: false,
452 max_tracker_children: 5,
453 drafts: Drafts::Never,
454 instructions: String::new(),
455 max_issue_chars: 60_000,
456 max_triage_chars: 200_000,
457 checkin_trust: Trust::Write,
458 checkin_resolve: true,
459 max_checkin_comments: 20,
460 effort_schedule: EffortSchedule::default(),
461 }
462 }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(default, deny_unknown_fields)]
467pub struct StyleCfg {
468 pub ban_em_dash: bool,
469 pub ban_ai_attribution: bool,
470 pub terse: bool,
471 pub max_detail_chars: usize,
472 pub max_summary_chars: usize,
473 pub max_body_chars: usize,
474 pub max_issue_body_chars: usize,
478 pub max_title_chars: usize,
479 pub pr_comments: PrComments,
480}
481
482impl Default for StyleCfg {
483 fn default() -> Self {
493 let style = Style::default();
494 Self {
495 ban_em_dash: style.ban_em_dash,
496 ban_ai_attribution: style.ban_ai_attribution,
497 terse: style.terse,
498 max_detail_chars: style.max_detail_chars,
499 max_summary_chars: style.max_summary_chars,
500 max_body_chars: style.max_body_chars,
501 max_issue_body_chars: style.max_issue_body_chars,
502 max_title_chars: style.max_title_chars,
503 pr_comments: style.pr_comments,
504 }
505 }
506}
507
508impl StyleCfg {
509 pub fn to_style(&self) -> Style {
510 Style {
511 ban_em_dash: self.ban_em_dash,
512 ban_ai_attribution: self.ban_ai_attribution,
513 terse: self.terse,
514 max_detail_chars: self.max_detail_chars,
515 max_summary_chars: self.max_summary_chars,
516 max_body_chars: self.max_body_chars,
517 max_issue_body_chars: self.max_issue_body_chars,
518 max_title_chars: self.max_title_chars,
519 pr_comments: self.pr_comments,
520 }
521 }
522}
523
524#[derive(Debug, Clone)]
529pub struct Config {
530 pub agents: Vec<AgentSpec>,
532 pub loop_cfg: LoopCfg,
533 pub style: Style,
534 pub first_implementor: String,
536 pub source: Option<PathBuf>,
538}
539
540impl Config {
541 pub fn agent_names(&self) -> Vec<String> {
542 self.agents.iter().map(|a| a.name.clone()).collect()
543 }
544
545 pub fn has_agent(&self, name: &str) -> bool {
546 self.agents.iter().any(|a| a.name == name)
547 }
548
549 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
550 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
551 spar_err!(
552 "no agent named '{name}' ({})",
553 self.agent_names().join(", ")
554 )
555 })
556 }
557
558 pub fn other(&self, name: &str) -> String {
561 let names = self.agent_names();
562 if names.first().map(String::as_str) == Some(name) {
563 names.get(1).cloned().unwrap_or_else(|| name.to_string())
564 } else {
565 names.first().cloned().unwrap_or_else(|| name.to_string())
566 }
567 }
568
569 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
572 let scheduled = if round <= 1 {
573 self.loop_cfg.effort_schedule.round_1.clone()
574 } else {
575 self.loop_cfg.effort_schedule.rest.clone()
576 };
577 scheduled
578 .filter(|s| !s.trim().is_empty())
579 .or_else(|| spec.effort.clone())
580 }
581
582 pub fn base_branch(&self) -> &str {
583 &self.loop_cfg.base_branch
584 }
585}
586
587#[derive(Debug, Deserialize)]
588#[serde(deny_unknown_fields)]
589struct RawConfig {
590 #[serde(default)]
591 agents: toml::Table,
592 #[serde(default)]
593 #[serde(rename = "loop")]
594 loop_cfg: Option<LoopCfg>,
595 #[serde(default)]
596 style: Option<StyleCfg>,
597}
598
599pub fn preset_dirs() -> Vec<PathBuf> {
612 let mut dirs = Vec::new();
613 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
614 dirs.push(PathBuf::from(custom));
615 }
616 dirs.push(PathBuf::from(".spar").join("presets"));
617 if let Some(home) = home_dir() {
618 dirs.push(home.join(".config").join("spar").join("presets"));
619 }
620 dirs
621}
622
623pub fn available_presets() -> Vec<String> {
625 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
626 for dir in preset_dirs() {
627 if let Ok(entries) = std::fs::read_dir(&dir) {
628 for entry in entries.flatten() {
629 let path = entry.path();
630 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
631 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
632 names.push(stem.to_string());
633 }
634 }
635 }
636 }
637 }
638 names.sort();
639 names.dedup();
640 names
641}
642
643fn parse_document(text: &str, what: &str) -> Result<Value> {
648 let table: toml::Table =
649 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
650 Ok(Value::Table(table))
651}
652
653pub fn load_preset(name: &str) -> Result<Value> {
656 for dir in preset_dirs() {
657 let path = dir.join(format!("{name}.toml"));
658 if path.is_file() {
659 let text = std::fs::read_to_string(&path)
660 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
661 return parse_document(&text, &format!("preset {}", path.display()));
662 }
663 }
664 for (builtin, text) in BUILTIN_PRESETS {
665 if *builtin == name {
666 return parse_document(text, &format!("built in preset {name}"));
667 }
668 }
669 Err(spar_err!(
670 "unknown preset '{name}'. Available: {}",
671 available_presets().join(", ")
672 ))
673}
674
675fn merge(base: &Value, over: &Value) -> Value {
678 match (base, over) {
679 (Value::Table(b), Value::Table(o)) => {
680 let mut out = b.clone();
681 for (key, value) in o {
682 let merged = match out.get(key) {
683 Some(existing) => merge(existing, value),
684 None => value.clone(),
685 };
686 out.insert(key.clone(), merged);
687 }
688 Value::Table(out)
689 }
690 _ => over.clone(),
691 }
692}
693
694fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
695 let table = raw
696 .as_table()
697 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
698
699 let merged = match table.get("preset").and_then(Value::as_str) {
700 Some(preset) => merge(&load_preset(preset)?, raw),
701 None => raw.clone(),
702 };
703
704 let mut merged_table = merged
705 .as_table()
706 .cloned()
707 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
708 merged_table.remove("preset");
709 let fallback_raw = merged_table.remove("fallback");
712
713 if !merged_table.contains_key("command") {
714 bail!(
715 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
716 available_presets().join(", ")
717 );
718 }
719
720 let mut spec: AgentSpec = Value::Table(merged_table)
721 .try_into()
722 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
723 spec.name = name.to_string();
724
725 if spec.command.is_empty() {
726 bail!("agent '{name}' has an empty command");
727 }
728 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
729 bail!("agent '{name}': the first command element must be the program name, not a group");
730 }
731 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
732 bail!(
733 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
734 );
735 }
736
737 if let Some(raw) = fallback_raw {
738 if !raw.is_table() {
739 bail!(
740 "agent '{name}': fallback is a whole agent, so write it as a table:\n [agents.{name}.fallback]\n preset = \"cursor\""
741 );
742 }
743 let backup = build_spec(&format!("{name}-fallback"), &raw)?;
746 if backup.fallback.is_some() {
747 bail!(
748 "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
749 another full timeout on a call that has already failed once."
750 );
751 }
752 spec.fallback = Some(Box::new(backup));
753 }
754
755 Ok(spec)
756}
757
758#[derive(Debug, Clone)]
764pub struct OptionInfo {
765 pub section: &'static str,
766 pub key: String,
767 pub default: String,
768}
769
770pub fn known_options() -> Vec<OptionInfo> {
776 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
777 toml::to_string(value)
778 .unwrap_or_default()
779 .lines()
780 .filter_map(|line| line.split_once(" = "))
781 .map(|(key, default)| OptionInfo {
782 section,
783 key: key.trim().to_string(),
784 default: default.trim().to_string(),
785 })
786 .collect()
787 }
788 let mut out = lines("loop", &LoopCfg::default());
789 out.extend(lines("style", &StyleCfg::default()));
790 out.extend(lines(
791 "loop.effort_schedule",
792 &EffortSchedule {
793 round_1: Some("high".into()),
794 rest: Some("low".into()),
795 },
796 ));
797 out
798}
799
800pub fn mentions(config_text: &str, key: &str) -> bool {
802 config_text.lines().any(|line| {
803 let bare = line.trim_start().trim_start_matches('#').trim_start();
804 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
805 })
806}
807
808pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
811 known_options()
812 .into_iter()
813 .filter(|o| !mentions(config_text, &o.key))
814 .collect()
815}
816
817pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
818
819pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
822 if let Some(path) = explicit {
823 if !path.is_file() {
824 bail!("config not found: {}", path.display());
825 }
826 return Ok(Some(path.to_path_buf()));
827 }
828 for name in CONFIG_NAMES {
829 let path = PathBuf::from(name);
830 if path.is_file() {
831 return Ok(Some(path));
832 }
833 }
834 if let Some(home) = home_dir() {
835 let path = home.join(".config").join("spar").join("spar.toml");
836 if path.is_file() {
837 return Ok(Some(path));
838 }
839 }
840 Ok(None)
841}
842
843pub fn load(explicit: Option<&Path>) -> Result<Config> {
844 let Some(path) = find_config(explicit)? else {
845 bail!(
846 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
847 );
848 };
849 let text = std::fs::read_to_string(&path)
850 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
851 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
852 cfg.source = Some(path);
853 Ok(cfg)
854}
855
856pub fn parse(text: &str) -> Result<Config> {
857 let raw: RawConfig = toml::from_str(text)?;
858
859 if raw.agents.len() != 2 {
860 bail!(
861 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
862 raw.agents.len()
863 );
864 }
865
866 let mut agents = Vec::new();
867 for (name, value) in raw.agents.iter() {
868 agents.push(build_spec(name, value)?);
869 }
870
871 let loop_cfg = raw.loop_cfg.unwrap_or_default();
872 let style = raw.style.unwrap_or_default().to_style();
873
874 if loop_cfg.max_rounds == 0 {
875 bail!("max_rounds must be at least 1");
876 }
877 if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
882 bail!(
883 "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
884 ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
885 to have it promoted when the review converges, or turn auto_merge off."
886 );
887 }
888
889 let first = match &loop_cfg.first_implementor {
890 Some(name) if !name.trim().is_empty() => name.trim().to_string(),
891 _ => agents[0].name.clone(),
892 };
893 if !agents.iter().any(|a| a.name == first) {
894 bail!(
895 "first_implementor '{first}' is not a configured agent ({})",
896 agents
897 .iter()
898 .map(|a| a.name.as_str())
899 .collect::<Vec<_>>()
900 .join(", ")
901 );
902 }
903
904 Ok(Config {
905 agents,
906 loop_cfg,
907 style,
908 first_implementor: first,
909 source: None,
910 })
911}
912
913pub fn resolve_search_path(raw: &str) -> PathBuf {
915 expand_tilde(raw)
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921
922 const TWO_AGENTS: &str = r#"
923[agents.claude]
924preset = "claude"
925model = "fable"
926
927[agents.codex]
928preset = "codex"
929model = "gpt-5.6-sol"
930"#;
931
932 #[test]
935 fn a_fallback_is_a_whole_agent_with_its_own_preset() {
936 let text = format!(
937 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
938 );
939 let cfg = parse(&text).expect("parses");
940 assert_eq!(2, cfg.agents.len());
942 let codex = cfg.spec("codex").expect("codex");
943 let backup = codex.fallback.as_ref().expect("fallback");
944 assert_eq!("codex-fallback", backup.name);
945 assert_eq!(Some("kimi-k3"), backup.model.as_deref());
946 assert_eq!(
947 Some(&CommandPart::One("cursor-agent".into())),
948 backup.command.first()
949 );
950 }
951
952 #[test]
953 fn the_agent_without_a_fallback_does_not_grow_one() {
954 let cfg = parse(TWO_AGENTS).expect("parses");
955 assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
956 }
957
958 #[test]
959 fn a_fallback_may_not_have_one_of_its_own() {
960 let text = format!(
961 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
962 [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
963 );
964 let err = parse(&text).expect_err("rejected");
965 assert!(err.message().contains("may not have a fallback"), "{err}");
966 }
967
968 #[test]
969 fn a_fallback_written_as_a_string_says_what_it_should_be() {
970 let text = "[agents.claude]\npreset = \"claude\"\n\n\
971 [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
972 let err = parse(text).expect_err("rejected");
973 assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
974 }
975
976 #[test]
981 fn a_partial_block_keeps_the_defaults_it_did_not_name() {
982 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
983 let cfg = parse(&text).expect("parses");
984
985 assert_eq!(9, cfg.loop_cfg.max_rounds);
986 assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
987 assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
988
989 assert!(!cfg.style.terse);
990 assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
991 assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
992 }
993
994 #[test]
998 fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
999 assert_eq!(Style::default(), StyleCfg::default().to_style());
1000 }
1001
1002 #[test]
1005 fn pull_requests_are_not_drafts_unless_asked_for() {
1006 assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1007 }
1008
1009 #[test]
1010 fn each_draft_setting_parses() {
1011 for (text, want) in [
1012 ("never", Drafts::Never),
1013 ("until_approved", Drafts::UntilApproved),
1014 ("always", Drafts::Always),
1015 ] {
1016 let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1017 .unwrap_or_else(|e| panic!("{text}: {e}"));
1018 assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1019 }
1020 }
1021
1022 #[test]
1026 fn auto_merge_and_a_permanent_draft_are_refused_together() {
1027 let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1028 let err = parse(&text).expect_err("refused");
1029 assert!(err.message().contains("auto_merge"), "{err}");
1030 assert!(
1031 err.message().contains("until_approved"),
1032 "says the way out: {err}"
1033 );
1034 }
1035
1036 #[test]
1039 fn auto_merge_is_fine_with_a_draft_that_clears() {
1040 let text =
1041 format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1042 assert!(parse(&text).is_ok());
1043 }
1044
1045 #[test]
1046 fn every_builtin_preset_parses() {
1047 for (name, _) in BUILTIN_PRESETS {
1048 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1049 assert!(value.get("command").is_some(), "{name} has no command");
1050 }
1051 }
1052
1053 #[test]
1054 fn every_builtin_preset_builds_a_spec() {
1055 for (name, _) in BUILTIN_PRESETS {
1056 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1057 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1058 }
1059 }
1060
1061 #[test]
1065 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1066 let spec = build_spec(
1067 "claude",
1068 &parse_document("preset = \"claude\"", "test").unwrap(),
1069 )
1070 .unwrap();
1071 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1072 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1073 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1074 }
1075
1076 #[test]
1077 fn codex_preset_declares_where_its_answer_lives() {
1078 let spec = build_spec(
1079 "codex",
1080 &parse_document("preset = \"codex\"", "test").unwrap(),
1081 )
1082 .unwrap();
1083 assert_eq!(OutputMode::Jsonl, spec.output);
1084 assert_eq!(Some("item.text"), spec.message_path.as_deref());
1085 assert!(!spec.message_match.is_empty());
1086 }
1087
1088 #[test]
1089 fn agent_order_follows_declaration_order() {
1090 let cfg = parse(TWO_AGENTS).unwrap();
1091 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1092 assert_eq!("claude", cfg.first_implementor);
1093 }
1094
1095 #[test]
1096 fn other_alternates() {
1097 let cfg = parse(TWO_AGENTS).unwrap();
1098 assert_eq!("codex", cfg.other("claude"));
1099 assert_eq!("claude", cfg.other("codex"));
1100 }
1101
1102 #[test]
1103 fn a_config_block_overrides_one_preset_field() {
1104 let cfg = parse(TWO_AGENTS).unwrap();
1105 let claude = cfg.spec("claude").unwrap();
1106 assert_eq!(Some("fable"), claude.model.as_deref());
1107 assert!(claude.command.len() > 1, "the preset command survived");
1108 }
1109
1110 #[test]
1111 fn exactly_two_agents_are_required() {
1112 let one = "[agents.claude]\npreset = \"claude\"\n";
1113 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1114 }
1115
1116 #[test]
1117 fn an_unknown_agent_option_is_named() {
1118 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1119 let err = parse(text).unwrap_err().to_string();
1120 assert!(err.contains("widget"), "{err}");
1121 }
1122
1123 #[test]
1124 fn an_unknown_loop_option_is_named() {
1125 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1126 let err = parse(&text).unwrap_err().to_string();
1127 assert!(err.contains("max_round"), "{err}");
1128 }
1129
1130 #[test]
1131 fn an_agent_with_no_command_and_no_preset_is_rejected() {
1132 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1133 let err = parse(text).unwrap_err().to_string();
1134 assert!(err.contains("no command and no preset"), "{err}");
1135 }
1136
1137 #[test]
1138 fn jsonl_without_a_message_path_is_rejected() {
1139 let text =
1140 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1141 let err = parse(text).unwrap_err().to_string();
1142 assert!(err.contains("message_path"), "{err}");
1143 }
1144
1145 #[test]
1146 fn first_implementor_must_name_a_configured_agent() {
1147 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1148 let err = parse(&text).unwrap_err().to_string();
1149 assert!(err.contains("not a configured agent"), "{err}");
1150 }
1151
1152 #[test]
1153 fn defaults_are_the_conservative_ones() {
1154 let cfg = parse(TWO_AGENTS).unwrap();
1155 assert!(
1156 !cfg.loop_cfg.auto_merge,
1157 "auto_merge must be off by default"
1158 );
1159 assert!(cfg.loop_cfg.worktrees);
1160 assert!(
1161 !cfg.loop_cfg.file_nits,
1162 "a filed nit is somebody else's triage queue"
1163 );
1164 assert_eq!(3, cfg.loop_cfg.max_rounds);
1165 assert_eq!(
1166 Followups::Local,
1167 cfg.loop_cfg.followups,
1168 "the tracker is somebody's queue; the default must not write to it"
1169 );
1170 assert!(
1171 !cfg.loop_cfg.file_non_blocking,
1172 "a suggestion is not a tracker item"
1173 );
1174 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1175 assert!(cfg.style.terse);
1176 }
1177
1178 #[test]
1179 fn effort_schedule_splits_round_one_from_the_rest() {
1180 let text =
1181 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1182 let cfg = parse(&text).unwrap();
1183 let spec = cfg.spec("claude").unwrap();
1184 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1185 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1186 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1187 }
1188
1189 #[test]
1190 fn effort_falls_back_to_the_agents_own_setting() {
1191 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1192 let cfg = parse(&text).unwrap();
1193 let spec = cfg.spec("codex").unwrap();
1194 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1195 }
1196
1197 #[test]
1198 fn an_unset_model_and_an_empty_model_normalise_the_same() {
1199 let a = AgentSpec {
1200 name: "a".into(),
1201 command: vec![CommandPart::One("x".into())],
1202 model: None,
1203 effort: None,
1204 output: OutputMode::Text,
1205 message_match: BTreeMap::new(),
1206 message_path: None,
1207 search_paths: vec![],
1208 system_via: SystemVia::Prompt,
1209 timeout: 60,
1210 fallback: None,
1211 models: vec![],
1212 efforts: vec![],
1213 options_note: None,
1214 };
1215 let b = AgentSpec {
1216 model: Some(" ".into()),
1217 ..a.clone()
1218 };
1219 assert_eq!(a.model_key(), b.model_key());
1220 }
1221
1222 #[test]
1223 fn max_rounds_zero_is_rejected() {
1224 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1225 assert!(parse(&text).is_err());
1226 }
1227
1228 #[test]
1229 fn an_inline_command_needs_no_preset() {
1230 let text = r#"
1231[agents.custom]
1232command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1233output = "text"
1234
1235[agents.other]
1236command = ["othertool", "{prompt}"]
1237"#;
1238 let cfg = parse(text).unwrap();
1239 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1240 }
1241
1242 #[test]
1243 fn style_budgets_are_configurable() {
1244 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1245 let cfg = parse(&text).unwrap();
1246 assert!(!cfg.style.terse);
1247 assert_eq!(40, cfg.style.max_detail_chars);
1248 }
1249}