1use std::collections::BTreeMap;
42use std::path::{Path, PathBuf};
43
44use anyhow::{Context as _, Result, bail};
45use serde::{Deserialize, Serialize};
46
47#[derive(Debug, thiserror::Error)]
50#[error("{stage}: {target}")]
51pub(crate) struct ConfigFailure {
52 pub(crate) stage: &'static str,
53 pub(crate) target: String,
54}
55
56impl ConfigFailure {
57 fn render(error: &teravars::Error, paths: &[PathBuf]) -> Self {
58 let stage = match error {
59 teravars::Error::Io(_) => "config-read",
60 teravars::Error::Toml(_) | teravars::Error::Extract(_) => "config-parse",
61 teravars::Error::Render(_) => "config-template",
62 teravars::Error::IncludeCycle { .. } | teravars::Error::IncludeConflict { .. } => {
63 "config-include"
64 }
65 _ => "config-render",
66 };
67 Self::layers(stage, paths)
68 }
69
70 fn layers(stage: &'static str, paths: &[PathBuf]) -> Self {
71 let targets: Vec<_> = paths
72 .iter()
73 .map(|path| {
74 path.ancestors()
77 .find(|p| p.join(REPO_MARKER).is_file())
78 .and_then(|root| path.strip_prefix(root).ok())
79 .unwrap_or(path)
80 .to_string_lossy()
81 .into_owned()
82 })
83 .collect();
84 Self {
85 stage,
86 target: serde_json::to_string(&targets).expect("config paths"),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
93#[serde(rename_all = "lowercase")]
94pub enum AgentKind {
95 Claude,
97 Opencode,
99 Antigravity,
103 Codex,
107 Omp,
113 Command,
115}
116
117impl AgentKind {
118 pub fn program(self) -> Option<&'static str> {
120 match self {
121 Self::Claude => Some("claude"),
122 Self::Opencode => Some("opencode"),
123 Self::Antigravity => Some("agy"),
124 Self::Codex => Some("codex"),
125 Self::Omp => Some("omp"),
126 Self::Command => None,
127 }
128 }
129
130 pub fn as_str(self) -> &'static str {
132 match self {
133 Self::Claude => "claude",
134 Self::Opencode => "opencode",
135 Self::Antigravity => "antigravity",
136 Self::Codex => "codex",
137 Self::Omp => "omp",
138 Self::Command => "command",
139 }
140 }
141
142 pub const ALL: [Self; 6] = [
150 Self::Claude,
151 Self::Opencode,
152 Self::Antigravity,
153 Self::Codex,
154 Self::Omp,
155 Self::Command,
156 ];
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
161#[serde(rename_all = "lowercase")]
162pub enum Delivery {
163 Stdin,
165 Argv,
167 File,
169}
170
171#[derive(Debug, Clone, Deserialize, Serialize)]
173#[serde(deny_unknown_fields)]
174pub struct AgentSpec {
175 pub id: String,
177 pub kind: AgentKind,
179 #[serde(default)]
181 pub model: Option<String>,
182 #[serde(default)]
185 pub command: Vec<String>,
186 #[serde(default)]
188 pub extra_args: Vec<String>,
189 #[serde(default)]
191 pub env: BTreeMap<String, String>,
192 #[serde(default)]
194 pub prompt_delivery: Option<Delivery>,
195}
196
197impl AgentSpec {
198 pub fn delivery(&self) -> Delivery {
204 self.prompt_delivery.unwrap_or(match self.kind {
205 AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
206 AgentKind::Codex => Delivery::Stdin,
210 AgentKind::Omp => Delivery::Stdin,
214 AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
215 })
216 }
217
218 pub fn display(&self) -> String {
220 match &self.model {
221 Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
222 None => format!("{} ({})", self.id, self.kind.as_str()),
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
234#[serde(untagged)]
235pub enum AgentChoice {
236 One(String),
238 Chain(Vec<String>),
240}
241
242impl AgentChoice {
243 pub fn ids(&self) -> Vec<&str> {
245 let all: Vec<&str> = match self {
246 Self::One(id) => vec![id.as_str()],
247 Self::Chain(ids) => ids.iter().map(String::as_str).collect(),
248 };
249 all.into_iter()
250 .map(str::trim)
251 .filter(|id| !id.is_empty())
252 .collect()
253 }
254
255 pub fn primary(&self) -> Option<&str> {
257 self.ids().into_iter().next()
258 }
259}
260
261impl From<&str> for AgentChoice {
262 fn from(id: &str) -> Self {
263 Self::One(id.to_owned())
264 }
265}
266
267pub fn primary(choice: Option<&AgentChoice>) -> Option<&str> {
269 choice.and_then(AgentChoice::primary)
270}
271
272#[derive(Debug, Clone, Default, Deserialize, Serialize)]
275#[serde(deny_unknown_fields, default)]
276pub struct Roles {
277 pub implementers: Vec<String>,
279 pub judges: Vec<String>,
281 pub reviewers: Vec<String>,
283 pub fixer: Option<AgentChoice>,
292 pub chatter: Option<AgentChoice>,
306 pub dupes_judge: Option<AgentChoice>,
310 pub conductor: Option<AgentChoice>,
323 pub advisors: Vec<String>,
334 pub synthesizer: Option<AgentChoice>,
347 pub language_judge: Option<AgentChoice>,
356}
357
358#[derive(Debug, Clone, Deserialize, Serialize)]
360#[serde(deny_unknown_fields, default)]
361pub struct Graph {
362 pub github_text_guard: bool,
364 pub github_language: String,
370 #[serde(alias = "candidates")]
402 pub implementers: usize,
403 #[serde(default)]
405 pub selected_implementer: Option<String>,
406 pub judges: usize,
408 pub deliberate_rounds: usize,
410 pub reviewers: usize,
418 pub review_rounds: usize,
420 pub gate_fix_rounds: usize,
426 pub max_parallel: usize,
428 pub language: String,
430 pub sessions: bool,
438 pub timeout_implement: u64,
440 pub timeout_judge: u64,
442 pub timeout_review: u64,
444 pub timeout_verify: Option<u64>,
453 pub timeout_fix: u64,
455 pub timeout_talk: u64,
463 pub timeout_resource_wait: u64,
472 pub retries: usize,
474 pub worktree_root: Option<PathBuf>,
476 pub land: bool,
489 pub land_rounds: usize,
491 pub rerun_flaky_checks: bool,
499 pub land_approval: bool,
510 pub hold_contested_merge: bool,
521 pub file_followups: bool,
528 pub release_deputy_followups: bool,
536 pub answer_timeout: u64,
540 pub incomplete_review: IncompleteReviewPolicy,
543 pub e2e_every_round: bool,
561 pub advise: bool,
575 pub advisors: usize,
580 pub graft_runner_up: bool,
589}
590
591impl Default for Graph {
592 fn default() -> Self {
593 Self {
594 github_text_guard: true,
595 github_language: "en".into(),
596 implementers: 1,
597 selected_implementer: None,
598 judges: 3,
599 deliberate_rounds: 1,
600 reviewers: 3,
601 review_rounds: 6,
602 gate_fix_rounds: 1,
603 max_parallel: 4,
604 language: "en".to_owned(),
605 sessions: true,
606 timeout_implement: 3600,
607 timeout_judge: 1200,
608 timeout_review: 1200,
609 timeout_verify: None,
610 timeout_fix: 1800,
611 timeout_talk: 3600,
612 timeout_resource_wait: 1800,
613 retries: 1,
614 worktree_root: None,
615 land: true,
616 land_rounds: 4,
617 rerun_flaky_checks: true,
618 land_approval: true,
619 hold_contested_merge: true,
620 file_followups: true,
621 release_deputy_followups: true,
622 answer_timeout: 86_400,
623 incomplete_review: IncompleteReviewPolicy::Block,
624 e2e_every_round: false,
625 advise: true,
626 advisors: 3,
627 graft_runner_up: true,
628 }
629 }
630}
631
632impl Graph {
633 pub fn verify_timeout(&self) -> u64 {
637 self.timeout_verify.unwrap_or(self.timeout_review)
638 }
639
640 pub fn language_gate(&self) -> bool {
643 self.github_text_guard && self.github_english()
644 }
645
646 pub fn github_english(&self) -> bool {
648 crate::lang::is_english(&self.github_language)
649 }
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
659#[serde(rename_all = "lowercase")]
660pub enum IncompleteReviewPolicy {
661 Block,
665 Warn,
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
674#[serde(rename_all = "lowercase")]
675pub enum LeakPolicy {
676 Warn,
678 Redact,
680 Fail,
682}
683
684#[derive(Debug, Clone, Deserialize, Serialize)]
691#[serde(deny_unknown_fields, default)]
692pub struct Blind {
693 pub commit_msg_hook: bool,
696 pub strip_lines: Vec<String>,
700 pub vendor_tokens: Vec<String>,
702 pub on_leak: LeakPolicy,
704 pub seed: Option<u64>,
707}
708
709impl Default for Blind {
710 fn default() -> Self {
711 Self {
712 commit_msg_hook: true,
713 strip_lines: [
714 "Co-Authored-By:",
715 "Signed-off-by:",
716 "Assisted-by:",
717 "Generated-by:",
718 "Generated with",
719 "\u{1f916}",
720 ]
721 .iter()
722 .map(|s| (*s).to_owned())
723 .collect(),
724 vendor_tokens: [
725 "claude",
726 "anthropic",
727 "codex",
728 "openai",
729 "chatgpt",
730 "gemini",
731 "grok",
732 "xai",
733 "copilot",
734 "opencode",
735 "qoder",
736 "cursor",
737 "\u{1f916}",
738 ]
739 .iter()
740 .map(|s| (*s).to_owned())
741 .collect(),
742 on_leak: LeakPolicy::Warn,
743 seed: None,
744 }
745 }
746}
747
748#[derive(Debug, Clone, Default, Deserialize, Serialize)]
750#[serde(deny_unknown_fields, default)]
751pub struct Verify {
752 pub e2e: Vec<String>,
755 pub gate: Vec<String>,
757 #[serde(default)]
766 pub pre_gate: Vec<String>,
767 pub shell: Option<Vec<String>>,
770}
771
772impl Verify {
773 pub fn cache_dir(&self) -> Option<PathBuf> {
779 self.e2e
780 .iter()
781 .chain(self.gate.iter())
782 .find_map(|cmd| crate::disk::extract_cargo_target_dir(cmd))
783 }
784
785 pub fn cargo_manifests(&self) -> Vec<PathBuf> {
788 let all: Vec<String> = self.e2e.iter().chain(self.gate.iter()).cloned().collect();
789 crate::cache::extract_manifest_paths(&all)
790 }
791}
792
793#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
795#[serde(deny_unknown_fields, default)]
796pub struct Worktree {
797 pub setup: Vec<SetupStep>,
800}
801
802#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
804#[serde(deny_unknown_fields, default)]
805pub struct SetupStep {
806 pub copy: Option<String>,
809 pub optional: bool,
811 pub run: Option<String>,
813 pub timeout_secs: Option<u64>,
816}
817
818impl Worktree {
819 pub fn validate(&self) -> Result<()> {
821 for (i, step) in self.setup.iter().enumerate() {
822 let n = i + 1;
823 match (&step.copy, &step.run) {
824 (Some(c), None) => {
825 if step.timeout_secs.is_some() {
826 bail!("[worktree] setup step {n}: `timeout_secs` applies to `run` only");
827 }
828 crate::worktree_setup::validate_copy(c)
829 .with_context(|| format!("[worktree] setup step {n}"))?;
830 }
831 (None, Some(r)) => {
832 if r.trim().is_empty() {
833 bail!("[worktree] setup step {n}: `run` is empty");
834 }
835 if step.optional {
836 bail!("[worktree] setup step {n}: `optional` applies to `copy` only");
837 }
838 }
839 _ => bail!("[worktree] setup step {n} must set exactly one of `copy` and `run`"),
840 }
841 }
842 Ok(())
843 }
844}
845
846#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
856#[serde(deny_unknown_fields, default)]
857pub struct Disk {
858 pub min_free_bytes: u64,
867 pub auto_fold: bool,
873 pub fold_grace_secs: u64,
881 pub cache_limit_bytes: u64,
893 pub worktree_limit_bytes: u64,
895 pub worktree_reclaim_bytes: u64,
897 pub retained_attempt_limit: usize,
901}
902
903impl Default for Disk {
904 fn default() -> Self {
905 Self {
906 min_free_bytes: 8 * 1024 * 1024 * 1024,
907 auto_fold: true,
908 fold_grace_secs: 6 * 60 * 60,
909 cache_limit_bytes: 10 * 1024 * 1024 * 1024,
910 worktree_limit_bytes: 10 * 1024 * 1024 * 1024,
911 worktree_reclaim_bytes: 16 * 1024 * 1024 * 1024,
912 retained_attempt_limit: 8,
913 }
914 }
915}
916
917#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
919#[serde(rename_all = "lowercase")]
920pub enum MergeMode {
921 None,
924 Local,
927 Pr,
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
947#[serde(rename_all = "lowercase")]
948pub enum MergeStyle {
949 #[default]
954 Merge,
955 Squash,
961 Rebase,
967}
968
969#[derive(Debug, Clone, Deserialize, Serialize)]
971#[serde(deny_unknown_fields, default)]
972pub struct Merge {
973 pub mode: MergeMode,
976 pub base: Option<String>,
978 pub style: MergeStyle,
981 pub remote: String,
983 pub release_bump: bool,
996}
997
998impl Default for Merge {
999 fn default() -> Self {
1000 Self {
1001 mode: MergeMode::None,
1002 base: None,
1003 style: MergeStyle::default(),
1004 remote: "origin".to_owned(),
1005 release_bump: true,
1006 }
1007 }
1008}
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1012#[serde(rename_all = "lowercase")]
1013pub enum UpdateMode {
1014 Off,
1016 Notify,
1019 Install,
1021}
1022
1023#[derive(Debug, Clone, Deserialize, Serialize)]
1025#[serde(deny_unknown_fields, default)]
1026pub struct Update {
1027 pub mode: UpdateMode,
1029 pub interval: Option<String>,
1031}
1032
1033impl Default for Update {
1034 fn default() -> Self {
1035 Self {
1036 mode: UpdateMode::Notify,
1037 interval: None,
1038 }
1039 }
1040}
1041
1042#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
1044#[serde(rename_all = "lowercase")]
1045pub enum ReleaseMode {
1046 #[default]
1049 Actions,
1050 Local,
1054}
1055
1056#[derive(Debug, Clone, Deserialize, Serialize)]
1058#[serde(deny_unknown_fields, default)]
1059pub struct Release {
1060 pub mode: ReleaseMode,
1063 pub commands: Vec<String>,
1070 pub timeout_minutes: u64,
1072}
1073
1074impl Default for Release {
1075 fn default() -> Self {
1076 Self {
1077 mode: ReleaseMode::Actions,
1078 commands: Vec::new(),
1079 timeout_minutes: 30,
1080 }
1081 }
1082}
1083
1084impl Release {
1085 pub fn is_local(&self) -> bool {
1087 self.mode == ReleaseMode::Local
1088 }
1089}
1090
1091#[derive(Debug, Clone, Deserialize, Serialize)]
1093#[serde(try_from = "CoordinationFields")]
1094pub struct Coordination {
1095 pub contract_file: String,
1097 pub contract_token: String,
1099 pub lock_file: String,
1101}
1102
1103#[derive(Deserialize, Default)]
1104#[serde(deny_unknown_fields, default)]
1105struct CoordinationFields {
1106 contract_file: String,
1107 contract_token: String,
1108 lock_file: String,
1109}
1110
1111impl TryFrom<CoordinationFields> for Coordination {
1112 type Error = anyhow::Error;
1113
1114 fn try_from(fields: CoordinationFields) -> Result<Self> {
1115 for (key, name) in [
1116 ("coordination.contract_file", &fields.contract_file),
1117 ("coordination.lock_file", &fields.lock_file),
1118 ] {
1119 if name.trim().is_empty()
1120 || name != name.trim()
1121 || name == "."
1122 || name.contains("..")
1123 || name.contains(['/', '\\', ':', '\0'])
1124 {
1125 bail!(
1126 "{key} must be a plain file name inside the Git common directory (no separators or '..')"
1127 );
1128 }
1129 }
1130 let token = &fields.contract_token;
1131 if token.is_empty() || token.trim() != token || token.chars().any(char::is_control) {
1132 bail!(
1133 "coordination.contract_token must be a nonempty single-line token without surrounding whitespace"
1134 );
1135 }
1136 Ok(Self {
1137 contract_file: fields.contract_file,
1138 contract_token: fields.contract_token,
1139 lock_file: fields.lock_file,
1140 })
1141 }
1142}
1143
1144#[cfg(test)]
1145pub(crate) fn test_coordination() -> Option<Coordination> {
1146 Some(Coordination {
1147 contract_file: "updater.contract".into(),
1148 contract_token: "test-protocol-v1".into(),
1149 lock_file: "updater.lock".into(),
1150 })
1151}
1152
1153#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1155#[serde(deny_unknown_fields, default)]
1156pub struct Config {
1157 pub coordination: Option<Coordination>,
1159 pub agents: Vec<AgentSpec>,
1161 pub roles: Roles,
1163 pub graph: Graph,
1165 pub blind: Blind,
1167 pub verify: Verify,
1169 pub worktree: Worktree,
1171 pub disk: Disk,
1173 pub merge: Merge,
1175 pub update: Update,
1177 pub prompts: Prompts,
1179 pub notify: Notify,
1181 pub repos: Repos,
1184 pub talk: Talk,
1186 pub daemon: Daemon,
1188 pub release: Release,
1190 pub context_windows: BTreeMap<String, u64>,
1193}
1194
1195const BUILTIN_CONTEXT_WINDOWS: &[(&str, u64)] = &[
1205 ("claude-opus-4", 200_000),
1206 ("claude-sonnet-4", 200_000),
1207 ("claude-haiku-4", 200_000),
1208 ("claude-3", 200_000),
1209 ("opus", 200_000),
1210 ("sonnet", 200_000),
1211 ("haiku", 200_000),
1212 ("gpt-5", 400_000),
1213 ("gpt-4.1", 1_000_000),
1214 ("gpt-4o", 128_000),
1215 ("o3", 200_000),
1216 ("o4-mini", 200_000),
1217 ("gemini-2.5", 1_000_000),
1218 ("deepseek", 128_000),
1219];
1220
1221impl Config {
1222 pub fn context_window(&self, model: &str) -> Option<u64> {
1227 fn lookup<'a>(model: &str, entries: impl Iterator<Item = (&'a str, u64)>) -> Option<u64> {
1228 let mut best: Option<(usize, u64)> = None;
1229 for (key, window) in entries {
1230 if key == model {
1231 return Some(window);
1232 }
1233 if model.starts_with(key) && best.is_none_or(|(len, _)| key.len() > len) {
1234 best = Some((key.len(), window));
1235 }
1236 }
1237 best.map(|(_, w)| w)
1238 }
1239 lookup(
1240 model,
1241 self.context_windows.iter().map(|(k, w)| (k.as_str(), *w)),
1242 )
1243 .or_else(|| lookup(model, BUILTIN_CONTEXT_WINDOWS.iter().copied()))
1244 }
1245}
1246
1247#[derive(Debug, Clone, Deserialize, Serialize)]
1257#[serde(deny_unknown_fields, default)]
1258pub struct Daemon {
1259 pub max_concurrent_runs: usize,
1280 pub pause_for_interrupts: bool,
1296 pub max_deputies: usize,
1303 pub release_stall_minutes: u64,
1310}
1311
1312impl Default for Daemon {
1313 fn default() -> Self {
1314 Self {
1315 max_concurrent_runs: 1,
1316 pause_for_interrupts: false,
1317 max_deputies: 2,
1318 release_stall_minutes: 60,
1319 }
1320 }
1321}
1322
1323#[derive(Debug, Clone, Deserialize, Serialize)]
1335#[serde(deny_unknown_fields, default)]
1336pub struct Repos {
1337 pub roots: Vec<PathBuf>,
1341 pub scan_ttl: u64,
1348 pub fetch_interval: u64,
1353}
1354
1355impl Default for Repos {
1356 fn default() -> Self {
1357 Self {
1358 roots: Vec::new(),
1359 scan_ttl: 86_400,
1360 fetch_interval: 600,
1361 }
1362 }
1363}
1364
1365#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1381#[serde(deny_unknown_fields, default)]
1382pub struct Prompts {
1383 pub all: String,
1385 pub implement: String,
1387 pub judge: String,
1389 pub review: String,
1391 pub fix: String,
1393}
1394
1395impl Prompts {
1396 pub fn overlay(&self, node: &str) -> Option<String> {
1401 let specific = match node {
1402 "implement" => &self.implement,
1403 "judge" | "vote" | "deliberate" => &self.judge,
1404 "review" => &self.review,
1405 "fix" => &self.fix,
1406 _ => "",
1407 };
1408 let mut parts: Vec<&str> = Vec::new();
1409 for p in [self.all.trim(), specific.trim()] {
1410 if !p.is_empty() {
1411 parts.push(p);
1412 }
1413 }
1414 if parts.is_empty() {
1415 return None;
1416 }
1417 Some(parts.join("\n\n"))
1418 }
1419}
1420
1421#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1428#[serde(deny_unknown_fields, default)]
1429pub struct Notify {
1430 pub command: Vec<String>,
1433}
1434
1435#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1437#[serde(deny_unknown_fields, default)]
1438pub struct Talk {
1439 pub allow_write: bool,
1448 pub personas: Vec<PersonaSpec>,
1453 pub operator_name: Option<String>,
1457}
1458
1459pub const OPERATOR_NAME_MAX: usize = 64;
1461
1462impl Talk {
1463 pub fn operator_name(&self) -> Option<&str> {
1465 self.operator_name
1466 .as_deref()
1467 .map(str::trim)
1468 .filter(|n| !n.is_empty())
1469 }
1470}
1471
1472#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
1474#[serde(deny_unknown_fields)]
1475pub struct PersonaSpec {
1476 pub id: String,
1478 #[serde(default)]
1481 pub name: String,
1482 #[serde(default)]
1485 pub prompt: String,
1486 #[serde(default, skip_serializing_if = "Option::is_none")]
1490 pub icon: Option<String>,
1491}
1492
1493#[derive(Debug, Clone)]
1495pub struct ResolvedRoles {
1496 pub implementers: Vec<AgentSpec>,
1498 pub judges: Vec<AgentSpec>,
1500 pub reviewers: Vec<AgentSpec>,
1502 pub fixer: Option<Vec<AgentSpec>>,
1507 pub conductor: AgentSpec,
1509 pub implementer_roster: Vec<AgentSpec>,
1517 pub judge_roster: Vec<AgentSpec>,
1521 pub reviewer_roster: Vec<AgentSpec>,
1523}
1524
1525fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
1532 let mut out = Vec::new();
1533 for (k, v) in table {
1534 if prefix.is_empty() && k == "vars" {
1535 continue;
1536 }
1537 let path = if prefix.is_empty() {
1538 k.clone()
1539 } else {
1540 format!("{prefix}.{k}")
1541 };
1542 match v {
1543 toml::Value::Array(_) => out.push(path),
1544 toml::Value::Table(t) => out.extend(array_keys(t, &path)),
1545 _ => {}
1546 }
1547 }
1548 out
1549}
1550
1551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1553enum ArrayMerge {
1554 Append,
1558 Replace,
1561}
1562
1563fn array_merge_policy(key: &str) -> ArrayMerge {
1602 match key {
1603 "verify.e2e" | "verify.gate" | "verify.pre_gate" | "repos.roots" => ArrayMerge::Append,
1604 _ => ArrayMerge::Replace,
1605 }
1606}
1607
1608impl Config {
1609 pub fn load(path: &Path) -> Result<Self> {
1612 Self::load_layers(&[path.to_path_buf()])
1613 }
1614
1615 fn render_ctx(paths: &[PathBuf]) -> teravars::Context {
1625 let mut ctx = teravars::system_context();
1626 let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
1627 ctx.insert("env", &env);
1628 if let Some(last) = paths.last()
1629 && let Some(dir) = last.parent()
1630 {
1631 let dir = std::fs::read_to_string(dir.join(REPO_MARKER))
1634 .map(|s| PathBuf::from(s.trim()))
1635 .unwrap_or_else(|_| dir.to_path_buf());
1636 ctx.insert("repo", &dir.to_string_lossy());
1637 ctx.insert(
1638 "repo_name",
1639 &dir.file_name().unwrap_or_default().to_string_lossy(),
1640 );
1641 }
1642 ctx
1643 }
1644
1645 pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
1653 let mut engine = teravars::Engine::default();
1654 let ctx = Self::render_ctx(paths);
1655 if paths.len() > 1 {
1656 Self::refuse_split_arrays(paths, &mut engine, &ctx).map_err(|error| {
1657 if error.downcast_ref::<ConfigFailure>().is_some() {
1658 error
1659 } else {
1660 let message = error.to_string();
1663 error
1664 .context(ConfigFailure::layers("config-merge", paths))
1665 .context(message)
1666 }
1667 })?;
1668 }
1669 let merged = teravars::load_merged(paths, &mut engine, &ctx)
1670 .map_err(|error| {
1671 let cause = ConfigFailure::render(&error, paths);
1672 anyhow::Error::new(error).context(cause)
1673 })
1674 .with_context(|| {
1675 format!(
1676 "rendering config via teravars: {}",
1677 paths
1678 .iter()
1679 .map(|p| p.display().to_string())
1680 .collect::<Vec<_>>()
1681 .join(", ")
1682 )
1683 })?;
1684 let mut table = merged.config;
1685 Self::normalize_renamed_keys(paths, &mut engine, &ctx, &mut table)?;
1686 table.remove("vars");
1689 let detect_agents = !table.contains_key("agents");
1693 let mut cfg: Self = toml::Value::Table(table)
1694 .try_into()
1695 .context("deserializing magi config")
1696 .context(ConfigFailure::layers("config-deserialize", paths))?;
1697 crate::persona::validate(&cfg.talk.personas)?;
1698 cfg.worktree.validate()?;
1699 if let Some(name) = cfg.talk.operator_name() {
1700 if name.chars().count() > OPERATOR_NAME_MAX || name.chars().any(char::is_control) {
1701 bail!(
1702 "[talk] operator_name must be one line of at most {OPERATOR_NAME_MAX} characters"
1703 );
1704 }
1705 }
1706 if detect_agents {
1707 cfg.agents = Self::autodetected().agents;
1708 }
1709 Ok(cfg)
1710 }
1711
1712 fn normalize_renamed_keys(
1720 paths: &[PathBuf],
1721 engine: &mut teravars::Engine,
1722 ctx: &teravars::Context,
1723 table: &mut toml::map::Map<String, toml::Value>,
1724 ) -> Result<()> {
1725 let both = matches!(
1726 table.get("graph"),
1727 Some(toml::Value::Table(g)) if g.contains_key("candidates") && g.contains_key("implementers")
1728 );
1729 if !both {
1730 return Ok(());
1731 }
1732 let mut winner: Option<toml::Value> = None;
1733 for path in paths {
1734 let one = teravars::load_merged([path], engine, ctx)
1735 .map_err(|error| {
1736 let cause = ConfigFailure::render(&error, std::slice::from_ref(path));
1737 anyhow::Error::new(error).context(cause)
1738 })
1739 .with_context(|| format!("rendering {}", path.display()))?;
1740 let Some(toml::Value::Table(g)) = one.config.get("graph") else {
1741 continue;
1742 };
1743 if g.contains_key("candidates") && g.contains_key("implementers") {
1744 bail!(
1745 "`[graph]` sets both `implementers` and its deprecated alias \n `candidates` in {}; keep only `implementers`.",
1746 path.display()
1747 );
1748 }
1749 if let Some(v) = g.get("implementers").or_else(|| g.get("candidates")) {
1750 winner = Some(v.clone());
1751 }
1752 }
1753 if let Some(toml::Value::Table(g)) = table.get_mut("graph") {
1754 g.remove("candidates");
1755 if let Some(v) = winner {
1756 g.insert("implementers".to_owned(), v);
1757 }
1758 }
1759 Ok(())
1760 }
1761
1762 fn refuse_split_arrays(
1786 paths: &[PathBuf],
1787 engine: &mut teravars::Engine,
1788 ctx: &teravars::Context,
1789 ) -> Result<()> {
1790 let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
1791 for path in paths {
1792 let one = teravars::load_merged([path], engine, ctx)
1793 .map_err(|error| {
1794 let cause = ConfigFailure::render(&error, std::slice::from_ref(path));
1795 anyhow::Error::new(error).context(cause)
1796 })
1797 .with_context(|| format!("rendering {}", path.display()))?;
1798 for key in array_keys(&one.config, "") {
1799 if array_merge_policy(&key) == ArrayMerge::Append {
1800 continue;
1801 }
1802 if let Some(first) = seen.get(&key) {
1803 bail!(
1804 "`{key}` is an array declared in two config layers:\n \
1805 {}\n {}\nteravars appends arrays when it merges, so \
1806 magi would run the concatenation of both - which is \
1807 not what either file says. Declare `{key}` in exactly \
1808 one of them.",
1809 first.display(),
1810 path.display()
1811 );
1812 }
1813 seen.insert(key, path.clone());
1814 }
1815 }
1816 Ok(())
1817 }
1818
1819 pub fn array_provenance(paths: &[PathBuf], key: &str) -> Vec<(PathBuf, Vec<String>)> {
1841 let mut engine = teravars::Engine::default();
1842 let ctx = Self::render_ctx(paths);
1843 let mut out = Vec::new();
1844 for path in paths {
1845 let Ok(one) = teravars::load_merged([path], &mut engine, &ctx) else {
1846 continue;
1847 };
1848 let mut cur = &one.config;
1849 let mut found = None;
1850 let parts: Vec<&str> = key.split('.').collect();
1851 for (i, part) in parts.iter().enumerate() {
1852 match cur.get(*part) {
1853 Some(toml::Value::Array(a)) if i == parts.len() - 1 => {
1854 found = Some(a);
1855 break;
1856 }
1857 Some(toml::Value::Table(t)) => cur = t,
1858 _ => break,
1859 }
1860 }
1861 let Some(values) = found else { continue };
1862 let strings: Vec<String> = values
1863 .iter()
1864 .filter_map(|v| v.as_str().map(str::to_owned))
1865 .collect();
1866 if !strings.is_empty() {
1867 out.push((path.clone(), strings));
1868 }
1869 }
1870 out
1871 }
1872
1873 pub(crate) fn layer_tables(paths: &[PathBuf]) -> Vec<(PathBuf, Result<toml::Table>)> {
1879 let mut engine = teravars::Engine::default();
1880 let ctx = Self::render_ctx(paths);
1881 paths
1882 .iter()
1883 .map(|path| {
1884 let one = teravars::load_merged([path], &mut engine, &ctx)
1885 .map(|m| m.config)
1886 .with_context(|| format!("rendering {}", path.display()));
1887 (path.clone(), one)
1888 })
1889 .collect()
1890 }
1891
1892 pub fn describe_composed(
1902 paths: &[PathBuf],
1903 commands: &[String],
1904 key: &str,
1905 empty: &str,
1906 ) -> String {
1907 if commands.is_empty() {
1908 return empty.to_owned();
1909 }
1910 let joined = commands.join(" && ");
1911 let provenance = Self::array_provenance(paths, key);
1912 if provenance.len() <= 1 {
1913 return joined;
1914 }
1915 let mut out = joined;
1916 for (path, cmds) in &provenance {
1917 out.push_str(&format!("\n [{}] {}", path.display(), cmds.join(" && ")));
1918 }
1919 out
1920 }
1921
1922 pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
1929 #[cfg(test)]
1930 crate::web::request_cost::discovery();
1931 if let Some(p) = explicit {
1932 let paths = vec![p.to_path_buf()];
1933 return Ok((Self::load_layers(&paths)?, paths));
1934 }
1935 let paths = Self::layers_checked(repo)?;
1936 if paths.is_empty() {
1937 return Ok((Self::autodetected(), paths));
1938 }
1939 Ok((Self::load_layers(&paths)?, paths))
1940 }
1941
1942 pub async fn discover_fetched(
1945 repo: &Path,
1946 explicit: Option<&Path>,
1947 ) -> Result<(Self, Vec<PathBuf>)> {
1948 if explicit.is_none() && is_git_worktree(repo) {
1949 let (remote, cfg_base) = probe_remote_base(repo);
1953 let base = crate::git::merge_base_branch(repo, &remote, cfg_base.as_deref())
1954 .await
1955 .with_context(|| ConfigFailure {
1956 stage: "config-base",
1957 target: remote.clone(),
1958 })?;
1959 let out = crate::git::fetch(repo, &remote, &base)
1960 .await
1961 .with_context(|| format!("fetching {remote}/{base} to read magi.toml"))
1962 .with_context(|| ConfigFailure {
1963 stage: "config-fetch",
1964 target: format!("{remote}/{base}"),
1965 })?;
1966 if !out.ok() {
1967 return Err(anyhow::anyhow!(
1968 "cannot read magi.toml: `git fetch {remote} {base}` failed ({}); refusing to use the local checkout's copy, which may be stale",
1969 out.stderr.lines().next().unwrap_or("").trim()
1970 )).context(ConfigFailure { stage: "config-fetch", target: format!("{remote}/{base}") });
1971 }
1972 if cfg_base.is_none() {
1973 crate::git::ensure_remote_head(repo, &remote, &base).await;
1974 }
1975 }
1976 Self::discover(repo, explicit).map_err(|error| {
1977 if error.downcast_ref::<ConfigFailure>().is_some() {
1978 error
1979 } else {
1980 error.context(ConfigFailure {
1981 stage: "config-load",
1982 target: explicit.unwrap_or(repo).to_string_lossy().into_owned(),
1983 })
1984 }
1985 })
1986 }
1987
1988 fn layers_checked(repo: &Path) -> Result<Vec<PathBuf>> {
1990 if !is_git_worktree(repo) {
1991 return Ok(Self::layers(repo));
1992 }
1993 let mut paths: Vec<PathBuf> = Self::machine_layer()
1994 .into_iter()
1995 .filter(|p| p.is_file())
1996 .collect();
1997 paths.extend(Self::repo_layers(repo)?);
1998 Ok(paths)
1999 }
2000
2001 pub(crate) fn repo_layer_description(repo: &Path, path: &Path) -> String {
2003 let cached = path.ancestors().find(|p| p.join(REPO_MARKER).is_file());
2004 if let Some(root) = cached {
2005 let relative = path.strip_prefix(root).unwrap_or(path);
2006 let reference = resolve_remote_base(repo)
2007 .map(|(remote, base)| format!("{remote}/{base}"))
2008 .unwrap_or_else(|_| "remote base".to_owned());
2009 format!(
2010 "{} at {reference} (including its includes)",
2011 repo.join(relative).display()
2012 )
2013 } else {
2014 format!("{} (including its includes)", path.display())
2015 }
2016 }
2017
2018 pub(crate) fn repo_layers(repo: &Path) -> Result<Vec<PathBuf>> {
2021 if !is_git_worktree(repo) {
2022 return Ok([
2023 repo.join(".magi").join("config.toml"),
2024 repo.join("magi.toml"),
2025 ]
2026 .into_iter()
2027 .filter(|p| p.is_file())
2028 .collect());
2029 }
2030 remote_layers(repo)
2031 }
2032
2033 pub const CONFIG_DIR_ENV: &str = "MAGI_CONFIG_DIR";
2053
2054 pub fn layers(repo: &Path) -> Vec<PathBuf> {
2059 let mut paths = Vec::new();
2060 paths.extend(Self::machine_layer());
2061 paths.push(repo.join(".magi").join("config.toml"));
2062 paths.push(repo.join("magi.toml"));
2063 paths.retain(|p| p.is_file());
2064 paths
2065 }
2066
2067 #[cfg(test)]
2074 pub(crate) fn machine_layer() -> Option<PathBuf> {
2075 std::env::var(Self::CONFIG_DIR_ENV)
2076 .ok()
2077 .filter(|dir| !dir.trim().is_empty())
2078 .map(|dir| PathBuf::from(dir).join("magi").join("config.toml"))
2079 }
2080
2081 #[cfg(not(test))]
2083 pub(crate) fn machine_layer() -> Option<PathBuf> {
2084 match std::env::var(Self::CONFIG_DIR_ENV) {
2085 Ok(dir) if dir.trim().is_empty() => None,
2087 Ok(dir) => Some(PathBuf::from(dir).join("magi").join("config.toml")),
2088 Err(_) => dirs::config_dir().map(|dir| dir.join("magi").join("config.toml")),
2089 }
2090 }
2091
2092 pub fn autodetected() -> Self {
2094 let mut cfg = Self::default();
2095 for (kind, id, model) in [
2096 (AgentKind::Claude, "opus", Some("opus")),
2097 (AgentKind::Claude, "sonnet", Some("sonnet")),
2098 (AgentKind::Antigravity, "antigravity", None),
2099 (AgentKind::Opencode, "opencode", None),
2100 (AgentKind::Codex, "codex", None),
2101 (AgentKind::Omp, "omp", None),
2102 ] {
2103 if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
2104 cfg.agents.push(AgentSpec {
2105 id: id.to_owned(),
2106 kind,
2107 model: model.map(str::to_owned),
2108 command: Vec::new(),
2109 extra_args: Vec::new(),
2110 env: BTreeMap::new(),
2111 prompt_delivery: None,
2112 });
2113 }
2114 }
2115 cfg
2116 }
2117
2118 pub fn cache_dir(&self) -> Option<PathBuf> {
2121 self.verify.cache_dir()
2122 }
2123
2124 pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
2126 self.agents
2127 .iter()
2128 .find(|a| a.id == id)
2129 .with_context(|| format!("no agent with id `{id}` in the roster"))
2130 }
2131
2132 fn rotate(&self, ids: &[String], count: usize, offset: usize) -> Result<Vec<AgentSpec>> {
2139 let mut out = Vec::with_capacity(count);
2140 for i in 0..count {
2141 let spec = if ids.is_empty() {
2142 self.agents[(i + offset) % self.agents.len()].clone()
2143 } else {
2144 self.agent(&ids[i % ids.len()])?.clone()
2145 };
2146 out.push(spec);
2147 }
2148 Ok(out)
2149 }
2150
2151 fn full_roster(&self, ids: &[String]) -> Result<Vec<AgentSpec>> {
2158 if ids.is_empty() {
2159 Ok(self.agents.clone())
2160 } else {
2161 ids.iter().map(|id| self.agent(id).cloned()).collect()
2162 }
2163 }
2164
2165 pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
2172 if self.agents.is_empty() {
2173 bail!(
2174 "agent roster is empty: no agent CLI found on PATH and no \
2175 [[agents]] in the config. Run `magi init` to write a starter \
2176 magi.toml."
2177 );
2178 }
2179 let pinned = self
2180 .graph
2181 .selected_implementer
2182 .as_ref()
2183 .map(|id| self.agent(id).cloned())
2184 .transpose()?;
2185 Ok(ResolvedRoles {
2186 implementers: match &pinned {
2187 Some(agent) => vec![agent.clone()],
2188 None => self.rotate(&self.roles.implementers, self.graph.implementers, 0)?,
2189 },
2190 judges: self.rotate(&self.roles.judges, self.graph.judges, 1)?,
2191 reviewers: self.rotate(&self.roles.reviewers, self.graph.reviewers, 0)?,
2192 fixer: match self.roles.fixer.as_ref() {
2193 Some(choice) => Some(crate::agent::pick_chain(
2196 &self.agents,
2197 Some(choice),
2198 &|_| true,
2199 "fixer",
2200 )?),
2201 None => None,
2202 },
2203 conductor: match self.roles.conductor.as_ref().map(AgentChoice::ids) {
2208 Some(ids) if ids.len() == 1 => self.agent(ids[0])?.clone(),
2209 Some(ids) if !ids.is_empty() => ids
2212 .iter()
2213 .find_map(|id| self.agent(id).ok())
2214 .cloned()
2215 .with_context(|| {
2216 format!("[roles] conductor names no agent in the roster: {ids:?}")
2217 })?,
2218 _ => crate::agent::pick(&self.agents, None, &crate::agent::installed)
2223 .unwrap_or_else(|_| self.agents[0].clone()),
2224 },
2225 implementer_roster: match pinned {
2226 Some(agent) => vec![agent],
2227 None => self.full_roster(&self.roles.implementers)?,
2228 },
2229 judge_roster: self.full_roster(&self.roles.judges)?,
2230 reviewer_roster: self.full_roster(&self.roles.reviewers)?,
2231 })
2232 }
2233
2234 pub fn advisors(&self) -> Result<Vec<AgentSpec>> {
2256 if self.agents.is_empty() {
2257 bail!(
2258 "agent roster is empty: no agent CLI found on PATH and no \
2259 [[agents]] in the config. Run `magi init` to write a starter \
2260 magi.toml."
2261 );
2262 }
2263 if !self.roles.advisors.is_empty() {
2264 return self.rotate(&self.roles.advisors, self.graph.advisors, 0);
2265 }
2266 self.rotate(&self.roles.judges, self.graph.advisors, 1)
2267 }
2268
2269 pub fn advisor_roster(&self) -> Result<Vec<AgentSpec>> {
2273 if self.roles.advisors.is_empty() {
2274 self.full_roster(&self.roles.judges)
2275 } else {
2276 self.full_roster(&self.roles.advisors)
2277 }
2278 }
2279
2280 pub fn shell(&self) -> Vec<String> {
2282 if let Some(s) = &self.verify.shell {
2283 return s.clone();
2284 }
2285 if which("sh") {
2286 vec!["sh".to_owned(), "-c".to_owned()]
2287 } else {
2288 vec!["cmd".to_owned(), "/C".to_owned()]
2289 }
2290 }
2291
2292 pub fn starter_toml() -> String {
2294 let detected = Self::autodetected();
2295 let mut s = String::from(
2296 "# magi — blind multi-agent implementation competition.\n\
2297 # `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
2298 # -> blind judging -> deliberation -> private final vote\n\
2299 # -> fold losers -> review + E2E loop -> gate -> merge.\n\
2300 #\n\
2301 # Rendered by teravars: a `[vars]` table, env\n\
2302 # and system lookups, and `include = [...]` all work. Tera\n\
2303 # braces are live everywhere in this file, but comments are\n\
2304 # stripped before rendering (teravars >= 0.2.2), so a comment\n\
2305 # may quote `{{ ... }}` freely.\n\
2306 #\n\
2307 # Layers deep-merge in increasing\n\
2308 # precedence, so the roster can live once per machine in\n\
2309 # <config_dir>/magi/config.toml and each repo only states its own\n\
2310 # gate:\n\
2311 # <config_dir>/magi/config.toml < .magi/config.toml < magi.toml\n\n\
2312 [vars]\n\
2313 # Reference it as vars.cache inside Tera braces, anywhere below.\n\
2314 # Single quotes inside the braces: teravars renders the raw file\n\
2315 # text, so TOML's own \\\" escaping never reaches Tera.\n\
2316 cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
2317 );
2318 if detected.agents.is_empty() {
2319 s.push_str(
2320 "# No agent CLI was found on PATH. Fill this in by hand.\n\
2321 # kind = claude | opencode | antigravity | codex | command\n\
2322 [[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
2323 );
2324 } else {
2325 for a in &detected.agents {
2326 s.push_str("[[agents]]\n");
2327 s.push_str(&format!("id = {:?}\n", a.id));
2328 s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
2329 if let Some(m) = &a.model {
2330 s.push_str(&format!("model = {m:?}\n"));
2331 }
2332 s.push('\n');
2333 }
2334 }
2335 s.push_str(
2336 "# Leave a role list empty to rotate through the roster.\n\
2337 # A non-empty list is also an ordered backup list: seats take entries from the\n\
2338 # front (see [graph] counts) and a failed or out-of-quota seat moves on to the\n\
2339 # next agent to its right, each tried once. Same for judges and reviewers.\n\
2340 # implementers = [\"opus\", \"codex\", \"sonnet\"] # with implementers = 1 below: opus sits, codex then sonnet are spares\n\
2341 [roles]\n\
2342 implementers = []\n\
2343 judges = []\n\
2344 reviewers = []\n\
2345 # conductor = \"opus\" # arranges the queue; unset picks a seat like chatter does\n\
2346 # synthesizer = \"opus\" # blends the advisors into one brief; unset picks a seat like chatter does\n\
2347 # synthesizer = [\"opus\", \"codex\"] # array form: fallback chain, each tried once on quota or failure\n\
2348 # language_judge = \"jev\" # kind = \"command\" wrapper deciding if PR text is English; unset keeps the built-in heuristics\n\
2349 # dupes_judge = \"opus\" # judges duplicate-work claims; unset inherits chatter (string or fallback chain)\n\
2350 # fixer = \"opus\" # applies review findings; unset keeps the winner's own author\n\
2351 # fixer = [\"opus\", \"codex\"] # array form: fallback chain, each tried once on quota or failure\n\n\
2352 [graph]\n\
2353 # Gate GitHub prose; secret redaction is always enabled.\n\
2354 github_text_guard = true\n\
2355 # Language of PR / issue titles, bodies and comments; non-English drops the English-only check (commits stay English).\n\
2356 github_language = \"en\"\n\
2357 implementers = 3\n\
2358 judges = 3\n\
2359 deliberate_rounds = 1\n\
2360 reviewers = 3\n\
2361 review_rounds = 6\n\
2362 # Fix rounds a failing verify.gate gets before the run is blocked (0 = none).\n\
2363 gate_fix_rounds = 1\n\
2364 # Rerun a failed required CI check once before a land fix round (flaky tests).\n\
2365 rerun_flaky_checks = true\n\
2366 # Port the strengths judges named in the losing candidates onto the\n\
2367 # winner before review (best-effort; rolled back on failure or a big diff).\n\
2368 graft_runner_up = true\n\
2369 max_parallel = 4\n\
2370 language = \"en\"\n\
2371 # One CLI conversation per seat: judges keep their own argument\n\
2372 # across deliberation, the fixer keeps its implementation context.\n\
2373 sessions = true\n\
2374 # Reviewer-seat timeout. When timeout_verify is omitted, E2E and\n\
2375 # the final gate inherit this value for compatibility.\n\
2376 timeout_review = 1200\n\
2377 # Optional independent E2E/final-gate timeout; uncomment to keep\n\
2378 # verification independent if timeout_review changes later.\n\
2379 # timeout_verify = 1200\n\
2380 # Longest a seat waits for the shared build cache before it is\n\
2381 # spawned. Never counted against an agent's own timeout; expiry\n\
2382 # holds the run (resumable) instead of failing the agent.\n\
2383 # timeout_resource_wait = 1800\n\n\
2384 # What the chat calls you (persona address and the Chat's turn label).\n\
2385 # [talk]\n\
2386 # operator_name = \"Commander\"\n\n\
2387 # Chat personas (tone only; the web Chat picks one per conversation).\n\
2388 # Built in: magi, rei, misato, ritsuko, shinji, asuka, kaworu. Entries add to\n\
2389 # them, and one with a built-in's id replaces it. `default` is the plain\n\
2390 # voice and cannot be redefined (it may only set `icon`). Declare this in one\n\
2391 # layer only. `icon` is an optional avatar, display only: an absolute or\n\
2392 # ~/ path to a png/jpg/gif/webp, or a data:image/...;base64 URI, up to\n\
2393 # 256 KiB; a missing or bad one falls back to the built-in avatar.\n\
2394 # [[talk.personas]]\n\
2395 # id = \"gendo\"\n\
2396 # name = \"Gendo Ikari\"\n\
2397 # prompt = \"Speak coldly and tersely, hands folded.\"\n\
2398 # icon = \"~/pictures/gendo.png\"\n\n\
2399 [verify]\n\
2400 # Run once per review round in the winner's worktree; failures are\n\
2401 # fed back to the fixer.\n\
2402 e2e = []\n\
2403 # Final gate. Every command must exit 0 before a merge.\n\
2404 gate = []\n\
2405 # Mechanical fixers (formatters) run in the winner's worktree just\n\
2406 # before the gate; whatever they change becomes one commit. A failing\n\
2407 # command only warns - the gate stays the arbiter. Same timeout as the\n\
2408 # gate (timeout_verify). Keep it light and idempotent.\n\
2409 # pre_gate = []\n\n\
2410 # Prepare each worktree magi creates (candidates, judges, advisors,\n\
2411 # reviewers, the operator-fix and review-takeover checkouts), in order,\n\
2412 # before any agent or verify command touches it. `copy` reads from the\n\
2413 # primary checkout and never overwrites; `optional` skips a missing\n\
2414 # source. `run` goes through the shell with the worktree as cwd and\n\
2415 # fails the seat on a non-zero exit or timeout (timeout_secs, default\n\
2416 # the verify timeout). Setup must not edit tracked files, and what it\n\
2417 # leaves untracked is kept out of magi's commits. Declare this in one\n\
2418 # layer only.\n\
2419 # [worktree]\n\
2420 # setup = [\n\
2421 # { copy = \".env.example -> .env\" },\n\
2422 # { copy = \".env.local\", optional = true },\n\
2423 # { run = \"pnpm install --frozen-lockfile\", timeout_secs = 900 },\n\
2424 # ]\n\n\
2425 [merge]\n\
2426 # none | local | pr\n\
2427 mode = \"none\"\n\
2428 # release_bump = true # Rust (Cargo.toml) repositories only; a no-op elsewhere\n\n\
2429 [update]\n\
2430 # off | notify | install — checked in the background, throttled.\n\
2431 mode = \"notify\"\n\
2432 # interval = \"24h\"\n\n\
2433 [daemon]\n\
2434 # Minutes a release-bump pull request (chore/release-v*) may sit with\n\
2435 # no new commit and no check changing state before `magi serve`\n\
2436 # notices and asks you. A failed check is rerun once on its own;\n\
2437 # still red after that escalates at once. 0 turns the watcher off.\n\
2438 # release_stall_minutes = 60\n\n\
2439 [release]\n\
2440 # actions (default) | local. \"local\" is for repositories whose GitHub\n\
2441 # Actions never run (private, no minutes): the release pull request\n\
2442 # does not wait for CI, you approve the merge in magi, and then\n\
2443 # `magi serve` pushes the vX.Y.Z tag (never over an existing one) and\n\
2444 # runs `commands` from a clean checkout of the merge commit. A\n\
2445 # failure holds the release with a notice; it is never retried blind.\n\
2446 # Needs `magi serve` running and [daemon] release_stall_minutes > 0.\n\
2447 # mode = \"actions\"\n\
2448 # Run in order; the first failure stops. They see MAGI_RELEASE_VERSION,\n\
2449 # MAGI_RELEASE_TAG, MAGI_RELEASE_COMMIT and MAGI_RELEASE_PR. Make them\n\
2450 # safe to repeat: a crash mid-command is treated as unfinished.\n\
2451 # commands = [\"cargo build --release\", \"gh release create \\\"$MAGI_RELEASE_TAG\\\" --generate-notes\"]\n\
2452 # timeout_minutes = 30\n",
2453 );
2454 s
2455 }
2456}
2457
2458pub fn which(program: &str) -> bool {
2460 find_program(program).is_some()
2461}
2462
2463pub fn find_program(program: &str) -> Option<PathBuf> {
2472 find_program_on(program, &std::env::var_os("PATH")?)
2473}
2474
2475pub fn find_program_on(program: &str, paths: &std::ffi::OsStr) -> Option<PathBuf> {
2478 let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
2479 find_program_in(program, paths, cfg!(windows).then_some(pathext.as_str()))
2480}
2481
2482fn find_program_in(
2483 program: &str,
2484 paths: &std::ffi::OsStr,
2485 pathext: Option<&str>,
2486) -> Option<PathBuf> {
2487 let p = Path::new(program);
2488 if p.components().count() > 1 {
2489 return p.is_file().then(|| p.to_path_buf());
2490 }
2491 std::env::split_paths(paths).find_map(|dir| match pathext {
2492 Some(exts) => {
2493 let bare = dir.join(program);
2494 if p.extension().is_some() && bare.is_file() {
2495 return Some(bare);
2496 }
2497 exts.split(';')
2498 .filter(|e| !e.is_empty())
2499 .map(|e| dir.join(format!("{program}{}", e.to_lowercase())))
2500 .find(|c| c.is_file())
2501 }
2502 None => Some(dir.join(program)).filter(|c| c.is_file()),
2503 })
2504}
2505
2506const REPO_MARKER: &str = ".magi-repo";
2508
2509fn git_out(repo: &Path, args: &[&str]) -> Option<String> {
2510 use crate::proc::Quiet as _;
2511 #[cfg(test)]
2512 crate::web::request_cost::git();
2513 let out = std::process::Command::new("git")
2514 .args(args)
2515 .quiet()
2516 .current_dir(repo)
2517 .stdin(std::process::Stdio::null())
2518 .output()
2519 .ok()?;
2520 out.status
2521 .success()
2522 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
2523}
2524
2525fn is_git_worktree(repo: &Path) -> bool {
2528 git_out(repo, &["rev-parse", "--git-dir"]).is_some()
2529}
2530
2531#[derive(Deserialize, Default)]
2532struct ProbeMerge {
2533 base: Option<String>,
2534 remote: Option<String>,
2535}
2536
2537#[derive(Deserialize, Default)]
2538struct Probe {
2539 merge: Option<ProbeMerge>,
2540}
2541
2542fn probe_merge(path: &Path, repo: &Path) -> Option<ProbeMerge> {
2548 if !path.is_file() {
2549 return None;
2550 }
2551 let paths = [path.to_path_buf()];
2552 let mut engine = teravars::Engine::default();
2553 let mut ctx = Config::render_ctx(&paths);
2554 ctx.insert("repo", &repo.to_string_lossy());
2557 ctx.insert(
2558 "repo_name",
2559 &repo.file_name().unwrap_or_default().to_string_lossy(),
2560 );
2561 if let Ok(merged) = teravars::load_merged(&paths, &mut engine, &ctx)
2562 && let Ok(probe) = toml::Value::Table(merged.config).try_into::<Probe>()
2563 {
2564 return probe.merge;
2565 }
2566 let text = std::fs::read_to_string(path).ok()?;
2567 toml::from_str::<Probe>(&text).ok()?.merge
2568}
2569
2570fn probe_remote_base(repo: &Path) -> (String, Option<String>) {
2572 let mut remote = None;
2573 let mut base = None;
2574 let mut sources: Vec<PathBuf> = Config::machine_layer().into_iter().collect();
2575 sources.push(repo.join("magi.toml"));
2576 sources.push(repo.join(".magi").join("config.toml"));
2577 for path in sources {
2578 if let Some(m) = probe_merge(&path, repo) {
2579 remote = remote.or(m.remote.filter(|s| !s.trim().is_empty()));
2580 base = base.or(m.base.filter(|s| !s.trim().is_empty()));
2581 }
2582 }
2583 (remote.unwrap_or_else(|| "origin".to_owned()), base)
2584}
2585
2586fn resolve_remote_base(repo: &Path) -> Result<(String, String)> {
2588 let (remote, base) = probe_remote_base(repo);
2589 let base = match base {
2590 Some(b) => b,
2591 None => {
2592 let head = format!("refs/remotes/{remote}/HEAD");
2593 git_out(repo, &["symbolic-ref", "--quiet", &head])
2594 .and_then(|s| crate::git::remote_head_branch(&remote, &s))
2595 .with_context(|| {
2596 format!(
2597 "cannot tell which branch of `{remote}` holds magi.toml: \
2598 set [merge] base, or run `git remote set-head {remote} -a`"
2599 )
2600 })?
2601 }
2602 };
2603 Ok((remote, base))
2604}
2605
2606fn fnv_hex(text: &str) -> String {
2607 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2608 for b in text.bytes() {
2609 hash ^= u64::from(b);
2610 hash = hash.wrapping_mul(0x0100_0000_01b3);
2611 }
2612 format!("{hash:016x}")
2613}
2614
2615fn declares_include(text: &str) -> bool {
2621 match toml::from_str::<toml::Table>(text) {
2622 Ok(table) => {
2623 table.contains_key("include")
2624 || table
2625 .get("teravars")
2626 .and_then(toml::Value::as_table)
2627 .is_some_and(|t| t.contains_key("include"))
2628 }
2629 Err(_) => text.contains("include"),
2630 }
2631}
2632
2633fn extract_tree(repo: &Path, sha: &str, dest: &Path) -> Result<()> {
2636 use crate::proc::Quiet as _;
2637 #[cfg(test)]
2638 crate::web::request_cost::git();
2639 let mut archive = std::process::Command::new("git")
2640 .args(["archive", "--format=tar", sha])
2641 .quiet()
2642 .current_dir(repo)
2643 .stdin(std::process::Stdio::null())
2644 .stdout(std::process::Stdio::piped())
2645 .stderr(std::process::Stdio::null())
2646 .spawn()
2647 .context("running git archive")?;
2648 let tar = std::process::Command::new("tar")
2649 .arg("-x")
2650 .arg("-C")
2651 .arg(dest)
2652 .quiet()
2653 .stdin(archive.stdout.take().context("git archive has no stdout")?)
2654 .output()
2655 .context("running tar")?;
2656 let status = archive.wait().context("waiting for git archive")?;
2657 if !status.success() || !tar.status.success() {
2658 bail!(
2659 "cannot unpack {sha} for the config's includes: {}",
2660 String::from_utf8_lossy(&tar.stderr).trim()
2661 );
2662 }
2663 Ok(())
2664}
2665
2666fn remote_layers(repo: &Path) -> Result<Vec<PathBuf>> {
2669 let (remote, base) = resolve_remote_base(repo)?;
2670 let tracking = format!("refs/remotes/{remote}/{base}");
2671 let spec = format!("{tracking}^{{commit}}");
2672 let sha = git_out(repo, &["rev-parse", "--verify", "--quiet", &spec])
2673 .map(|s| s.trim().to_owned())
2674 .filter(|s| !s.is_empty())
2675 .with_context(|| {
2676 format!(
2677 "`{remote}/{base}` does not exist in this checkout; fetch it \
2678 (`git fetch {remote} {base}`) or set [merge] base/remote. magi will not read \
2679 a possibly stale local magi.toml instead"
2680 )
2681 })?;
2682 let key = format!("{sha}-{}", fnv_hex(&repo.to_string_lossy()));
2683 let rels = [".magi/config.toml", "magi.toml"];
2684 let mut texts: Vec<(&str, String)> = Vec::new();
2685 for rel in rels {
2686 let object = format!("{sha}:{rel}");
2687 if git_out(repo, &["cat-file", "-e", &object]).is_none() {
2688 continue;
2689 }
2690 let Some(text) = git_out(repo, &["show", &object]) else {
2691 bail!("cannot read {rel} from {remote}/{base} ({sha})");
2692 };
2693 texts.push((rel, text));
2694 }
2695 let wants_tree = texts.iter().any(|(_, t)| declares_include(t));
2696 let build = |root: &Path| -> Result<PathBuf> {
2700 let final_dir = root.join(&key);
2701 if !final_dir.is_dir() {
2702 std::fs::create_dir_all(root)?;
2703 let tmp = root.join(format!(
2704 "{key}.tmp-{}-{}",
2705 std::process::id(),
2706 std::time::SystemTime::now()
2707 .duration_since(std::time::UNIX_EPOCH)
2708 .map(|d| d.as_nanos())
2709 .unwrap_or_default()
2710 ));
2711 let filled = (|| -> Result<()> {
2712 std::fs::create_dir_all(&tmp)?;
2713 if wants_tree {
2714 extract_tree(repo, &sha, &tmp)?;
2715 }
2716 for (rel, text) in &texts {
2717 let file = tmp.join(rel);
2718 let parent = file.parent().unwrap_or(&tmp);
2719 std::fs::create_dir_all(parent)?;
2720 std::fs::write(&file, text)?;
2721 std::fs::write(parent.join(REPO_MARKER), repo.to_string_lossy().as_bytes())?;
2722 }
2723 Ok(())
2724 })();
2725 if let Err(e) = filled {
2726 let _ = std::fs::remove_dir_all(&tmp);
2727 return Err(e);
2728 }
2729 if std::fs::rename(&tmp, &final_dir).is_err() {
2730 let _ = std::fs::remove_dir_all(&tmp);
2732 if !final_dir.is_dir() {
2733 bail!("cannot place {}", final_dir.display());
2734 }
2735 }
2736 }
2737 Ok(final_dir)
2738 };
2739 let common = git_out(
2740 repo,
2741 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
2742 )
2743 .map(|s| PathBuf::from(s.trim()).join("magi").join("config"));
2744 let dir = match common.map(|c| build(&c)) {
2745 Some(Ok(d)) => d,
2746 _ => build(&std::env::temp_dir().join("magi-config"))
2747 .with_context(|| format!("extracting config from {remote}/{base}"))?,
2748 };
2749 Ok(texts.iter().map(|(rel, _)| dir.join(rel)).collect())
2750}
2751
2752#[cfg(test)]
2753mod tests {
2754 #[test]
2755 fn coordination_defaults_off_and_validates_rendered_layer_values() {
2756 let dir = tempfile::tempdir().unwrap();
2757 let machine = dir.path().join("machine.toml");
2758 let repo = dir.path().join("repo.toml");
2759 std::fs::write(&machine, "").unwrap();
2760 assert!(Config::default().coordination.is_none());
2761 assert!(Config::load(&machine).unwrap().coordination.is_none());
2762 std::fs::write(
2763 &machine,
2764 r#"
2765[vars]
2766contract = "updater.contract"
2767[coordination]
2768contract_file = "{{ vars.contract }}"
2769contract_token = "protocol-v1"
2770lock_file = "updater.lock"
2771"#,
2772 )
2773 .unwrap();
2774 let cfg = Config::load(&machine).unwrap();
2775 let policy = cfg.coordination.unwrap();
2776 assert_eq!(policy.contract_file, "updater.contract");
2777 assert_eq!(policy.contract_token, "protocol-v1");
2778 assert_eq!(policy.lock_file, "updater.lock");
2779 std::fs::write(&repo, "[coordination]\nlock_file = 'repository.lock'\n").unwrap();
2780 let cfg = Config::load_layers(&[machine.clone(), repo.clone()]).unwrap();
2781 let policy = cfg.coordination.unwrap();
2782 assert_eq!(policy.contract_file, "updater.contract");
2783 assert_eq!(policy.lock_file, "repository.lock");
2784 std::fs::write(
2785 &repo,
2786 r#"
2787[vars]
2788bad = "../escape"
2789[coordination]
2790lock_file = "{{ vars.bad }}"
2791"#,
2792 )
2793 .unwrap();
2794 let error = Config::load_layers(&[machine, repo]).unwrap_err();
2795 assert!(format!("{error:#}").contains("coordination.lock_file"));
2796 }
2797
2798 #[test]
2799 fn coordination_rejects_invalid_names_tokens_and_incomplete_sections() {
2800 let dir = tempfile::tempdir().unwrap();
2801 let path = dir.path().join("config.toml");
2802 let valid = toml::toml! {
2803 [coordination]
2804 contract_file = "updater.contract"
2805 contract_token = "protocol-v1"
2806 lock_file = "updater.lock"
2807 };
2808 for key in ["contract_file", "lock_file"] {
2809 for bad in [
2810 "",
2811 " ",
2812 " file",
2813 "file ",
2814 "\tfile",
2815 "file\t",
2816 ".",
2817 "..",
2818 "file..name",
2819 "../escape",
2820 "a/b",
2821 "a\\b",
2822 "/absolute",
2823 "C:drive",
2824 "C:\\absolute",
2825 "nul\0name",
2826 ] {
2827 let mut table = valid.clone();
2828 table["coordination"][key] = bad.into();
2829 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
2830 let error = Config::load(&path).unwrap_err();
2831 assert!(
2832 format!("{error:#}").contains(&format!("coordination.{key}")),
2833 "{bad:?}: {error:#}"
2834 );
2835 }
2836 }
2837 for bad in ["", " ", " token", "token ", "two\nlines", "two\rlines"] {
2838 let mut table = valid.clone();
2839 table["coordination"]["contract_token"] = bad.into();
2840 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
2841 let error = Config::load(&path).unwrap_err();
2842 assert!(
2843 format!("{error:#}").contains("coordination.contract_token"),
2844 "{error:#}"
2845 );
2846 }
2847 for key in ["contract_file", "contract_token", "lock_file"] {
2848 let mut table = valid.clone();
2849 table["coordination"].as_table_mut().unwrap().remove(key);
2850 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
2851 let error = Config::load(&path).unwrap_err();
2852 assert!(
2853 format!("{error:#}").contains(&format!("coordination.{key}")),
2854 "{error:#}"
2855 );
2856 }
2857 let mut table = valid;
2858 table["coordination"]
2859 .as_table_mut()
2860 .unwrap()
2861 .insert("typo".into(), "value".into());
2862 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
2863 assert!(format!("{:#}", Config::load(&path).unwrap_err()).contains("typo"));
2864 }
2865
2866 #[test]
2867 fn context_window_prefers_exact_then_longest_prefix_and_user_over_builtin() {
2868 let mut cfg = Config::default();
2869 assert_eq!(
2870 cfg.context_window("claude-sonnet-4-5-20250929"),
2871 Some(200_000)
2872 );
2873 assert_eq!(cfg.context_window("gpt-4.1-mini"), Some(1_000_000));
2874 assert_eq!(cfg.context_window("mystery-model"), None);
2875 cfg.context_windows
2876 .insert("claude-sonnet-4-5".into(), 1_000_000);
2877 cfg.context_windows.insert("claude-sonnet-4".into(), 5);
2878 cfg.context_windows.insert("mystery".into(), 32_768);
2879 assert_eq!(
2881 cfg.context_window("claude-sonnet-4-5-20250929"),
2882 Some(1_000_000)
2883 );
2884 cfg.context_windows
2886 .insert("claude-sonnet-4-5-20250929".into(), 7);
2887 assert_eq!(cfg.context_window("claude-sonnet-4-5-20250929"), Some(7));
2888 assert_eq!(cfg.context_window("mystery-model"), Some(32_768));
2889 }
2890
2891 use super::*;
2892
2893 #[test]
2896 fn windows_lookup_picks_the_cmd_shim_over_the_extensionless_script() {
2897 let dir = tempfile::tempdir().unwrap();
2898 std::fs::write(dir.path().join("codex"), "#!/bin/sh\n").unwrap();
2899 std::fs::write(dir.path().join("codex.cmd"), "@echo off\n").unwrap();
2900 let paths = dir.path().as_os_str();
2901 assert_eq!(
2902 find_program_in("codex", paths, Some(".EXE;.CMD")),
2903 Some(dir.path().join("codex.cmd"))
2904 );
2905 assert_eq!(
2906 find_program_in("codex", paths, None),
2907 Some(dir.path().join("codex"))
2908 );
2909 assert_eq!(find_program_in("claude", paths, Some(".EXE;.CMD")), None);
2910 }
2911
2912 fn spec(id: &str) -> AgentSpec {
2913 AgentSpec {
2914 id: id.to_owned(),
2915 kind: AgentKind::Command,
2916 model: None,
2917 command: vec!["true".to_owned()],
2918 extra_args: Vec::new(),
2919 env: BTreeMap::new(),
2920 prompt_delivery: None,
2921 }
2922 }
2923
2924 #[test]
2925 fn github_text_guard_defaults_on_and_can_be_disabled() {
2926 let default: Graph = toml::from_str("").unwrap();
2927 assert!(default.github_text_guard);
2928 let disabled: Graph = toml::from_str("github_text_guard = false").unwrap();
2929 assert!(!disabled.github_text_guard);
2930 let config = layered("", "[graph]\ngithub_text_guard = false\n").unwrap();
2931 assert!(!config.graph.github_text_guard);
2932 assert!(Config::starter_toml().contains("github_text_guard = true"));
2933 }
2934
2935 #[test]
2936 fn rerun_flaky_checks_defaults_on_and_is_in_the_starter() {
2937 assert!(Graph::default().rerun_flaky_checks);
2938 let off: Graph = toml::from_str("rerun_flaky_checks = false").unwrap();
2939 assert!(!off.rerun_flaky_checks);
2940 assert!(Config::starter_toml().contains("rerun_flaky_checks = true"));
2941 }
2942
2943 #[test]
2944 fn timeout_verify_omitted_from_old_toml_inherits_timeout_review() {
2945 let g: Graph = toml::from_str("timeout_review = 3600").expect("parse");
2950 assert_eq!(g.timeout_verify, None);
2951 assert_eq!(g.verify_timeout(), 3600);
2952 }
2953
2954 #[test]
2955 fn shrinking_timeout_review_does_not_shrink_timeout_verify() {
2956 let g: Graph = toml::from_str("timeout_review = 45").expect("parse");
2961 assert_eq!(g.timeout_review, 45);
2962 assert_eq!(
2963 g.verify_timeout(),
2964 45,
2965 "an omitted legacy value follows review"
2966 );
2967 let explicit: Graph = toml::from_str("timeout_review = 45\ntimeout_verify = 1200")
2968 .expect("parse explicit override");
2969 assert_eq!(explicit.verify_timeout(), 1200);
2970 }
2971
2972 #[test]
2973 fn graph_implementers_new_key_loads_and_old_key_is_an_alias() {
2974 let g: Graph = toml::from_str("implementers = 4\n").unwrap();
2975 assert_eq!(g.implementers, 4);
2976 let g: Graph = toml::from_str("candidates = 5\n").unwrap();
2977 assert_eq!(g.implementers, 5);
2978 }
2979
2980 fn layered(lower: &str, upper: &str) -> Result<Config> {
2981 let dir = tempfile::tempdir().unwrap();
2982 let a = dir.path().join("a.toml");
2983 let b = dir.path().join("b.toml");
2984 std::fs::write(&a, lower).unwrap();
2985 std::fs::write(&b, upper).unwrap();
2986 Config::load_layers(&[a, b])
2987 }
2988
2989 #[test]
2990 fn graph_implementers_higher_layer_wins_across_spellings() {
2991 let c = layered("[graph]\ncandidates = 2\n", "[graph]\nimplementers = 5\n").unwrap();
2992 assert_eq!(c.graph.implementers, 5);
2993 let c = layered("[graph]\nimplementers = 2\n", "[graph]\ncandidates = 5\n").unwrap();
2994 assert_eq!(c.graph.implementers, 5);
2995 let c = layered("[graph]\ncandidates = 2\n", "[graph]\njudges = 3\n").unwrap();
2996 assert_eq!(c.graph.implementers, 2);
2997 }
2998
2999 #[test]
3000 fn graph_implementers_both_keys_in_one_layer_is_refused() {
3001 let err = layered("", "[graph]\ncandidates = 2\nimplementers = 3\n")
3002 .unwrap_err()
3003 .to_string();
3004 assert!(
3005 err.contains("candidates") && err.contains("implementers"),
3006 "{err}"
3007 );
3008 assert!(err.contains("b.toml"), "{err}");
3009 }
3010
3011 #[test]
3012 fn a_toml_layer_written_before_these_fields_existed_still_parses() {
3013 let g: Graph =
3017 toml::from_str("candidates = 1\nreviewers = 3\nreview_rounds = 6\nmax_parallel = 4\n")
3018 .expect("an old-shaped [graph] table must still parse");
3019 assert_eq!(g.verify_timeout(), Graph::default().timeout_review);
3020 assert!(
3021 !g.e2e_every_round,
3022 "off by default, same as before this field existed"
3023 );
3024 }
3025
3026 #[test]
3027 fn empty_roles_rotate_judges_off_their_own_candidate() {
3028 let cfg = Config {
3031 agents: vec![spec("a"), spec("b"), spec("c")],
3032 graph: Graph {
3033 implementers: 3,
3034 ..Graph::default()
3035 },
3036 ..Config::default()
3037 };
3038 let roles = cfg.resolve_roles().unwrap();
3039 let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
3040 let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
3041 assert_eq!(impls, ["a", "b", "c"]);
3042 assert_eq!(judges, ["b", "c", "a"]);
3043 for (i, j) in judges.iter().enumerate() {
3044 assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
3045 }
3046 }
3047
3048 #[test]
3049 fn single_agent_roster_fills_every_seat() {
3050 let cfg = Config {
3051 agents: vec![spec("solo")],
3052 graph: Graph {
3053 implementers: 3,
3054 ..Graph::default()
3055 },
3056 ..Config::default()
3057 };
3058 let roles = cfg.resolve_roles().unwrap();
3059 assert_eq!(roles.implementers.len(), 3);
3060 assert!(roles.judges.iter().all(|a| a.id == "solo"));
3061 }
3062
3063 #[test]
3064 fn implementer_roster_is_the_whole_agent_list_untruncated_when_unset() {
3065 let cfg = Config {
3070 agents: vec![spec("a"), spec("b"), spec("c")],
3071 graph: Graph {
3072 implementers: 1,
3073 ..Graph::default()
3074 },
3075 ..Config::default()
3076 };
3077 let roles = cfg.resolve_roles().unwrap();
3078 assert_eq!(roles.implementers.len(), 1);
3079 let roster: Vec<&str> = roles
3080 .implementer_roster
3081 .iter()
3082 .map(|a| a.id.as_str())
3083 .collect();
3084 assert_eq!(roster, ["a", "b", "c"]);
3085 }
3086
3087 #[test]
3088 fn implementer_roster_follows_explicit_ids_in_the_order_named() {
3089 let cfg = Config {
3090 agents: vec![spec("a"), spec("b"), spec("c")],
3091 roles: Roles {
3092 implementers: vec!["c".to_owned(), "a".to_owned()],
3093 ..Roles::default()
3094 },
3095 graph: Graph {
3096 implementers: 1,
3097 ..Graph::default()
3098 },
3099 ..Config::default()
3100 };
3101 let roles = cfg.resolve_roles().unwrap();
3102 assert_eq!(roles.implementers.len(), 1);
3104 assert_eq!(roles.implementers[0].id, "c");
3105 let roster: Vec<&str> = roles
3106 .implementer_roster
3107 .iter()
3108 .map(|a| a.id.as_str())
3109 .collect();
3110 assert_eq!(roster, ["c", "a"]);
3111 }
3112
3113 #[test]
3114 fn explicit_roles_win() {
3115 let cfg = Config {
3116 agents: vec![spec("a"), spec("b")],
3117 roles: Roles {
3118 implementers: vec!["b".to_owned()],
3119 judges: vec!["a".to_owned()],
3120 reviewers: Vec::new(),
3121 fixer: Some("a".into()),
3122 ..Roles::default()
3123 },
3124 ..Config::default()
3125 };
3126 let roles = cfg.resolve_roles().unwrap();
3127 assert!(roles.implementers.iter().all(|a| a.id == "b"));
3128 assert!(roles.judges.iter().all(|a| a.id == "a"));
3129 let fixer = roles.fixer.unwrap();
3130 assert_eq!(fixer.len(), 1);
3131 assert_eq!(fixer[0].id, "a");
3132 assert_eq!(roles.conductor.id, "a");
3133 }
3134
3135 #[test]
3136 fn fixer_takes_a_string_or_an_ordered_chain() {
3137 let one: Roles = toml::from_str("fixer = \"a\"").unwrap();
3138 assert_eq!(one.fixer.as_ref().unwrap().ids(), ["a"]);
3139 let chain: Roles = toml::from_str("fixer = [\"b\", \"a\"]").unwrap();
3140 assert_eq!(chain.fixer.as_ref().unwrap().ids(), ["b", "a"]);
3141
3142 let resolve = |fixer: Option<AgentChoice>| {
3143 Config {
3144 agents: vec![spec("a"), spec("b"), spec("c")],
3145 roles: Roles {
3146 fixer,
3147 ..Roles::default()
3148 },
3149 ..Config::default()
3150 }
3151 .resolve_roles()
3152 };
3153 let ids = |r: &ResolvedRoles| -> Vec<String> {
3154 r.fixer
3155 .as_ref()
3156 .unwrap()
3157 .iter()
3158 .map(|s| s.id.clone())
3159 .collect()
3160 };
3161 assert_eq!(ids(&resolve(Some("c".into())).unwrap()), ["c"]);
3163 assert!(resolve(None).unwrap().fixer.is_none());
3164 let chain = AgentChoice::Chain(vec!["b".into(), "zzz".into(), "a".into(), "b".into()]);
3166 assert_eq!(ids(&resolve(Some(chain)).unwrap()), ["b", "a"]);
3167 let err = resolve(Some(AgentChoice::Chain(vec!["zzz".into()]))).unwrap_err();
3169 assert!(format!("{err:#}").contains("fixer"), "{err:#}");
3170 }
3171
3172 #[test]
3173 fn unknown_agent_id_is_an_error() {
3174 let cfg = Config {
3175 agents: vec![spec("a")],
3176 roles: Roles {
3177 judges: vec!["nope".to_owned()],
3178 ..Roles::default()
3179 },
3180 ..Config::default()
3181 };
3182 assert!(cfg.resolve_roles().is_err());
3183 }
3184
3185 #[test]
3186 fn conductor_role_is_resolved_validated_and_has_a_fallback() {
3187 let mut cfg = Config {
3188 agents: vec![spec("a"), spec("b")],
3189 ..Config::default()
3190 };
3191 assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "a");
3192
3193 cfg.roles.conductor = Some("b".into());
3194 assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "b");
3195
3196 cfg.roles.conductor = Some("missing".into());
3197 assert!(cfg.resolve_roles().is_err());
3198 }
3199
3200 #[test]
3201 fn a_standalone_role_takes_a_string_or_an_array_and_round_trips() {
3202 let one: Roles = toml::from_str("synthesizer = \"opus\"").unwrap();
3203 assert_eq!(one.synthesizer, Some(AgentChoice::One("opus".to_owned())));
3204 assert_eq!(primary(one.synthesizer.as_ref()), Some("opus"));
3205
3206 let chain: Roles = toml::from_str("chatter = [\"opus\", \"codex\"]").unwrap();
3207 assert_eq!(chain.chatter.as_ref().unwrap().ids(), ["opus", "codex"]);
3208 assert_eq!(primary(chain.chatter.as_ref()), Some("opus"));
3209
3210 for roles in [one, chain] {
3211 let text = toml::to_string(&roles).unwrap();
3212 let back: Roles = toml::from_str(&text).unwrap();
3213 assert_eq!(back.synthesizer, roles.synthesizer);
3214 assert_eq!(back.chatter, roles.chatter);
3215 }
3216 let text = toml::to_string(&Roles {
3217 conductor: Some("x".into()),
3218 ..Roles::default()
3219 })
3220 .unwrap();
3221 assert!(
3222 text.contains("conductor = \"x\""),
3223 "string stays a string: {text}"
3224 );
3225 assert!(toml::from_str::<Roles>("synthesizer = 3").is_err());
3226 }
3227
3228 #[test]
3229 fn dupes_judge_role_accepts_strings_and_chains_and_round_trips() {
3230 for (text, ids) in [
3231 (r#"dupes_judge = "dedicated""#, vec!["dedicated"]),
3232 (
3233 r#"dupes_judge = ["first", "second"]"#,
3234 vec!["first", "second"],
3235 ),
3236 ("dupes_judge = []", vec![]),
3237 ] {
3238 let roles: Roles = toml::from_str(text).unwrap();
3239 assert_eq!(roles.dupes_judge.as_ref().unwrap().ids(), ids);
3240 let serialized = toml::to_string(&roles).unwrap();
3241 let back: Roles = toml::from_str(&serialized).unwrap();
3242 assert_eq!(back.dupes_judge, roles.dupes_judge);
3243 assert!(serialized.contains(text));
3244 }
3245 assert!(toml::from_str::<Roles>("dupes_judge = 3").is_err());
3246 assert!(Roles::default().dupes_judge.is_none());
3247 assert!(Config::starter_toml().contains("# dupes_judge ="));
3248
3249 let mut cfg = Config {
3251 agents: vec![spec("a")],
3252 ..Config::default()
3253 };
3254 cfg.roles.dupes_judge = Some("missing".into());
3255 assert!(cfg.resolve_roles().is_ok());
3256 }
3257
3258 #[test]
3259 fn an_empty_choice_counts_as_unset() {
3260 assert!(AgentChoice::Chain(Vec::new()).ids().is_empty());
3261 assert!(AgentChoice::One(" ".to_owned()).ids().is_empty());
3262 assert_eq!(primary(Some(&AgentChoice::Chain(vec![]))), None);
3263 }
3264
3265 #[test]
3266 fn synthesizer_role_is_a_lazy_lookup_with_the_same_fallback_as_chatter() {
3267 let mut cfg = Config {
3273 agents: vec![spec("a"), spec("b")],
3274 ..Config::default()
3275 };
3276 let want = primary(cfg.roles.synthesizer.as_ref());
3277 assert_eq!(
3278 crate::agent::pick(&cfg.agents, want, &crate::agent::installed)
3279 .unwrap()
3280 .id,
3281 "a",
3282 "unset falls back to agent::pick's own default order"
3283 );
3284
3285 cfg.roles.synthesizer = Some("b".into());
3286 let want = primary(cfg.roles.synthesizer.as_ref());
3287 assert_eq!(
3288 crate::agent::pick(&cfg.agents, want, &crate::agent::installed)
3289 .unwrap()
3290 .id,
3291 "b",
3292 "the named agent wins over the default order"
3293 );
3294 }
3295
3296 #[test]
3297 fn empty_roster_is_an_error() {
3298 assert!(Config::default().resolve_roles().is_err());
3299 }
3300
3301 #[test]
3302 fn advise_defaults_to_on_with_three_proposals() {
3303 let g = Graph::default();
3304 assert!(g.advise);
3305 assert!(g.graft_runner_up, "graft is on by default");
3306 assert_eq!(g.advisors, 3);
3307 }
3308
3309 #[test]
3310 fn unset_advisors_falls_back_to_the_judge_roster() {
3311 let cfg = Config {
3312 agents: vec![spec("a"), spec("b")],
3313 roles: Roles {
3314 judges: vec!["b".to_owned()],
3315 ..Roles::default()
3316 },
3317 graph: Graph {
3318 advisors: 2,
3319 ..Graph::default()
3320 },
3321 ..Config::default()
3322 };
3323 let advisors = cfg.advisors().expect("advisors resolve");
3324 assert_eq!(advisors.len(), 2);
3325 assert!(
3326 advisors.iter().all(|a| a.id == "b"),
3327 "an unset [roles] advisors must fall back to [roles] judges: {advisors:?}"
3328 );
3329 }
3330
3331 #[test]
3332 fn an_explicit_advisor_roster_wins_over_the_judge_fallback() {
3333 let cfg = Config {
3334 agents: vec![spec("a"), spec("b")],
3335 roles: Roles {
3336 judges: vec!["b".to_owned()],
3337 advisors: vec!["a".to_owned()],
3338 ..Roles::default()
3339 },
3340 graph: Graph {
3341 advisors: 2,
3342 ..Graph::default()
3343 },
3344 ..Config::default()
3345 };
3346 let advisors = cfg.advisors().expect("advisors resolve");
3347 assert!(advisors.iter().all(|a| a.id == "a"));
3348 }
3349
3350 #[test]
3356 fn an_unconfigured_advisor_and_judge_roster_resolve_to_the_same_agents() {
3357 let cfg = Config {
3358 agents: vec![spec("a"), spec("b"), spec("c")],
3359 graph: Graph {
3360 advisors: 3,
3361 judges: 3,
3362 ..Graph::default()
3363 },
3364 ..Config::default()
3365 };
3366 let advisors = cfg.advisors().expect("advisors resolve");
3367 let judges = cfg.resolve_roles().expect("roles resolve").judges;
3368 let advisor_ids: Vec<&str> = advisors.iter().map(|a| a.id.as_str()).collect();
3369 let judge_ids: Vec<&str> = judges.iter().map(|a| a.id.as_str()).collect();
3370 assert_eq!(
3371 advisor_ids, judge_ids,
3372 "an unconfigured advisor roster must be the same seats an unconfigured judge panel gets"
3373 );
3374 }
3375
3376 #[test]
3377 fn an_unresolvable_advisor_seat_is_an_error_naming_the_id() {
3378 let cfg = Config {
3379 agents: vec![spec("a")],
3380 roles: Roles {
3381 advisors: vec!["nope".to_owned()],
3382 ..Roles::default()
3383 },
3384 graph: Graph {
3385 advisors: 1,
3386 ..Graph::default()
3387 },
3388 ..Config::default()
3389 };
3390 let err = cfg.advisors().expect_err("`nope` is not in the roster");
3391 assert!(format!("{err:#}").contains("nope"));
3392 }
3393
3394 #[test]
3395 fn repos_default_to_no_roots_and_a_day_of_trust() {
3396 assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
3397 assert_eq!(Config::default().repos.scan_ttl, 86_400);
3398 assert_eq!(Config::default().repos.fetch_interval, 600);
3399 }
3400
3401 #[test]
3402 fn a_config_file_with_no_repos_table_still_loads() {
3403 let dir = tempfile::tempdir().unwrap();
3404 let path = dir.path().join("magi.toml");
3405 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
3406 let cfg = Config::load(&path).expect("must load without [repos]");
3407 assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
3408 assert_eq!(cfg.repos.scan_ttl, 86_400);
3409 }
3410
3411 #[test]
3426 fn a_test_build_does_not_read_the_operators_machine_config() {
3427 let repo = tempfile::tempdir().unwrap();
3428 std::fs::write(repo.path().join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
3429
3430 let layers = Config::layers(repo.path());
3431 assert_eq!(
3432 layers,
3433 vec![repo.path().join("magi.toml")],
3434 "only the fixture's own file may be a layer"
3435 );
3436 if let Some(real) = dirs::config_dir() {
3437 let machine = real.join("magi").join("config.toml");
3438 assert!(
3439 !layers.contains(&machine),
3440 "the operator's {} must not be a layer in a test build",
3441 machine.display()
3442 );
3443 }
3444 }
3445
3446 #[test]
3447 fn starter_toml_loads_through_teravars() {
3448 let dir = tempfile::tempdir().unwrap();
3449 let path = dir.path().join("magi.toml");
3450 std::fs::write(&path, Config::starter_toml()).unwrap();
3451 let parsed = Config::load(&path).expect("starter config must load");
3452 assert_eq!(parsed.graph.implementers, 3);
3453 assert_eq!(parsed.merge.mode, MergeMode::None);
3454 assert_eq!(parsed.merge.style, MergeStyle::Merge);
3455 assert!(parsed.graph.sessions);
3456 assert!(parsed.graph.github_text_guard);
3457 assert_eq!(parsed.graph.timeout_review, 1200);
3458 assert_eq!(parsed.graph.verify_timeout(), 1200);
3459 assert_eq!(parsed.update.mode, UpdateMode::Notify);
3460 }
3461
3462 #[test]
3463 fn release_defaults_to_actions_and_the_starter_documents_it() {
3464 let dir = tempfile::tempdir().unwrap();
3465 let path = dir.path().join("magi.toml");
3466 std::fs::write(&path, Config::starter_toml()).unwrap();
3467 let r = Config::load(&path).unwrap().release;
3468 assert_eq!(r.mode, ReleaseMode::Actions);
3469 assert!(!r.is_local());
3470 assert!(r.commands.is_empty());
3471 assert_eq!(r.timeout_minutes, 30);
3472 assert!(Config::starter_toml().contains("# mode = \"actions\""));
3473 }
3474
3475 #[test]
3476 fn release_local_parses_and_unknown_keys_are_refused() {
3477 let dir = tempfile::tempdir().unwrap();
3478 let path = dir.path().join("magi.toml");
3479 std::fs::write(
3480 &path,
3481 "[release]\nmode = \"local\"\ncommands = [\"true\", \"echo $MAGI_RELEASE_TAG\"]\ntimeout_minutes = 5\n",
3482 )
3483 .unwrap();
3484 let r = Config::load(&path).unwrap().release;
3485 assert!(r.is_local());
3486 assert_eq!(r.commands.len(), 2);
3487 assert_eq!(r.timeout_minutes, 5);
3488 std::fs::write(&path, "[release]\nmode = \"local\"\nbogus = 1\n").unwrap();
3489 assert!(Config::load(&path).is_err());
3490 std::fs::write(&path, "[release]\nmode = \"gha\"\n").unwrap();
3491 assert!(Config::load(&path).is_err());
3492 }
3493
3494 #[test]
3495 fn pre_gate_defaults_to_empty_and_the_starter_documents_it() {
3496 let dir = tempfile::tempdir().unwrap();
3497 let path = dir.path().join("magi.toml");
3498 std::fs::write(&path, Config::starter_toml()).unwrap();
3499 assert!(Config::load(&path).unwrap().verify.pre_gate.is_empty());
3500 assert!(Config::starter_toml().contains("# pre_gate = []"));
3501 }
3502
3503 #[test]
3504 fn starter_toml_explains_inherited_and_explicit_verify_timeouts() {
3505 let starter = Config::starter_toml();
3506 assert!(starter.contains("When timeout_verify is omitted, E2E and"));
3507 assert!(starter.contains("verification independent if timeout_review changes later"));
3508 assert!(starter.contains("# timeout_verify = 1200"));
3509 }
3510
3511 #[test]
3512 fn starter_toml_documents_chat_personas() {
3513 let starter = Config::starter_toml();
3514 assert!(starter.contains("# [[talk.personas]]"));
3515 assert!(starter.contains("tone only"));
3516 }
3517
3518 #[test]
3519 fn talk_personas_parse_and_are_validated() {
3520 let dir = tempfile::tempdir().unwrap();
3521 let path = dir.path().join("magi.toml");
3522 std::fs::write(
3523 &path,
3524 "[[talk.personas]]\nid = \"rei\"\nname = \"Quiet\"\nprompt = \"Be brief.\"\n\n\
3525 [[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n",
3526 )
3527 .unwrap();
3528 let cfg = Config::load(&path).expect("loads");
3529 assert_eq!(cfg.talk.personas.len(), 2);
3530 let catalog = crate::persona::catalog(&cfg.talk.personas);
3531 let rei = catalog.iter().find(|p| p.id == "rei").unwrap();
3532 assert_eq!(
3533 (rei.name.as_str(), rei.prompt.as_str()),
3534 ("Quiet", "Be brief.")
3535 );
3536 assert_eq!(catalog.last().unwrap().id, "gendo");
3537
3538 for (body, needle) in [
3539 (
3540 "[[talk.personas]]\nid = \"\"\nname = \"n\"\nprompt = \"p\"\n",
3541 "empty `id`",
3542 ),
3543 (
3544 "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \" \"\n",
3545 "empty `prompt`",
3546 ),
3547 (
3548 "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \"p\"\n\
3549 [[talk.personas]]\nid = \"a\"\nname = \"m\"\nprompt = \"q\"\n",
3550 "more than once",
3551 ),
3552 (
3553 "[[talk.personas]]\nid = \"default\"\nname = \"n\"\nprompt = \"p\"\n",
3554 "cannot be redefined",
3555 ),
3556 ] {
3557 std::fs::write(&path, body).unwrap();
3558 let err = format!("{:#}", Config::load(&path).unwrap_err());
3559 assert!(err.contains(needle), "{needle}: {err}");
3560 }
3561 }
3562
3563 #[test]
3564 fn talk_operator_name_is_trimmed_and_validated() {
3565 let dir = tempfile::tempdir().unwrap();
3566 let path = dir.path().join("magi.toml");
3567 let load = |body: &str| {
3568 std::fs::write(&path, body).unwrap();
3569 Config::load(&path)
3570 };
3571 let ok = load("[talk]\noperator_name = \" Commander \"\n").expect("ok");
3572 assert_eq!(ok.talk.operator_name(), Some("Commander"));
3573 let blank = load("[talk]\noperator_name = \" \"\n").expect("blank");
3574 assert_eq!(blank.talk.operator_name(), None);
3575 assert!(load("[talk]\noperator_name = \"a\\nb\"\n").is_err());
3576 let long = "x".repeat(65);
3577 assert!(load(&format!("[talk]\noperator_name = \"{long}\"\n")).is_err());
3578 }
3579
3580 #[test]
3581 fn talk_persona_icon_parses_and_a_bad_shape_keeps_config_usable() {
3582 let dir = tempfile::tempdir().unwrap();
3583 let load = |body: &str| {
3584 let path = dir.path().join("c.toml");
3585 std::fs::write(&path, body).unwrap();
3586 Config::load(&path)
3587 };
3588 let ok = load(
3589 "[[talk.personas]]\nid = \"rei\"\nicon = \"~/rei.png\"\n\n\
3590 [[talk.personas]]\nid = \"default\"\nicon = \"data:image/png;base64,AAAA\"\n",
3591 )
3592 .unwrap();
3593 let c = crate::persona::catalog(&ok.talk.personas);
3594 assert_eq!(c[0].icon.as_deref(), Some("data:image/png;base64,AAAA"));
3595 assert_eq!(c[2].icon.as_deref(), Some("~/rei.png"));
3596 assert!(c[2].prompt.contains("Rei"), "built-in voice kept");
3597 let oversized = format!(
3598 "data:image/png;base64,{}",
3599 "A".repeat(crate::persona_icon::ICON_MAX_BYTES)
3600 );
3601 for bad in ["", "rei.png", "/a/b.svg", "/a/../b.png", &oversized] {
3602 for (id, fields) in [
3603 ("rei", ""),
3604 ("default", ""),
3605 ("custom", "name = \"Custom\"\nprompt = \"Keep calm\"\n"),
3606 ] {
3607 let body = format!("[[talk.personas]]\nid = \"{id}\"\nicon = \"{bad}\"\n{fields}");
3608 let cfg = load(&body).expect("bad icon must not invalidate config");
3609 let persona = crate::persona::find(&cfg.talk.personas, id).unwrap();
3610 assert_eq!(persona.id, id);
3611 assert!(crate::persona_icon::load(bad).is_err());
3612 }
3613 }
3614 let doc = Config::starter_toml();
3615 assert!(doc.contains("# icon = "));
3616 }
3617
3618 #[test]
3619 fn talk_personas_in_two_layers_are_refused() {
3620 let dir = tempfile::tempdir().unwrap();
3621 let machine = dir.path().join("machine.toml");
3622 let repo = dir.path().join("repo.toml");
3623 let one = "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \"p\"\n";
3624 std::fs::write(&machine, one).unwrap();
3625 std::fs::write(&repo, one.replace("\"a\"", "\"b\"")).unwrap();
3626 let err = format!("{:#}", Config::load_layers(&[machine, repo]).unwrap_err());
3627 assert!(err.contains("talk.personas"), "{err}");
3628 }
3629
3630 #[test]
3631 fn starter_toml_documents_role_lists_as_backup_order() {
3632 let starter = Config::starter_toml();
3633 assert!(starter.contains("also an ordered backup list"));
3634 assert!(starter.contains("codex then sonnet are spares"));
3635 }
3636
3637 #[test]
3641 fn a_repository_can_declare_a_linear_history_merge_style() {
3642 let dir = tempfile::tempdir().unwrap();
3643 let path = dir.path().join("magi.toml");
3644 std::fs::write(&path, "[merge]\nmode = \"none\"\nstyle = \"squash\"\n").unwrap();
3645 let parsed = Config::load(&path).expect("config must load");
3646 assert_eq!(parsed.merge.style, MergeStyle::Squash);
3647 }
3648
3649 #[test]
3650 fn later_layers_win_and_vars_render() {
3651 let dir = tempfile::tempdir().unwrap();
3652 let machine = dir.path().join("machine.toml");
3653 let project = dir.path().join("magi.toml");
3654 std::fs::write(
3656 &machine,
3657 "[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
3658 [graph]\ncandidates = 3\nmax_parallel = 8\n",
3659 )
3660 .unwrap();
3661 std::fs::write(
3664 &project,
3665 "[vars]\ncache = \"/shared\"\n\n\
3666 [graph]\ncandidates = 2\n\n\
3667 [verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
3668 )
3669 .unwrap();
3670
3671 let cfg = Config::load_layers(&[machine, project]).expect("layered load");
3672 assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
3673 assert_eq!(cfg.graph.implementers, 2, "project layer wins");
3674 assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
3675 assert_eq!(
3676 cfg.verify.gate,
3677 ["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
3678 );
3679 assert_eq!(cfg.cache_dir(), Some(PathBuf::from("/shared/t")));
3681 }
3682
3683 #[test]
3684 fn talk_defaults_to_an_hour_and_an_unwritten_config_still_gets_it() {
3685 let g = Graph::default();
3689 assert_eq!(g.timeout_talk, 3600);
3690
3691 let dir = tempfile::tempdir().unwrap();
3692 let path = dir.path().join("magi.toml");
3693 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
3694 let cfg = Config::load(&path).expect("must load without timeout_talk set");
3695 assert_eq!(cfg.graph.timeout_talk, 3600);
3696 }
3697
3698 #[test]
3699 fn an_overridden_talk_timeout_reaches_the_loaded_config() {
3700 let dir = tempfile::tempdir().unwrap();
3701 let path = dir.path().join("magi.toml");
3702 std::fs::write(&path, "[graph]\ntimeout_talk = 120\n").unwrap();
3703 let cfg = Config::load(&path).expect("must load");
3704 assert_eq!(cfg.graph.timeout_talk, 120);
3705 }
3706
3707 #[test]
3708 fn the_disk_defaults_are_the_measurements_made_up_front() {
3709 let cfg = Config::default();
3710 assert_eq!(cfg.disk.min_free_bytes, 8 * 1024 * 1024 * 1024);
3711 assert!(cfg.disk.auto_fold);
3712 assert_eq!(cfg.disk.fold_grace_secs, 6 * 60 * 60);
3713 assert_eq!(cfg.disk.cache_limit_bytes, 10 * 1024 * 1024 * 1024);
3714 }
3715
3716 #[test]
3717 fn an_unset_disk_section_is_the_safe_default() {
3718 let dir = tempfile::tempdir().unwrap();
3719 std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
3720 let cfg = Config::load(&dir.path().join("magi.toml")).expect("load");
3721 assert_eq!(cfg.disk, Disk::default());
3722 }
3723
3724 #[test]
3725 fn env_is_available_to_templates_with_a_default() {
3726 let dir = tempfile::tempdir().unwrap();
3727 let path = dir.path().join("magi.toml");
3728 std::fs::write(
3736 &path,
3737 "[verify]\n\
3738 gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
3739 \"populated={{ env | length > 0 }}\"]\n",
3740 )
3741 .unwrap();
3742 let cfg = Config::load(&path).expect("env lookup must render");
3743 assert_eq!(cfg.verify.gate[0], "cache=fallback");
3744 assert_eq!(cfg.verify.gate[1], "populated=true");
3745 }
3746
3747 #[test]
3748 fn a_broken_template_names_the_file() {
3749 let dir = tempfile::tempdir().unwrap();
3750 let path = dir.path().join("magi.toml");
3751 std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
3752 let err = Config::load(&path).expect_err("must not silently ignore");
3753 assert!(err.to_string().contains("teravars"), "{err}");
3754 }
3755
3756 #[test]
3757 fn tera_syntax_in_comments_is_inert() {
3758 let dir = tempfile::tempdir().unwrap();
3762 let path = dir.path().join("magi.toml");
3763 std::fs::write(
3764 &path,
3765 "# a comment may quote templates: `{{ env.NOPE | default(value='x') }}` and `{% if %}`\n\
3766 [graph]\ncandidates = 2\n",
3767 )
3768 .unwrap();
3769 let cfg = Config::load(&path).expect("comments must be inert, not rendered");
3770 assert_eq!(cfg.graph.implementers, 2);
3771 }
3772
3773 #[test]
3774 fn opencode_defaults_to_file_delivery() {
3775 let mut s = spec("oc");
3776 s.kind = AgentKind::Opencode;
3777 assert_eq!(s.delivery(), Delivery::File);
3778 s.prompt_delivery = Some(Delivery::Argv);
3779 assert_eq!(s.delivery(), Delivery::Argv);
3780 }
3781 #[test]
3782 fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
3783 let g = Graph::default();
3788 assert!(
3789 g.land,
3790 "stopping at an open PR left the watching to a human"
3791 );
3792 assert!(
3793 g.land_approval,
3794 "on-by-default land is only defensible while this is also on"
3795 );
3796 assert!(g.land_rounds > 0, "a loop with no budget never terminates");
3797 assert!(
3798 g.hold_contested_merge,
3799 "a contested hand-off must still reach a human when approvals are off"
3800 );
3801 }
3802 #[test]
3803 fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
3804 let dir = tempfile::tempdir().unwrap();
3808 let machine = dir.path().join("machine.toml");
3809 let repo = dir.path().join("magi.toml");
3810 std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
3811 std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();
3812
3813 let err = Config::load_layers(&[machine.clone(), repo.clone()])
3814 .expect_err("two layers naming one array must not merge silently");
3815 assert_eq!(
3816 err.downcast_ref::<ConfigFailure>().unwrap().stage,
3817 "config-merge"
3818 );
3819 let err = err.to_string();
3820 assert!(err.contains("roles.implementers"), "{err}");
3821 assert!(err.contains("machine.toml"), "{err}");
3824 assert!(err.contains("magi.toml"), "{err}");
3825 }
3826
3827 #[test]
3828 fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
3829 let dir = tempfile::tempdir().unwrap();
3832 let machine = dir.path().join("machine.toml");
3833 let repo = dir.path().join("magi.toml");
3834 std::fs::write(&machine, "[roles]\nchatter = \"opus\"\n").unwrap();
3835 std::fs::write(
3836 &repo,
3837 "[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
3838 [roles]\nimplementers = [\"oc\"]\n",
3839 )
3840 .unwrap();
3841
3842 let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
3843 assert_eq!(primary(cfg.roles.chatter.as_ref()), Some("opus"));
3844 assert_eq!(cfg.roles.implementers, ["oc"]);
3845 assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
3846 }
3847
3848 #[test]
3849 fn two_layers_declaring_verify_gate_run_both_in_priority_order() {
3850 let dir = tempfile::tempdir().unwrap();
3853 let machine = dir.path().join("machine.toml");
3854 let repo = dir.path().join("magi.toml");
3855 std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
3856 std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
3857
3858 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
3859 assert_eq!(
3860 cfg.verify.gate,
3861 [
3862 "editorconfig-checker".to_owned(),
3863 "cargo make check".to_owned()
3864 ],
3865 "low-priority (machine) command first, high-priority (repo) command after"
3866 );
3867 }
3868
3869 fn load_one(body: &str) -> Result<Config> {
3870 let dir = tempfile::tempdir().unwrap();
3871 let path = dir.path().join("magi.toml");
3872 std::fs::write(&path, body).unwrap();
3873 Config::load_layers(&[path])
3874 }
3875
3876 #[test]
3877 fn worktree_setup_defaults_to_empty_and_parses_steps() {
3878 assert!(Config::default().worktree.setup.is_empty());
3879 let cfg = load_one(
3880 "[worktree]\nsetup = [\n { copy = \".env.example -> .env\" },\n \
3881 { copy = \"x\", optional = true },\n { run = \"true\", timeout_secs = 5 },\n]\n",
3882 )
3883 .unwrap();
3884 assert_eq!(cfg.worktree.setup.len(), 3);
3885 assert!(cfg.worktree.setup[1].optional);
3886 assert_eq!(cfg.worktree.setup[2].timeout_secs, Some(5));
3887 }
3888
3889 #[test]
3890 fn worktree_setup_rejects_malformed_steps() {
3891 for bad in [
3892 "{ copy = \"a\", run = \"b\" }",
3893 "{ optional = true }",
3894 "{ copy = \"a -> ../b\" }",
3895 "{ copy = \"a -> /abs\" }",
3896 "{ copy = \"a -> .git/config\" }",
3897 "{ run = \"b\", optional = true }",
3898 "{ copy = \"a\", timeout_secs = 3 }",
3899 "{ run = \"b\", bogus = 1 }",
3900 ] {
3901 let body = format!("[worktree]\nsetup = [{bad}]\n");
3902 assert!(load_one(&body).is_err(), "{bad}");
3903 }
3904 }
3905
3906 #[test]
3907 fn worktree_setup_in_two_layers_is_refused() {
3908 let dir = tempfile::tempdir().unwrap();
3909 let machine = dir.path().join("machine.toml");
3910 let repo = dir.path().join("magi.toml");
3911 std::fs::write(&machine, "[worktree]\nsetup = [{ run = \"a\" }]\n").unwrap();
3912 std::fs::write(&repo, "[worktree]\nsetup = [{ run = \"b\" }]\n").unwrap();
3913 assert!(Config::load_layers(&[machine, repo]).is_err());
3914 }
3915
3916 #[test]
3917 fn starter_toml_documents_worktree_setup() {
3918 let starter = Config::starter_toml();
3919 assert!(starter.contains("# [worktree]") && starter.contains("setup = ["));
3920 let body = starter.replace("# [worktree]", "[worktree]");
3922 let mut out = String::new();
3923 let mut live = false;
3924 for line in body.lines() {
3925 if line == "[worktree]" {
3926 live = true;
3927 } else if live && !line.starts_with("# ") {
3928 live = false;
3929 }
3930 out.push_str(&if live {
3931 line.replacen("# ", "", 1)
3932 } else {
3933 line.to_owned()
3934 });
3935 out.push('\n');
3936 }
3937 let cfg = load_one(&out).expect("uncommented example loads");
3938 assert_eq!(cfg.worktree.setup.len(), 3);
3939 }
3940
3941 #[test]
3942 fn two_layers_declaring_verify_e2e_run_both_in_priority_order() {
3943 let dir = tempfile::tempdir().unwrap();
3944 let machine = dir.path().join("machine.toml");
3945 let repo = dir.path().join("magi.toml");
3946 std::fs::write(&machine, "[verify]\ne2e = [\"shared-smoke-test\"]\n").unwrap();
3947 std::fs::write(&repo, "[verify]\ne2e = [\"cargo test\"]\n").unwrap();
3948
3949 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
3950 assert_eq!(
3951 cfg.verify.e2e,
3952 ["shared-smoke-test".to_owned(), "cargo test".to_owned()]
3953 );
3954 }
3955
3956 #[test]
3957 fn two_layers_declaring_repos_roots_are_both_scanned() {
3958 let dir = tempfile::tempdir().unwrap();
3959 let machine = dir.path().join("machine.toml");
3960 let repo = dir.path().join("magi.toml");
3961 std::fs::write(&machine, "[repos]\nroots = [\"/machine/root\"]\n").unwrap();
3962 std::fs::write(&repo, "[repos]\nroots = [\"/repo/root\"]\n").unwrap();
3963
3964 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
3965 assert_eq!(
3966 cfg.repos.roots,
3967 [PathBuf::from("/machine/root"), PathBuf::from("/repo/root")]
3968 );
3969 }
3970
3971 #[test]
3972 fn duplicate_gate_commands_across_layers_both_run() {
3973 let dir = tempfile::tempdir().unwrap();
3976 let machine = dir.path().join("machine.toml");
3977 let repo = dir.path().join("magi.toml");
3978 std::fs::write(&machine, "[verify]\ngate = [\"same-command\"]\n").unwrap();
3979 std::fs::write(&repo, "[verify]\ngate = [\"same-command\"]\n").unwrap();
3980
3981 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
3982 assert_eq!(
3983 cfg.verify.gate,
3984 ["same-command".to_owned(), "same-command".to_owned()]
3985 );
3986 }
3987
3988 #[test]
3989 fn notify_command_is_still_refused_across_two_layers() {
3990 let dir = tempfile::tempdir().unwrap();
3992 let machine = dir.path().join("machine.toml");
3993 let repo = dir.path().join("magi.toml");
3994 std::fs::write(&machine, "[notify]\ncommand = [\"ntfy\", \"publish\"]\n").unwrap();
3995 std::fs::write(&repo, "[notify]\ncommand = [\"curl\", \"-X\"]\n").unwrap();
3996
3997 let err = Config::load_layers(&[machine.clone(), repo.clone()])
3998 .expect_err("an argv split across layers must not concatenate");
3999 assert_eq!(
4000 err.downcast_ref::<ConfigFailure>().unwrap().stage,
4001 "config-merge"
4002 );
4003 let err = err.to_string();
4004 assert!(err.contains("notify.command"), "{err}");
4005 assert!(err.contains("machine.toml"), "{err}");
4006 assert!(err.contains("magi.toml"), "{err}");
4007 }
4008
4009 #[test]
4010 fn one_layer_declaring_verify_gate_runs_unchanged() {
4011 let dir = tempfile::tempdir().unwrap();
4014 let path = dir.path().join("magi.toml");
4015 std::fs::write(&path, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4016
4017 let cfg = Config::load(&path).expect("single layer must still load");
4018 assert_eq!(cfg.verify.gate, ["cargo make check".to_owned()]);
4019 }
4020
4021 #[test]
4022 fn layered_parse_failures_name_the_failing_input() {
4023 let dir = tempfile::tempdir().unwrap();
4024 let a = dir.path().join("a.toml");
4025 let b = dir.path().join("b.toml");
4026 std::fs::write(&a, "[graph").unwrap();
4027 std::fs::write(&b, "[verify").unwrap();
4028 let paths = [a.clone(), b.clone()];
4029 let first = Config::load_layers(&paths).unwrap_err();
4030 let first_cause = first.downcast_ref::<ConfigFailure>().unwrap();
4031 assert_eq!(first_cause.stage, "config-parse");
4032 assert_eq!(first_cause.target, serde_json::to_string(&[&a]).unwrap());
4033 std::fs::write(&a, "").unwrap();
4034 let second = Config::load_layers(&paths).unwrap_err();
4035 let second_cause = second.downcast_ref::<ConfigFailure>().unwrap();
4036 assert_eq!(second_cause.stage, "config-parse");
4037 assert_eq!(second_cause.target, serde_json::to_string(&[&b]).unwrap());
4038 assert_ne!(first_cause.target, second_cause.target);
4039 }
4040
4041 #[test]
4042 fn describe_composed_names_the_contributing_layers_only_when_there_are_two() {
4043 let dir = tempfile::tempdir().unwrap();
4044 let machine = dir.path().join("machine.toml");
4045 let repo = dir.path().join("magi.toml");
4046 std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
4047 std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4048 let paths = vec![machine.clone(), repo.clone()];
4049
4050 let cfg = Config::load_layers(&paths).expect("appendable arrays must merge");
4051 let described =
4052 Config::describe_composed(&paths, &cfg.verify.gate, "verify.gate", "(none)");
4053 assert!(described.contains("editorconfig-checker && cargo make check"));
4054 assert!(
4055 described.contains(&machine.display().to_string()),
4056 "{described}"
4057 );
4058 assert!(
4059 described.contains(&repo.display().to_string()),
4060 "{described}"
4061 );
4062
4063 let single = vec![repo.clone()];
4065 let solo_cfg = Config::load_layers(&single).expect("single layer loads");
4066 let solo_described =
4067 Config::describe_composed(&single, &solo_cfg.verify.gate, "verify.gate", "(none)");
4068 assert_eq!(solo_described, "cargo make check");
4069 }
4070
4071 mod remote_layers {
4072 use super::*;
4073
4074 fn git(dir: &Path, args: &[&str]) {
4075 use crate::proc::Quiet as _;
4076 let out = std::process::Command::new("git")
4077 .args(args)
4078 .quiet()
4079 .current_dir(dir)
4080 .output()
4081 .unwrap();
4082 assert!(out.status.success(), "git {args:?}: {out:?}");
4083 }
4084
4085 fn fixture(remote: &str, disk: &str) -> (tempfile::TempDir, PathBuf) {
4088 let tmp = tempfile::tempdir().unwrap();
4089 let bare = tmp.path().join("o.git");
4090 git(tmp.path(), &["init", "--bare", "-b", "trunk", "o.git"]);
4091 let repo = tmp.path().join("repo");
4092 std::fs::create_dir_all(&repo).unwrap();
4093 git(&repo, &["init", "-b", "trunk"]);
4094 git(&repo, &["config", "user.name", "t"]);
4095 git(&repo, &["config", "user.email", "t@example.com"]);
4096 std::fs::write(repo.join("magi.toml"), remote).unwrap();
4097 git(&repo, &["add", "-A"]);
4098 git(&repo, &["commit", "-m", "x"]);
4099 git(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
4100 git(&repo, &["push", "origin", "trunk"]);
4101 git(&repo, &["fetch", "origin"]);
4102 git(&repo, &["remote", "set-head", "origin", "trunk"]);
4103 std::fs::write(repo.join("magi.toml"), disk).unwrap();
4104 (tmp, repo)
4105 }
4106
4107 #[test]
4108 fn reads_the_remote_blob_not_the_working_tree() {
4109 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4110 let (cfg, _) = Config::discover(&repo, None).unwrap();
4111 assert_eq!(cfg.graph.implementers, 2);
4112 }
4113
4114 #[test]
4115 fn detached_head_still_reads_the_remote() {
4116 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4117 git(&repo, &["checkout", "--detach"]);
4118 let (cfg, _) = Config::discover(&repo, None).unwrap();
4119 assert_eq!(cfg.graph.implementers, 2);
4120 }
4121
4122 #[test]
4123 fn a_missing_ref_is_an_error_not_a_fallback() {
4124 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4125 git(&repo, &["update-ref", "-d", "refs/remotes/origin/trunk"]);
4126 let err = format!("{:#}", Config::discover(&repo, None).unwrap_err());
4127 assert!(err.contains("origin"), "{err}");
4128 }
4129
4130 #[test]
4131 fn merge_base_in_the_checkout_locates_the_ref() {
4132 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[merge]\nbase = \"nope\"\n");
4133 assert!(Config::discover(&repo, None).is_err());
4134 }
4135
4136 #[test]
4137 fn the_magi_dir_layer_is_read_too() {
4138 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4139 std::fs::write(repo.join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
4140 std::fs::create_dir_all(repo.join(".magi")).unwrap();
4141 std::fs::write(repo.join(".magi/config.toml"), "[graph]\njudges = 3\n").unwrap();
4142 git(&repo, &["add", "-A"]);
4143 git(&repo, &["commit", "-m", "y"]);
4144 git(&repo, &["push", "origin", "trunk"]);
4145 git(&repo, &["fetch", "origin"]);
4146 let (cfg, layers) = Config::discover(&repo, None).unwrap();
4147 assert_eq!((cfg.graph.implementers, cfg.graph.judges), (2, 3));
4148 assert_eq!(layers.len(), 2);
4149 }
4150
4151 #[test]
4152 fn an_include_in_a_repository_layer_resolves_against_the_same_commit() {
4153 let (_t, repo) = fixture("include = [\"gate.toml\"]\n", "");
4154 std::fs::write(repo.join("magi.toml"), "include = [\"gate.toml\"]\n").unwrap();
4155 std::fs::write(repo.join("gate.toml"), "[graph]\ncandidates = 2\n").unwrap();
4156 git(&repo, &["add", "-A"]);
4157 git(&repo, &["commit", "-m", "inc"]);
4158 git(&repo, &["push", "origin", "trunk"]);
4159 git(&repo, &["fetch", "origin"]);
4160 std::fs::write(repo.join("gate.toml"), "[graph]\ncandidates = 9\n").unwrap();
4162 let (cfg, _) = Config::discover(&repo, None).unwrap();
4163 assert_eq!(cfg.graph.implementers, 2);
4164 }
4165
4166 #[test]
4167 fn a_machine_layer_base_is_rendered_before_locating_the_ref() {
4168 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "[merge]\nbase = \"nope\"\n");
4169 let m = t.path().join("machine.toml");
4170 std::fs::write(
4171 &m,
4172 "[merge]\nbase = \"{{ env.MAGI_TEST_NO_SUCH | default(value='trunk') }}\"\n",
4173 )
4174 .unwrap();
4175 let (remote, base) = {
4176 let probe = probe_merge(&m, &repo).unwrap();
4177 (probe.remote, probe.base)
4178 };
4179 assert_eq!((remote, base), (None, Some("trunk".to_owned())));
4180 }
4181
4182 #[test]
4183 fn a_machine_layer_base_sees_the_checkouts_repo_name() {
4184 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4185 let m = t.path().join("machine.toml");
4186 std::fs::write(&m, "[merge]\nbase = \"{{ repo_name }}\"\n").unwrap();
4187 let probe = probe_merge(&m, &repo).unwrap();
4188 assert_eq!(probe.base.as_deref(), Some("repo"));
4189 }
4190
4191 #[test]
4192 fn every_include_form_is_recognised() {
4193 assert!(declares_include("include = [\"a.toml\"]\n"));
4194 assert!(declares_include("\"include\" = [\"a.toml\"]\n"));
4195 assert!(declares_include("teravars = { include = [\"a.toml\"] }\n"));
4196 assert!(declares_include("[teravars]\ninclude = [\"a.toml\"]\n"));
4197 assert!(!declares_include("[graph]\ncandidates = 2\n"));
4198 }
4199
4200 #[test]
4201 fn an_explicit_config_is_read_as_named() {
4202 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4203 let own = t.path().join("own.toml");
4204 std::fs::write(&own, "[graph]\ncandidates = 4\n").unwrap();
4205 let (cfg, _) = Config::discover(&repo, Some(&own)).unwrap();
4206 assert_eq!(cfg.graph.implementers, 4);
4207 }
4208
4209 #[test]
4210 fn discover_fetched_fails_when_the_fetch_does() {
4211 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4212 std::fs::remove_dir_all(t.path().join("o.git")).unwrap();
4213 let rt = tokio::runtime::Builder::new_current_thread()
4214 .enable_all()
4215 .build()
4216 .unwrap();
4217 let err = rt
4218 .block_on(Config::discover_fetched(&repo, None))
4219 .unwrap_err();
4220 assert!(format!("{err:#}").contains("fetch"), "{err:#}");
4221 }
4222
4223 #[test]
4224 fn fetch_and_remote_parse_failures_have_distinct_verified_causes() {
4225 let (tmp, repo) = fixture("[graph\ncandidates = 2\n", "");
4226 let remote = tmp.path().join("o.git");
4227 let offline = tmp.path().join("offline.git");
4228 std::fs::rename(&remote, &offline).unwrap();
4229 let rt = tokio::runtime::Builder::new_current_thread()
4230 .enable_all()
4231 .build()
4232 .unwrap();
4233 let fetch = rt
4234 .block_on(Config::discover_fetched(&repo, None))
4235 .unwrap_err();
4236 let fetch_cause = fetch.downcast_ref::<ConfigFailure>().unwrap();
4237 assert_eq!(fetch_cause.stage, "config-fetch");
4238 assert_eq!(fetch_cause.target, "origin/trunk");
4239
4240 std::fs::rename(&offline, &remote).unwrap();
4241 let parse = rt
4242 .block_on(Config::discover_fetched(&repo, None))
4243 .unwrap_err();
4244 let parse_cause = parse.downcast_ref::<ConfigFailure>().unwrap();
4245 assert_eq!(parse_cause.stage, "config-parse");
4246 assert!(parse_cause.target.contains("magi.toml"));
4247 assert!(
4248 !parse_cause.target.contains("/magi/config/"),
4249 "cache commit paths are incidental"
4250 );
4251 assert_ne!(fetch_cause.stage, parse_cause.stage);
4252 }
4253
4254 #[test]
4255 fn discover_fetched_survives_a_fetch_that_leaves_no_origin_head() {
4256 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4259 git(&repo, &["remote", "set-head", "origin", "-d"]);
4260 git(&repo, &["update-ref", "-d", "refs/remotes/origin/trunk"]);
4261 git(
4262 &repo,
4263 &["config", "remote.origin.followRemoteHEAD", "never"],
4264 );
4265 let rt = tokio::runtime::Builder::new_current_thread()
4266 .enable_all()
4267 .build()
4268 .unwrap();
4269 let (cfg, _) = rt.block_on(Config::discover_fetched(&repo, None)).unwrap();
4270 assert_eq!(cfg.graph.implementers, 2);
4271 }
4272
4273 #[test]
4274 fn a_non_git_directory_reads_its_files() {
4275 let dir = tempfile::tempdir().unwrap();
4276 std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 4\n").unwrap();
4277 let (cfg, _) = Config::discover(dir.path(), None).unwrap();
4278 assert_eq!(cfg.graph.implementers, 4);
4279 }
4280 }
4281
4282 #[test]
4283 fn github_language_defaults_to_english_and_accepts_japanese() {
4284 let default: Graph = toml::from_str("").unwrap();
4285 assert_eq!(default.github_language, "en");
4286 assert!(default.github_english() && default.language_gate());
4287 let ja: Graph = toml::from_str("github_language = \"ja\"").unwrap();
4288 assert!(!ja.github_english() && !ja.language_gate());
4289 let only_ops: Graph = toml::from_str("language = \"ja\"").unwrap();
4291 assert!(only_ops.language_gate());
4292 let off: Graph = toml::from_str("github_text_guard = false").unwrap();
4293 assert!(!off.language_gate() && off.github_english());
4294 assert!(Config::starter_toml().contains("github_language = \"en\""));
4295 }
4296}