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)]
191#[serde(rename_all = "lowercase")]
192pub enum PrComments {
193 Outcome,
195 Rounds,
198 None,
200}
201
202impl std::fmt::Display for PrComments {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 f.write_str(match self {
205 PrComments::Outcome => "outcome",
206 PrComments::Rounds => "rounds",
207 PrComments::None => "none",
208 })
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "lowercase")]
217pub enum StateStore {
218 Local,
219 Pr,
220 Both,
221}
222
223impl StateStore {
224 pub fn writes_local(self) -> bool {
225 matches!(self, StateStore::Local | StateStore::Both)
226 }
227 pub fn writes_pr(self) -> bool {
228 matches!(self, StateStore::Pr | StateStore::Both)
229 }
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct EffortSchedule {
235 pub round_1: Option<String>,
237 pub rest: Option<String>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(default, deny_unknown_fields)]
246pub struct LoopCfg {
247 pub max_rounds: u32,
248 pub auto_merge: bool,
249 pub first_implementor: Option<String>,
250 pub base_branch: String,
251 pub worktrees: bool,
252 pub keep_worktrees: bool,
253 pub state_store: StateStore,
254 pub branch_prefix: String,
255 pub followups: Followups,
256 pub file_non_blocking: bool,
264 pub max_followups: usize,
267 pub file_nits: bool,
271 pub close_skipped: bool,
274 pub parallel_triage: bool,
277 pub min_number: i64,
285 pub absorb_new_issues: u32,
292 pub max_issue_chars: usize,
300 pub max_triage_chars: usize,
308 pub effort_schedule: EffortSchedule,
309}
310
311impl Default for LoopCfg {
312 fn default() -> Self {
313 Self {
314 max_rounds: 3,
315 auto_merge: false,
316 first_implementor: None,
317 base_branch: "main".into(),
318 worktrees: true,
319 keep_worktrees: false,
320 state_store: StateStore::Local,
321 branch_prefix: String::new(),
322 followups: Followups::Local,
323 file_non_blocking: false,
324 max_followups: 5,
325 file_nits: false,
326 close_skipped: true,
327 parallel_triage: true,
328 min_number: 0,
329 absorb_new_issues: 0,
330 max_issue_chars: 60_000,
331 max_triage_chars: 200_000,
332 effort_schedule: EffortSchedule::default(),
333 }
334 }
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(default, deny_unknown_fields)]
339pub struct StyleCfg {
340 pub ban_em_dash: bool,
341 pub ban_ai_attribution: bool,
342 pub terse: bool,
343 pub max_detail_chars: usize,
344 pub max_summary_chars: usize,
345 pub max_body_chars: usize,
346 pub max_issue_body_chars: usize,
350 pub max_title_chars: usize,
351 pub pr_comments: PrComments,
352}
353
354impl Default for StyleCfg {
355 fn default() -> Self {
365 let style = Style::default();
366 Self {
367 ban_em_dash: style.ban_em_dash,
368 ban_ai_attribution: style.ban_ai_attribution,
369 terse: style.terse,
370 max_detail_chars: style.max_detail_chars,
371 max_summary_chars: style.max_summary_chars,
372 max_body_chars: style.max_body_chars,
373 max_issue_body_chars: style.max_issue_body_chars,
374 max_title_chars: style.max_title_chars,
375 pr_comments: style.pr_comments,
376 }
377 }
378}
379
380impl StyleCfg {
381 pub fn to_style(&self) -> Style {
382 Style {
383 ban_em_dash: self.ban_em_dash,
384 ban_ai_attribution: self.ban_ai_attribution,
385 terse: self.terse,
386 max_detail_chars: self.max_detail_chars,
387 max_summary_chars: self.max_summary_chars,
388 max_body_chars: self.max_body_chars,
389 max_issue_body_chars: self.max_issue_body_chars,
390 max_title_chars: self.max_title_chars,
391 pr_comments: self.pr_comments,
392 }
393 }
394}
395
396#[derive(Debug, Clone)]
401pub struct Config {
402 pub agents: Vec<AgentSpec>,
404 pub loop_cfg: LoopCfg,
405 pub style: Style,
406 pub first_implementor: String,
408 pub source: Option<PathBuf>,
410}
411
412impl Config {
413 pub fn agent_names(&self) -> Vec<String> {
414 self.agents.iter().map(|a| a.name.clone()).collect()
415 }
416
417 pub fn has_agent(&self, name: &str) -> bool {
418 self.agents.iter().any(|a| a.name == name)
419 }
420
421 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
422 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
423 spar_err!(
424 "no agent named '{name}' ({})",
425 self.agent_names().join(", ")
426 )
427 })
428 }
429
430 pub fn other(&self, name: &str) -> String {
433 let names = self.agent_names();
434 if names.first().map(String::as_str) == Some(name) {
435 names.get(1).cloned().unwrap_or_else(|| name.to_string())
436 } else {
437 names.first().cloned().unwrap_or_else(|| name.to_string())
438 }
439 }
440
441 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
444 let scheduled = if round <= 1 {
445 self.loop_cfg.effort_schedule.round_1.clone()
446 } else {
447 self.loop_cfg.effort_schedule.rest.clone()
448 };
449 scheduled
450 .filter(|s| !s.trim().is_empty())
451 .or_else(|| spec.effort.clone())
452 }
453
454 pub fn base_branch(&self) -> &str {
455 &self.loop_cfg.base_branch
456 }
457}
458
459#[derive(Debug, Deserialize)]
460#[serde(deny_unknown_fields)]
461struct RawConfig {
462 #[serde(default)]
463 agents: toml::Table,
464 #[serde(default)]
465 #[serde(rename = "loop")]
466 loop_cfg: Option<LoopCfg>,
467 #[serde(default)]
468 style: Option<StyleCfg>,
469}
470
471pub fn preset_dirs() -> Vec<PathBuf> {
484 let mut dirs = Vec::new();
485 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
486 dirs.push(PathBuf::from(custom));
487 }
488 dirs.push(PathBuf::from(".spar").join("presets"));
489 if let Some(home) = home_dir() {
490 dirs.push(home.join(".config").join("spar").join("presets"));
491 }
492 dirs
493}
494
495pub fn available_presets() -> Vec<String> {
497 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
498 for dir in preset_dirs() {
499 if let Ok(entries) = std::fs::read_dir(&dir) {
500 for entry in entries.flatten() {
501 let path = entry.path();
502 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
503 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
504 names.push(stem.to_string());
505 }
506 }
507 }
508 }
509 }
510 names.sort();
511 names.dedup();
512 names
513}
514
515fn parse_document(text: &str, what: &str) -> Result<Value> {
520 let table: toml::Table =
521 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
522 Ok(Value::Table(table))
523}
524
525pub fn load_preset(name: &str) -> Result<Value> {
528 for dir in preset_dirs() {
529 let path = dir.join(format!("{name}.toml"));
530 if path.is_file() {
531 let text = std::fs::read_to_string(&path)
532 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
533 return parse_document(&text, &format!("preset {}", path.display()));
534 }
535 }
536 for (builtin, text) in BUILTIN_PRESETS {
537 if *builtin == name {
538 return parse_document(text, &format!("built in preset {name}"));
539 }
540 }
541 Err(spar_err!(
542 "unknown preset '{name}'. Available: {}",
543 available_presets().join(", ")
544 ))
545}
546
547fn merge(base: &Value, over: &Value) -> Value {
550 match (base, over) {
551 (Value::Table(b), Value::Table(o)) => {
552 let mut out = b.clone();
553 for (key, value) in o {
554 let merged = match out.get(key) {
555 Some(existing) => merge(existing, value),
556 None => value.clone(),
557 };
558 out.insert(key.clone(), merged);
559 }
560 Value::Table(out)
561 }
562 _ => over.clone(),
563 }
564}
565
566fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
567 let table = raw
568 .as_table()
569 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
570
571 let merged = match table.get("preset").and_then(Value::as_str) {
572 Some(preset) => merge(&load_preset(preset)?, raw),
573 None => raw.clone(),
574 };
575
576 let mut merged_table = merged
577 .as_table()
578 .cloned()
579 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
580 merged_table.remove("preset");
581 let fallback_raw = merged_table.remove("fallback");
584
585 if !merged_table.contains_key("command") {
586 bail!(
587 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
588 available_presets().join(", ")
589 );
590 }
591
592 let mut spec: AgentSpec = Value::Table(merged_table)
593 .try_into()
594 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
595 spec.name = name.to_string();
596
597 if spec.command.is_empty() {
598 bail!("agent '{name}' has an empty command");
599 }
600 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
601 bail!("agent '{name}': the first command element must be the program name, not a group");
602 }
603 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
604 bail!(
605 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
606 );
607 }
608
609 if let Some(raw) = fallback_raw {
610 if !raw.is_table() {
611 bail!(
612 "agent '{name}': fallback is a whole agent, so write it as a table:\n [agents.{name}.fallback]\n preset = \"cursor\""
613 );
614 }
615 let backup = build_spec(&format!("{name}-fallback"), &raw)?;
618 if backup.fallback.is_some() {
619 bail!(
620 "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
621 another full timeout on a call that has already failed once."
622 );
623 }
624 spec.fallback = Some(Box::new(backup));
625 }
626
627 Ok(spec)
628}
629
630#[derive(Debug, Clone)]
636pub struct OptionInfo {
637 pub section: &'static str,
638 pub key: String,
639 pub default: String,
640}
641
642pub fn known_options() -> Vec<OptionInfo> {
648 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
649 toml::to_string(value)
650 .unwrap_or_default()
651 .lines()
652 .filter_map(|line| line.split_once(" = "))
653 .map(|(key, default)| OptionInfo {
654 section,
655 key: key.trim().to_string(),
656 default: default.trim().to_string(),
657 })
658 .collect()
659 }
660 let mut out = lines("loop", &LoopCfg::default());
661 out.extend(lines("style", &StyleCfg::default()));
662 out.extend(lines(
663 "loop.effort_schedule",
664 &EffortSchedule {
665 round_1: Some("high".into()),
666 rest: Some("low".into()),
667 },
668 ));
669 out
670}
671
672pub fn mentions(config_text: &str, key: &str) -> bool {
674 config_text.lines().any(|line| {
675 let bare = line.trim_start().trim_start_matches('#').trim_start();
676 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
677 })
678}
679
680pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
683 known_options()
684 .into_iter()
685 .filter(|o| !mentions(config_text, &o.key))
686 .collect()
687}
688
689pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
690
691pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
694 if let Some(path) = explicit {
695 if !path.is_file() {
696 bail!("config not found: {}", path.display());
697 }
698 return Ok(Some(path.to_path_buf()));
699 }
700 for name in CONFIG_NAMES {
701 let path = PathBuf::from(name);
702 if path.is_file() {
703 return Ok(Some(path));
704 }
705 }
706 if let Some(home) = home_dir() {
707 let path = home.join(".config").join("spar").join("spar.toml");
708 if path.is_file() {
709 return Ok(Some(path));
710 }
711 }
712 Ok(None)
713}
714
715pub fn load(explicit: Option<&Path>) -> Result<Config> {
716 let Some(path) = find_config(explicit)? else {
717 bail!(
718 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
719 );
720 };
721 let text = std::fs::read_to_string(&path)
722 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
723 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
724 cfg.source = Some(path);
725 Ok(cfg)
726}
727
728pub fn parse(text: &str) -> Result<Config> {
729 let raw: RawConfig = toml::from_str(text)?;
730
731 if raw.agents.len() != 2 {
732 bail!(
733 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
734 raw.agents.len()
735 );
736 }
737
738 let mut agents = Vec::new();
739 for (name, value) in raw.agents.iter() {
740 agents.push(build_spec(name, value)?);
741 }
742
743 let loop_cfg = raw.loop_cfg.unwrap_or_default();
744 let style = raw.style.unwrap_or_default().to_style();
745
746 if loop_cfg.max_rounds == 0 {
747 bail!("max_rounds must be at least 1");
748 }
749
750 let first = match &loop_cfg.first_implementor {
751 Some(name) if !name.trim().is_empty() => name.trim().to_string(),
752 _ => agents[0].name.clone(),
753 };
754 if !agents.iter().any(|a| a.name == first) {
755 bail!(
756 "first_implementor '{first}' is not a configured agent ({})",
757 agents
758 .iter()
759 .map(|a| a.name.as_str())
760 .collect::<Vec<_>>()
761 .join(", ")
762 );
763 }
764
765 Ok(Config {
766 agents,
767 loop_cfg,
768 style,
769 first_implementor: first,
770 source: None,
771 })
772}
773
774pub fn resolve_search_path(raw: &str) -> PathBuf {
776 expand_tilde(raw)
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 const TWO_AGENTS: &str = r#"
784[agents.claude]
785preset = "claude"
786model = "fable"
787
788[agents.codex]
789preset = "codex"
790model = "gpt-5.6-sol"
791"#;
792
793 #[test]
796 fn a_fallback_is_a_whole_agent_with_its_own_preset() {
797 let text = format!(
798 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
799 );
800 let cfg = parse(&text).expect("parses");
801 assert_eq!(2, cfg.agents.len());
803 let codex = cfg.spec("codex").expect("codex");
804 let backup = codex.fallback.as_ref().expect("fallback");
805 assert_eq!("codex-fallback", backup.name);
806 assert_eq!(Some("kimi-k3"), backup.model.as_deref());
807 assert_eq!(
808 Some(&CommandPart::One("cursor-agent".into())),
809 backup.command.first()
810 );
811 }
812
813 #[test]
814 fn the_agent_without_a_fallback_does_not_grow_one() {
815 let cfg = parse(TWO_AGENTS).expect("parses");
816 assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
817 }
818
819 #[test]
820 fn a_fallback_may_not_have_one_of_its_own() {
821 let text = format!(
822 "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
823 [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
824 );
825 let err = parse(&text).expect_err("rejected");
826 assert!(err.message().contains("may not have a fallback"), "{err}");
827 }
828
829 #[test]
830 fn a_fallback_written_as_a_string_says_what_it_should_be() {
831 let text = "[agents.claude]\npreset = \"claude\"\n\n\
832 [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
833 let err = parse(text).expect_err("rejected");
834 assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
835 }
836
837 #[test]
842 fn a_partial_block_keeps_the_defaults_it_did_not_name() {
843 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
844 let cfg = parse(&text).expect("parses");
845
846 assert_eq!(9, cfg.loop_cfg.max_rounds);
847 assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
848 assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
849
850 assert!(!cfg.style.terse);
851 assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
852 assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
853 }
854
855 #[test]
859 fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
860 assert_eq!(Style::default(), StyleCfg::default().to_style());
861 }
862
863 #[test]
864 fn every_builtin_preset_parses() {
865 for (name, _) in BUILTIN_PRESETS {
866 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
867 assert!(value.get("command").is_some(), "{name} has no command");
868 }
869 }
870
871 #[test]
872 fn every_builtin_preset_builds_a_spec() {
873 for (name, _) in BUILTIN_PRESETS {
874 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
875 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
876 }
877 }
878
879 #[test]
883 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
884 let spec = build_spec(
885 "claude",
886 &parse_document("preset = \"claude\"", "test").unwrap(),
887 )
888 .unwrap();
889 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
890 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
891 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
892 }
893
894 #[test]
895 fn codex_preset_declares_where_its_answer_lives() {
896 let spec = build_spec(
897 "codex",
898 &parse_document("preset = \"codex\"", "test").unwrap(),
899 )
900 .unwrap();
901 assert_eq!(OutputMode::Jsonl, spec.output);
902 assert_eq!(Some("item.text"), spec.message_path.as_deref());
903 assert!(!spec.message_match.is_empty());
904 }
905
906 #[test]
907 fn agent_order_follows_declaration_order() {
908 let cfg = parse(TWO_AGENTS).unwrap();
909 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
910 assert_eq!("claude", cfg.first_implementor);
911 }
912
913 #[test]
914 fn other_alternates() {
915 let cfg = parse(TWO_AGENTS).unwrap();
916 assert_eq!("codex", cfg.other("claude"));
917 assert_eq!("claude", cfg.other("codex"));
918 }
919
920 #[test]
921 fn a_config_block_overrides_one_preset_field() {
922 let cfg = parse(TWO_AGENTS).unwrap();
923 let claude = cfg.spec("claude").unwrap();
924 assert_eq!(Some("fable"), claude.model.as_deref());
925 assert!(claude.command.len() > 1, "the preset command survived");
926 }
927
928 #[test]
929 fn exactly_two_agents_are_required() {
930 let one = "[agents.claude]\npreset = \"claude\"\n";
931 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
932 }
933
934 #[test]
935 fn an_unknown_agent_option_is_named() {
936 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
937 let err = parse(text).unwrap_err().to_string();
938 assert!(err.contains("widget"), "{err}");
939 }
940
941 #[test]
942 fn an_unknown_loop_option_is_named() {
943 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
944 let err = parse(&text).unwrap_err().to_string();
945 assert!(err.contains("max_round"), "{err}");
946 }
947
948 #[test]
949 fn an_agent_with_no_command_and_no_preset_is_rejected() {
950 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
951 let err = parse(text).unwrap_err().to_string();
952 assert!(err.contains("no command and no preset"), "{err}");
953 }
954
955 #[test]
956 fn jsonl_without_a_message_path_is_rejected() {
957 let text =
958 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
959 let err = parse(text).unwrap_err().to_string();
960 assert!(err.contains("message_path"), "{err}");
961 }
962
963 #[test]
964 fn first_implementor_must_name_a_configured_agent() {
965 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
966 let err = parse(&text).unwrap_err().to_string();
967 assert!(err.contains("not a configured agent"), "{err}");
968 }
969
970 #[test]
971 fn defaults_are_the_conservative_ones() {
972 let cfg = parse(TWO_AGENTS).unwrap();
973 assert!(
974 !cfg.loop_cfg.auto_merge,
975 "auto_merge must be off by default"
976 );
977 assert!(cfg.loop_cfg.worktrees);
978 assert!(
979 !cfg.loop_cfg.file_nits,
980 "a filed nit is somebody else's triage queue"
981 );
982 assert_eq!(3, cfg.loop_cfg.max_rounds);
983 assert_eq!(
984 Followups::Local,
985 cfg.loop_cfg.followups,
986 "the tracker is somebody's queue; the default must not write to it"
987 );
988 assert!(
989 !cfg.loop_cfg.file_non_blocking,
990 "a suggestion is not a tracker item"
991 );
992 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
993 assert!(cfg.style.terse);
994 }
995
996 #[test]
997 fn effort_schedule_splits_round_one_from_the_rest() {
998 let text =
999 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1000 let cfg = parse(&text).unwrap();
1001 let spec = cfg.spec("claude").unwrap();
1002 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1003 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1004 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1005 }
1006
1007 #[test]
1008 fn effort_falls_back_to_the_agents_own_setting() {
1009 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1010 let cfg = parse(&text).unwrap();
1011 let spec = cfg.spec("codex").unwrap();
1012 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1013 }
1014
1015 #[test]
1016 fn an_unset_model_and_an_empty_model_normalise_the_same() {
1017 let a = AgentSpec {
1018 name: "a".into(),
1019 command: vec![CommandPart::One("x".into())],
1020 model: None,
1021 effort: None,
1022 output: OutputMode::Text,
1023 message_match: BTreeMap::new(),
1024 message_path: None,
1025 search_paths: vec![],
1026 system_via: SystemVia::Prompt,
1027 timeout: 60,
1028 fallback: None,
1029 models: vec![],
1030 efforts: vec![],
1031 options_note: None,
1032 };
1033 let b = AgentSpec {
1034 model: Some(" ".into()),
1035 ..a.clone()
1036 };
1037 assert_eq!(a.model_key(), b.model_key());
1038 }
1039
1040 #[test]
1041 fn max_rounds_zero_is_rejected() {
1042 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1043 assert!(parse(&text).is_err());
1044 }
1045
1046 #[test]
1047 fn an_inline_command_needs_no_preset() {
1048 let text = r#"
1049[agents.custom]
1050command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1051output = "text"
1052
1053[agents.other]
1054command = ["othertool", "{prompt}"]
1055"#;
1056 let cfg = parse(text).unwrap();
1057 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1058 }
1059
1060 #[test]
1061 fn style_budgets_are_configurable() {
1062 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1063 let cfg = parse(&text).unwrap();
1064 assert!(!cfg.style.terse);
1065 assert_eq!(40, cfg.style.max_detail_chars);
1066 }
1067}