1use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33use std::sync::Mutex;
34
35use serde::Serialize;
36
37use crate::config::{AgentChoice, AgentSpec, Config};
38
39pub(crate) const ROLE_KEYS: [&str; 10] = [
41 "implementers",
42 "judges",
43 "reviewers",
44 "advisors",
45 "synthesizer",
46 "fixer",
47 "chatter",
48 "conductor",
49 "language_judge",
50 "dupes_judge",
51];
52
53fn is_chain_role(key: &str) -> bool {
55 matches!(
56 key,
57 "synthesizer" | "fixer" | "chatter" | "conductor" | "language_judge" | "dupes_judge"
58 )
59}
60
61static SAVE_LOCK: Mutex<()> = Mutex::new(());
63
64#[derive(Debug, Serialize)]
66pub(crate) struct SettingsView {
67 pub(crate) repo: String,
69 pub(crate) machine: MachineView,
70 pub(crate) repositories: Vec<crate::repos::Repo>,
71 pub(crate) revision: String,
73 pub(crate) error: Option<ConfigError>,
76 pub(crate) roles: Vec<RoleView>,
77 pub(crate) agents: Vec<AgentView>,
78}
79
80#[derive(Debug, Serialize)]
81pub(crate) struct MachineView {
82 pub(crate) path: Option<String>,
84 pub(crate) exists: bool,
85 pub(crate) unavailable: Option<String>,
87}
88
89#[derive(Debug, Serialize)]
90pub(crate) struct ConfigError {
91 pub(crate) message: String,
92 pub(crate) path: Option<String>,
94}
95
96#[derive(Debug, Serialize)]
97pub(crate) struct RoleView {
98 pub(crate) key: &'static str,
99 pub(crate) chain: bool,
101 pub(crate) configured: Vec<String>,
103 pub(crate) source: &'static str,
105 pub(crate) source_path: Option<String>,
106 pub(crate) machine_value: Option<toml::Value>,
108 pub(crate) editable: bool,
110 pub(crate) locked_reason: Option<String>,
111 pub(crate) fallback: Option<&'static str>,
113 pub(crate) seats: Vec<String>,
115 pub(crate) seats_error: Option<String>,
116 pub(crate) skipped: Vec<String>,
118 pub(crate) count: Option<CountView>,
120}
121
122#[derive(Debug, Serialize)]
126pub(crate) struct CountView {
127 pub(crate) value: usize,
128 pub(crate) min: usize,
131 pub(crate) file_key: &'static str,
134 pub(crate) source: &'static str,
136 pub(crate) source_path: Option<String>,
137 pub(crate) machine_value: Option<toml::Value>,
139 pub(crate) editable: bool,
140 pub(crate) locked_reason: Option<String>,
141 pub(crate) roster_len: Option<usize>,
143 pub(crate) backups: Option<usize>,
145}
146
147const COUNT_KEYS: [&str; 4] = ["implementers", "judges", "reviewers", "advisors"];
149
150fn count_spellings(key: &str) -> &'static [&'static str] {
151 match key {
152 "implementers" => &["implementers", "candidates"],
153 "judges" => &["judges"],
154 "reviewers" => &["reviewers"],
155 _ => &["advisors"],
156 }
157}
158
159fn count_min(key: &str) -> usize {
160 usize::from(key != "advisors")
161}
162
163fn graph_count(cfg: &Config, key: &str) -> usize {
164 match key {
165 "implementers" => cfg.graph.implementers,
166 "judges" => cfg.graph.judges,
167 "reviewers" => cfg.graph.reviewers,
168 _ => cfg.graph.advisors,
169 }
170}
171
172fn declares_count(table: &toml::Table, key: &str) -> bool {
173 table
174 .get("graph")
175 .and_then(toml::Value::as_table)
176 .is_some_and(|g| count_spellings(key).iter().any(|k| g.contains_key(*k)))
177}
178
179#[derive(Debug, Serialize)]
180pub(crate) struct AgentView {
181 pub(crate) id: String,
182 pub(crate) kind: String,
183 pub(crate) model: Option<String>,
184 pub(crate) source: &'static str,
186 pub(crate) source_path: Option<String>,
187}
188
189#[derive(Debug)]
191pub(crate) enum SaveError {
192 Conflict(String),
194 Refused(String),
196 Internal(String),
198}
199
200fn fingerprint(bytes: &[u8]) -> String {
202 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
203 for b in bytes {
204 hash ^= u64::from(*b);
205 hash = hash.wrapping_mul(0x0100_0000_01b3);
206 }
207 format!("{hash:016x}")
208}
209
210fn repo_layers(repo: &Path) -> Vec<PathBuf> {
211 Config::repo_layers(repo).unwrap_or_else(|e| {
212 tracing::warn!("could not read the repository's config layers: {e:#}");
213 Vec::new()
214 })
215}
216
217fn layer_paths_strict(repo: &Path, machine: Option<&Path>) -> anyhow::Result<Vec<PathBuf>> {
221 let mut paths: Vec<PathBuf> = machine
222 .filter(|m| m.is_file())
223 .map(Path::to_path_buf)
224 .into_iter()
225 .collect();
226 paths.extend(Config::repo_layers(repo)?);
227 Ok(paths)
228}
229
230fn layer_paths(repo: &Path, machine: Option<&Path>) -> Vec<PathBuf> {
234 let mut paths: Vec<PathBuf> = machine
235 .filter(|m| m.is_file())
236 .map(Path::to_path_buf)
237 .into_iter()
238 .collect();
239 paths.extend(repo_layers(repo));
240 paths
241}
242
243fn effective(paths: &[PathBuf]) -> anyhow::Result<Config> {
244 if paths.is_empty() {
245 return Ok(Config::autodetected());
246 }
247 Config::load_layers(paths)
248}
249
250fn declares(table: &toml::Table, key: &str) -> bool {
251 table
252 .get("roles")
253 .and_then(toml::Value::as_table)
254 .is_some_and(|r| r.contains_key(key))
255}
256
257fn key_name(key: &str) -> &'static str {
258 COUNT_KEYS
259 .iter()
260 .find(|k| **k == key)
261 .copied()
262 .unwrap_or("")
263}
264
265fn choice_ids(choice: Option<&AgentChoice>) -> Vec<String> {
266 choice
267 .map(|c| c.ids().into_iter().map(str::to_owned).collect())
268 .unwrap_or_default()
269}
270
271fn role_ids(cfg: &Config, key: &str) -> Vec<String> {
272 match key {
273 "implementers" => cfg.roles.implementers.clone(),
274 "judges" => cfg.roles.judges.clone(),
275 "reviewers" => cfg.roles.reviewers.clone(),
276 "advisors" => cfg.roles.advisors.clone(),
277 key if is_chain_role(key) => choice_ids(chain_choice(cfg, key)),
278 _ => unreachable!("unknown settings role: {key}"),
279 }
280}
281
282fn chain_choice<'a>(cfg: &'a Config, key: &str) -> Option<&'a AgentChoice> {
283 match key {
284 "synthesizer" => cfg.roles.synthesizer.as_ref(),
285 "fixer" => cfg.roles.fixer.as_ref(),
286 "chatter" => cfg.roles.chatter.as_ref(),
287 "conductor" => cfg.roles.conductor.as_ref(),
288 "language_judge" => cfg.roles.language_judge.as_ref(),
289 "dupes_judge" => cfg.roles.dupes_judge.as_ref(),
290 _ => unreachable!("unknown chain role: {key}"),
291 }
292}
293
294fn chain_seats(
295 cfg: &Config,
296 choice: Option<&AgentChoice>,
297 view: &mut RoleView,
298) -> anyhow::Result<Vec<String>> {
299 let seats = crate::agent::pick_chain(&cfg.agents, choice, &crate::agent::installed, view.key)
300 .map(|chain| ids_of(&chain));
301 let runnable = seats.as_ref().cloned().unwrap_or_default();
302 view.skipped = choice_ids(choice)
303 .into_iter()
304 .filter(|id| !runnable.contains(id))
305 .collect();
306 seats
307}
308
309fn ids_of(specs: &[AgentSpec]) -> Vec<String> {
310 specs.iter().map(|s| s.id.clone()).collect()
311}
312
313#[cfg(test)]
316pub(crate) fn view(repo: &Path, machine: Option<&Path>) -> SettingsView {
317 view_with_repos(repo, machine, &[])
318}
319
320pub(crate) fn repository_config(repo: &Path, machine: Option<&Path>) -> anyhow::Result<Config> {
321 effective(&layer_paths_strict(repo, machine)?)
322}
323
324pub(crate) fn view_with_repos(
325 repo: &Path,
326 machine: Option<&Path>,
327 known: &[crate::repos::Repo],
328) -> SettingsView {
329 let bytes = machine
330 .and_then(|m| std::fs::read(m).ok())
331 .unwrap_or_default();
332 let mut out = SettingsView {
333 repo: repo.display().to_string(),
334 machine: MachineView {
335 path: machine.map(|m| m.display().to_string()),
336 exists: machine.is_some_and(Path::is_file),
337 unavailable: machine.is_none().then(|| {
338 "This machine has no machine-config location (the config directory is \
339 unset or empty), so nothing can be saved from here."
340 .to_owned()
341 }),
342 },
343 repositories: known.to_vec(),
344 revision: fingerprint(&bytes),
345 error: None,
346 roles: Vec::new(),
347 agents: Vec::new(),
348 };
349 let paths = match layer_paths_strict(repo, machine) {
350 Ok(p) => p,
351 Err(e) => {
352 out.error = Some(ConfigError {
353 message: format!("{e:#}"),
354 path: None,
355 });
356 return out;
357 }
358 };
359 let cfg = match effective(&paths) {
360 Ok(cfg) => cfg,
361 Err(e) => {
362 let failing = Config::layer_tables(&paths)
363 .into_iter()
364 .find(|(_, t)| t.is_err())
365 .map(|(p, _)| p.display().to_string());
366 out.error = Some(ConfigError {
367 message: format!("{e:#}"),
368 path: failing,
369 });
370 return out;
371 }
372 };
373 let tables: Vec<(PathBuf, toml::Table)> = Config::layer_tables(&paths)
374 .into_iter()
375 .filter_map(|(p, t)| t.ok().map(|t| (p, t)))
376 .collect();
377 let is_machine = |p: &Path| machine.is_some_and(|m| m == p);
378
379 let mut other_declares: BTreeMap<&str, String> = BTreeMap::new();
380 for other in other_checkouts(repo, &cfg.repos.roots, known) {
381 let theirs = Config::layer_tables(&repo_layers(&other.path));
382 for key in ROLE_KEYS {
383 if theirs
384 .iter()
385 .any(|(_, t)| t.as_ref().is_ok_and(|t| declares(t, key)))
386 {
387 other_declares
388 .entry(key)
389 .or_insert_with(|| other.name.clone());
390 }
391 }
392 }
393 let resolved = cfg.resolve_roles();
394 let advisors = cfg.advisors();
395 for key in ROLE_KEYS {
396 let configured = role_ids(&cfg, key);
397 let owner = tables.iter().rev().find(|(_, t)| declares(t, key));
398 let (source, source_path) = match owner {
399 Some((p, _)) if is_machine(p) => ("machine", Some(p.display().to_string())),
400 Some((p, _)) => ("repo", Some(Config::repo_layer_description(repo, p))),
401 None => ("default", None),
402 };
403 let repo_owner = tables
404 .iter()
405 .find(|(p, t)| !is_machine(p) && declares(t, key));
406 let (editable, locked_reason) = if let Some((p, _)) = repo_owner {
407 (
408 false,
409 Some(format!(
410 "This repo overrides roles.{key} in {} - edit it there.",
411 Config::repo_layer_description(repo, p)
412 )),
413 )
414 } else if let Some(name) = other_declares.get(key) {
415 (
416 false,
417 Some(format!(
418 "`{name}` declares roles.{key} in its own config, so a machine setting \
419 would stop it from loading. Edit it there."
420 )),
421 )
422 } else if machine.is_none() {
423 (false, out.machine.unavailable.clone())
424 } else {
425 (true, None)
426 };
427 let mut view = RoleView {
428 key,
429 chain: is_chain_role(key),
430 configured,
431 source,
432 source_path,
433 machine_value: tables
434 .iter()
435 .find(|(p, _)| is_machine(p))
436 .and_then(|(_, t)| t.get("roles")?.get(key))
437 .cloned(),
438 editable,
439 locked_reason,
440 fallback: None,
441 seats: Vec::new(),
442 seats_error: None,
443 skipped: Vec::new(),
444 count: None,
445 };
446 if COUNT_KEYS.contains(&key) {
447 let owner = tables.iter().rev().find(|(_, t)| declares_count(t, key));
448 let (source, source_path) = match owner {
449 Some((p, _)) if is_machine(p) => ("machine", Some(p.display().to_string())),
450 Some((p, _)) => ("repo", Some(Config::repo_layer_description(repo, p))),
451 None => ("default", None),
452 };
453 let repo_owner = tables
454 .iter()
455 .find(|(p, t)| !is_machine(p) && declares_count(t, key));
456 let (editable, locked_reason) = if let Some((p, _)) = repo_owner {
457 (
458 false,
459 Some(format!(
460 "This repo overrides graph.{key} in {} - edit it there.",
461 Config::repo_layer_description(repo, p)
462 )),
463 )
464 } else if machine.is_none() {
465 (false, out.machine.unavailable.clone())
466 } else {
467 (true, None)
468 };
469 let file_key = tables
470 .iter()
471 .find(|(p, _)| is_machine(p))
472 .and_then(|(_, t)| t.get("graph").and_then(toml::Value::as_table))
473 .filter(|g| key == "implementers" && g.contains_key("candidates"))
474 .map_or(key_name(key), |_| "candidates");
475 let roster_len = resolved.as_ref().ok().map(|r| match key {
476 "implementers" => r.implementer_roster.len(),
477 "judges" => r.judge_roster.len(),
478 _ => r.reviewer_roster.len(),
479 });
480 let roster_len = if key == "advisors" {
481 cfg.advisor_roster().ok().map(|r| r.len())
482 } else {
483 roster_len
484 };
485 let value = graph_count(&cfg, key);
486 view.count = Some(CountView {
487 value,
488 min: count_min(key),
489 file_key,
490 source,
491 source_path,
492 machine_value: tables
493 .iter()
494 .find(|(p, _)| is_machine(p))
495 .and_then(|(_, t)| {
496 count_spellings(key)
497 .iter()
498 .find_map(|k| t.get("graph")?.get(*k))
499 })
500 .cloned(),
501 editable,
502 locked_reason,
503 roster_len,
504 backups: roster_len.map(|n| n.saturating_sub(value)),
505 });
506 }
507 let seats = match key {
508 "implementers" => resolved
509 .as_ref()
510 .map(|r| ids_of(&r.implementers))
511 .map_err(|e| anyhow::anyhow!("{e:#}")),
512 "judges" => resolved
513 .as_ref()
514 .map(|r| ids_of(&r.judges))
515 .map_err(|e| anyhow::anyhow!("{e:#}")),
516 "reviewers" => resolved
517 .as_ref()
518 .map(|r| ids_of(&r.reviewers))
519 .map_err(|e| anyhow::anyhow!("{e:#}")),
520 "advisors" => {
521 if view.configured.is_empty() {
522 view.fallback = Some("judges");
523 }
524 advisors
525 .as_ref()
526 .map(|a| ids_of(a))
527 .map_err(|e| anyhow::anyhow!("{e:#}"))
528 }
529 "fixer" if cfg.roles.fixer.is_none() => {
531 view.fallback = Some("winner's implementer");
532 Ok(Vec::new())
533 }
534 "language_judge" if cfg.roles.language_judge.is_none() => {
535 view.fallback = Some("built-in heuristics");
536 Ok(Vec::new())
537 }
538 "dupes_judge" => {
539 if cfg.roles.dupes_judge.is_none() {
540 view.fallback = Some("chatter");
541 }
542 chain_seats(
543 &cfg,
544 cfg.roles
545 .dupes_judge
546 .as_ref()
547 .or(cfg.roles.chatter.as_ref()),
548 &mut view,
549 )
550 }
551 "conductor"
553 if cfg
554 .roles
555 .conductor
556 .as_ref()
557 .is_none_or(|c| c.ids().len() <= 1) =>
558 {
559 let seats = resolved
560 .as_ref()
561 .map(|r| vec![r.conductor.id.clone()])
562 .map_err(|e| anyhow::anyhow!("{e:#}"));
563 if seats.is_err() {
564 view.skipped = view.configured.clone();
565 }
566 seats
567 }
568 "fixer" | "synthesizer" | "chatter" | "conductor" | "language_judge" => {
569 chain_seats(&cfg, chain_choice(&cfg, key), &mut view)
570 }
571 _ => unreachable!("unknown settings role: {key}"),
572 };
573 match seats {
574 Ok(ids) => view.seats = ids,
575 Err(e) => view.seats_error = Some(format!("{e:#}")),
576 }
577 out.roles.push(view);
578 }
579
580 out.agents = cfg
581 .agents
582 .iter()
583 .map(|a| {
584 let owner = tables.iter().rev().find(|(_, t)| {
585 t.get("agents")
586 .and_then(toml::Value::as_array)
587 .is_some_and(|list| {
588 list.iter()
589 .any(|x| x.get("id").and_then(toml::Value::as_str) == Some(&a.id))
590 })
591 });
592 let (source, source_path) = match owner {
593 Some((p, _)) if is_machine(p) => ("machine", Some(p.display().to_string())),
594 Some((p, _)) => ("repo", Some(Config::repo_layer_description(repo, p))),
595 None => ("detected", None),
596 };
597 AgentView {
598 id: a.id.clone(),
599 kind: toml::Value::try_from(a.kind)
600 .ok()
601 .and_then(|v| v.as_str().map(str::to_owned))
602 .unwrap_or_default(),
603 model: a.model.clone(),
604 source,
605 source_path,
606 }
607 })
608 .collect();
609 out
610}
611
612#[cfg(test)]
617pub(crate) fn save(
618 repo: &Path,
619 machine: Option<&Path>,
620 revision: &str,
621 roles: &BTreeMap<String, Vec<String>>,
622 counts: &BTreeMap<String, serde_json::Value>,
623) -> Result<SettingsView, SaveError> {
624 save_with_repos(repo, machine, revision, roles, counts, &[])
625}
626
627pub(crate) fn save_with_repos(
628 repo: &Path,
629 machine: Option<&Path>,
630 revision: &str,
631 roles: &BTreeMap<String, Vec<String>>,
632 counts: &BTreeMap<String, serde_json::Value>,
633 known: &[crate::repos::Repo],
634) -> Result<SettingsView, SaveError> {
635 let _guard = SAVE_LOCK.lock().unwrap_or_else(|p| p.into_inner());
636 let Some(machine) = machine else {
637 return Err(SaveError::Refused(
638 "This machine has no machine-config location, so there is nowhere to save to."
639 .to_owned(),
640 ));
641 };
642 let current = view_with_repos(repo, Some(machine), known);
643 if let Some(err) = ¤t.error {
644 return Err(SaveError::Refused(format!(
645 "The current config does not load, so it cannot be edited safely: {}",
646 err.message
647 )));
648 }
649 if current.revision != revision {
650 return Err(SaveError::Conflict(
651 "The machine config changed since this screen loaded it. Reload and apply \
652 the change again."
653 .to_owned(),
654 ));
655 }
656 if roles.is_empty() && counts.is_empty() {
657 return Err(SaveError::Refused("Nothing to change.".to_owned()));
658 }
659 let agent_ids: Vec<&str> = current.agents.iter().map(|a| a.id.as_str()).collect();
660 let mut edits: Vec<(&'static str, Vec<String>)> = Vec::new();
661 for (key, ids) in roles {
662 let Some(role) = current.roles.iter().find(|r| r.key == key) else {
663 return Err(SaveError::Refused(format!(
664 "`{key}` is not a role this screen edits."
665 )));
666 };
667 if !role.editable {
668 return Err(SaveError::Refused(
669 role.locked_reason
670 .clone()
671 .unwrap_or_else(|| format!("`{key}` cannot be edited here.")),
672 ));
673 }
674 let ids: Vec<String> = ids.iter().map(|i| i.trim().to_owned()).collect();
675 if let Some(bad) = ids
676 .iter()
677 .find(|i| i.is_empty() || !agent_ids.contains(&i.as_str()))
678 {
679 return Err(SaveError::Refused(format!(
680 "`{bad}` is not a defined agent. Defined: {}.",
681 agent_ids.join(", ")
682 )));
683 }
684 edits.push((role.key, ids));
685 }
686 let mut count_edits: Vec<(&'static str, &'static str, usize)> = Vec::new();
687 for (key, raw) in counts {
688 let Some(count) = current
689 .roles
690 .iter()
691 .find(|r| r.key == key)
692 .and_then(|r| r.count.as_ref())
693 else {
694 return Err(SaveError::Refused(format!(
695 "`{key}` has no seat count this screen edits."
696 )));
697 };
698 if !count.editable {
699 return Err(SaveError::Refused(
700 count
701 .locked_reason
702 .clone()
703 .unwrap_or_else(|| format!("The `{key}` seat count cannot be edited here.")),
704 ));
705 }
706 let Some(n) = raw.as_u64().and_then(|n| usize::try_from(n).ok()) else {
707 return Err(SaveError::Refused(format!(
708 "The {key} seat count must be a whole number, got {raw}."
709 )));
710 };
711 if n < count.min {
712 return Err(SaveError::Refused(format!(
713 "The {key} seat count must be at least {}, got {n}.",
714 count.min
715 )));
716 }
717 count_edits.push((key_name(key), count.file_key, n));
718 }
719
720 let original = match std::fs::read_to_string(machine) {
721 Ok(text) => text,
722 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
723 Err(e) => {
724 return Err(SaveError::Internal(format!(
725 "reading {}: {e}",
726 machine.display()
727 )));
728 }
729 };
730 let mut text = original.clone();
731 for (key, ids) in &edits {
732 text = patch_role(&text, key, ids).map_err(SaveError::Refused)?;
733 }
734 for (key, file_key, n) in &count_edits {
735 text = patch_count(&text, file_key, key, *n).map_err(SaveError::Refused)?;
736 }
737 let dir = machine
738 .parent()
739 .ok_or_else(|| SaveError::Internal("the machine config has no parent directory".into()))?;
740 std::fs::create_dir_all(dir)
741 .map_err(|e| SaveError::Internal(format!("creating {}: {e}", dir.display())))?;
742 let nonce = std::time::SystemTime::now()
743 .duration_since(std::time::UNIX_EPOCH)
744 .map_or(0, |d| d.as_nanos());
745 let tmp = dir.join(format!(".config.toml.{}.{nonce}.tmp", std::process::id()));
746 let cleanup = |e: SaveError| {
747 let _ = std::fs::remove_file(&tmp);
748 e
749 };
750 write_synced(&tmp, &text).map_err(|e| {
751 cleanup(SaveError::Internal(format!(
752 "writing {}: {e}",
753 tmp.display()
754 )))
755 })?;
756
757 let mut layers = vec![tmp.clone()];
760 match Config::repo_layers(repo) {
761 Ok(l) => layers.extend(l),
762 Err(e) => {
763 return Err(cleanup(SaveError::Refused(format!(
764 "The repository's config cannot be read, so nothing was saved: {e:#}"
765 ))));
766 }
767 }
768 let loaded = Config::load_layers(&layers).map_err(|e| {
769 cleanup(SaveError::Refused(format!(
770 "The change would leave the config unloadable, so nothing was saved: {e:#}"
771 )))
772 })?;
773 for (key, ids) in &edits {
774 let got = role_ids(&loaded, key);
775 if &got != ids {
776 return Err(cleanup(SaveError::Refused(format!(
777 "The change would not take effect as asked: `{key}` would resolve to [{}] \
778 instead of [{}] (an include or a template in the machine file overrides \
779 it). Nothing was saved.",
780 got.join(", "),
781 ids.join(", ")
782 ))));
783 }
784 }
785 for (key, _, n) in &count_edits {
786 let got = graph_count(&loaded, key);
787 if got != *n {
788 return Err(cleanup(SaveError::Refused(format!(
789 "The change would not take effect as asked: the {key} seat count would be \
790 {got} instead of {n} (an include or a template in the machine file \
791 overrides it). Nothing was saved."
792 ))));
793 }
794 }
795 other_repos_still_load(repo, machine, &tmp, &loaded.repos.roots, known).map_err(cleanup)?;
796 std::fs::rename(&tmp, machine).map_err(|e| {
797 cleanup(SaveError::Internal(format!(
798 "replacing {}: {e}",
799 machine.display()
800 )))
801 })?;
802 Ok(view_with_repos(repo, Some(machine), known))
803}
804
805fn other_checkouts(
808 repo: &Path,
809 roots: &[PathBuf],
810 known: &[crate::repos::Repo],
811) -> Vec<crate::repos::Repo> {
812 let here = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
813 let mut checkouts = crate::repos::scan(roots);
814 for r in known {
815 if !checkouts.iter().any(|c| c.path == r.path) {
816 checkouts.push(r.clone());
817 }
818 }
819 checkouts
820 .into_iter()
821 .filter(|r| r.path != here && !repo_layers(&r.path).is_empty())
822 .collect()
823}
824
825fn other_repos_still_load(
831 repo: &Path,
832 machine: &Path,
833 proposal: &Path,
834 roots: &[PathBuf],
835 known: &[crate::repos::Repo],
836) -> Result<(), SaveError> {
837 for other in other_checkouts(repo, roots, known) {
838 let layers = repo_layers(&other.path);
839 let mut old = layer_paths(&other.path, Some(machine));
840 if old.is_empty() {
841 old = layers.clone();
842 }
843 if Config::load_layers(&old).is_err() {
844 continue;
845 }
846 let mut new = vec![proposal.to_path_buf()];
847 new.extend(layers);
848 if let Err(e) = Config::load_layers(&new) {
849 return Err(SaveError::Refused(format!(
850 "Nothing was saved: this machine setting would stop `{}` from loading, \
851 because that repository declares the same setting in its own config \
852 ({e:#}). Edit it there, or remove it from that repository first.",
853 other.name
854 )));
855 }
856 }
857 Ok(())
858}
859
860fn write_synced(path: &Path, text: &str) -> std::io::Result<()> {
861 use std::io::Write;
862 let mut f = std::fs::File::create(path)?;
863 f.write_all(text.as_bytes())?;
864 f.sync_all()
865}
866
867fn quote(s: &str) -> String {
868 toml::Value::String(s.to_owned()).to_string()
869}
870
871fn value_text(key: &str, ids: &[String]) -> String {
874 if is_chain_role(key) && ids.len() == 1 {
875 return quote(&ids[0]);
876 }
877 let items: Vec<String> = ids.iter().map(|i| quote(i)).collect();
878 format!("[{}]", items.join(", "))
879}
880
881#[derive(Default)]
884struct Scan {
885 depth: i32,
886 multi: Option<&'static str>,
887}
888
889impl Scan {
890 fn open(&self) -> bool {
891 self.depth > 0 || self.multi.is_some()
892 }
893}
894
895fn scan_line(line: &str, st: &mut Scan) -> Option<usize> {
899 let b = line.as_bytes();
900 let mut quote: Option<u8> = None;
901 let mut escaped = false;
902 let mut i = 0;
903 while i < b.len() {
904 if let Some(delim) = st.multi {
905 if b[i..].starts_with(delim.as_bytes()) {
906 st.multi = None;
907 i += 3;
908 } else if delim == "\"\"\"" && b[i] == b'\\' {
909 i += 2;
910 } else {
911 i += 1;
912 }
913 continue;
914 }
915 let c = b[i];
916 match quote {
917 Some(b'"') if escaped => escaped = false,
918 Some(b'"') if c == b'\\' => escaped = true,
919 Some(q) if c == q => quote = None,
920 Some(_) => {}
921 None => {
922 if b[i..].starts_with(b"\"\"\"") {
923 st.multi = Some("\"\"\"");
924 i += 3;
925 continue;
926 }
927 if b[i..].starts_with(b"'''") {
928 st.multi = Some("'''");
929 i += 3;
930 continue;
931 }
932 match c {
933 b'"' | b'\'' => quote = Some(c),
934 b'[' | b'{' => st.depth += 1,
935 b']' | b'}' => st.depth -= 1,
936 b'#' => return Some(i),
937 _ => {}
938 }
939 }
940 }
941 i += 1;
942 }
943 None
944}
945
946fn quoted_ids(code: &str) -> Vec<String> {
948 code.split('"')
949 .skip(1)
950 .step_by(2)
951 .map(str::to_owned)
952 .collect()
953}
954
955fn assigned_key(trimmed: &str) -> Option<(String, usize)> {
957 let eq = trimmed.find('=')?;
958 let raw = trimmed[..eq].trim();
959 let key = raw.trim_matches(|c| c == '"' || c == '\'');
960 let simple = !key.is_empty()
961 && key
962 .chars()
963 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
964 simple.then(|| (key.to_owned(), eq + 1))
965}
966
967fn is_roles_header(trimmed: &str) -> bool {
968 is_table_header(trimmed, "roles")
969}
970
971fn is_table_header(trimmed: &str, name: &str) -> bool {
972 let Some(rest) = trimmed.strip_prefix('[') else {
973 return false;
974 };
975 if rest.starts_with('[') {
976 return false;
977 }
978 rest.split(']')
979 .next()
980 .is_some_and(|n| n.trim().trim_matches(|c| c == '"' || c == '\'') == name)
981}
982
983struct OldSpan {
986 start: usize,
987 end: usize,
988 notes: Vec<(usize, Vec<String>, String)>,
990 seats: Vec<(String, Option<String>)>,
994}
995
996fn patch_role(text: &str, key: &str, ids: &[String]) -> Result<String, String> {
1003 let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
1004 let lines: Vec<&str> = text.split_inclusive('\n').collect();
1005
1006 let mut st = Scan::default();
1009 let mut in_roles = false;
1010 let mut header: Option<usize> = None;
1011 let mut last_key_end: Option<usize> = None;
1012 let mut span: Option<OldSpan> = None;
1013 let mut i = 0;
1014 while i < lines.len() {
1015 let trimmed = lines[i].trim();
1016 if !st.open() && trimmed.starts_with('[') {
1017 in_roles = header.is_none() && is_roles_header(trimmed);
1018 if in_roles {
1019 header = Some(i);
1020 } else if header.is_some() {
1021 break;
1022 }
1023 i += 1;
1024 continue;
1025 }
1026 if !st.open()
1027 && let Some((name, _)) = assigned_key(trimmed)
1028 {
1029 let start = i;
1030 let first = lines[i].trim_start();
1031 let at = first.find('=').map_or(0, |p| p + 1);
1032 let mut end = i;
1033 let mut notes = Vec::new();
1034 let mut seats: Vec<(String, Option<String>)> = Vec::new();
1035 let mut note = |line: usize, code: &str, hash: Option<usize>| {
1036 let ids = quoted_ids(&code[..hash.unwrap_or(code.len())]);
1037 let comment = hash.map(|h| code[h..].trim_end().to_owned());
1038 let last = ids.len().saturating_sub(1);
1039 for (n, id) in ids.iter().enumerate() {
1040 seats.push((id.clone(), comment.clone().filter(|_| n == last)));
1041 }
1042 if let Some(c) = comment {
1043 notes.push((line, ids, c));
1044 }
1045 };
1046 let code = &first[at..];
1047 let hash = scan_line(code, &mut st);
1048 note(start, code, hash);
1049 while st.open() && end + 1 < lines.len() {
1050 end += 1;
1051 let hash = scan_line(lines[end], &mut st);
1052 note(end, lines[end], hash);
1053 }
1054 if in_roles {
1055 last_key_end = Some(end);
1056 if name == key {
1057 let body = lines[start..=end].concat();
1058 if body.contains("\"\"\"") || body.contains("'''") {
1059 return Err(format!(
1060 "`{key}` uses a multi-line string; edit it by hand."
1061 ));
1062 }
1063 if body.contains("{{") || body.contains("{%") {
1064 return Err(format!(
1065 "`{key}` is written with a template expression, which this screen \
1066 cannot edit without losing it. Change it by hand."
1067 ));
1068 }
1069 span = Some(OldSpan {
1070 start,
1071 end,
1072 notes,
1073 seats,
1074 });
1075 }
1076 }
1077 i = end + 1;
1078 continue;
1079 }
1080 if st.open() {
1081 scan_line(lines[i], &mut st);
1082 }
1083 i += 1;
1084 }
1085
1086 let mut out: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
1087 let new_line = |indent: &str, comment: Option<&str>| {
1088 let mut s = format!("{indent}{key} = {}", value_text(key, ids));
1089 if let Some(c) = comment {
1090 s.push_str(" ");
1091 s.push_str(c);
1092 }
1093 s.push_str(eol);
1094 s
1095 };
1096 match (span, ids.is_empty()) {
1097 (Some(old), true) => {
1098 let indent: String = lines[old.start]
1099 .chars()
1100 .take_while(|c| c.is_whitespace())
1101 .collect();
1102 let kept: Vec<String> = old
1103 .notes
1104 .iter()
1105 .map(|(_, _, c)| format!("{indent}{c}{eol}"))
1106 .collect();
1107 out.splice(old.start..=old.end, kept);
1108 }
1109 (Some(old), false) => {
1110 let indent: String = lines[old.start]
1111 .chars()
1112 .take_while(|c| c.is_whitespace())
1113 .collect();
1114 let single = old.start == old.end;
1115 let trailing = match old.notes.last() {
1118 Some((line, on_line, c)) if *line == old.end && (single || on_line.is_empty()) => {
1119 Some(c.clone())
1120 }
1121 _ => None,
1122 };
1123 let interior = &old.notes[..old.notes.len() - usize::from(trailing.is_some())];
1124 let replacement: Vec<String> =
1125 if interior.is_empty() || (is_chain_role(key) && ids.len() == 1) {
1126 let mut v: Vec<String> = interior
1128 .iter()
1129 .map(|(_, _, c)| format!("{indent}{c}{eol}"))
1130 .collect();
1131 v.push(new_line(&indent, trailing.as_deref()));
1132 v
1133 } else {
1134 let mut v = vec![format!("{indent}{key} = [{eol}")];
1135 for (_, _, c) in interior.iter().filter(|(_, l, _)| l.is_empty()) {
1136 v.push(format!("{indent} {c}{eol}"));
1137 }
1138 let mut taken: std::collections::HashMap<&str, usize> = Default::default();
1139 for id in ids {
1140 let nth = taken.entry(id.as_str()).or_insert(0);
1141 let note = old
1142 .seats
1143 .iter()
1144 .filter(|(old_id, _)| old_id == id)
1145 .nth(*nth)
1146 .and_then(|(_, c)| c.as_ref())
1147 .map(|c| format!(" {c}"))
1148 .unwrap_or_default();
1149 *nth += 1;
1150 v.push(format!("{indent} {},{note}{eol}", quote(id)));
1151 }
1152 v.push(format!(
1153 "{indent}]{}{eol}",
1154 trailing.map(|c| format!(" {c}")).unwrap_or_default()
1155 ));
1156 v
1157 };
1158 out.splice(old.start..=old.end, replacement);
1159 }
1160 (None, true) => {}
1161 (None, false) => match (header, last_key_end) {
1162 (Some(_), Some(end)) | (Some(end), None) => {
1163 if !out[end].ends_with('\n') {
1164 out[end].push_str(eol);
1165 }
1166 out.insert(end + 1, new_line("", None));
1167 }
1168 (None, _) => {
1169 if let Some(last) = out.last_mut()
1170 && !last.ends_with('\n')
1171 {
1172 last.push_str(eol);
1173 }
1174 if !out.is_empty() {
1175 out.push(eol.to_owned());
1176 }
1177 out.push(format!("[roles]{eol}"));
1178 out.push(new_line("", None));
1179 }
1180 },
1181 }
1182 Ok(out.concat())
1183}
1184
1185fn patch_count(text: &str, file_key: &str, key: &str, n: usize) -> Result<String, String> {
1194 let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
1195 let spellings = count_spellings(key);
1196 let lines: Vec<&str> = text.split_inclusive('\n').collect();
1197 let mut st = Scan::default();
1198 let mut in_graph = false;
1199 let mut in_table = false;
1200 let mut header: Option<usize> = None;
1201 let mut last_key_end: Option<usize> = None;
1202 let mut found: Vec<(usize, usize)> = Vec::new();
1203 let mut i = 0;
1204 while i < lines.len() {
1205 let trimmed = lines[i].trim();
1206 if !st.open() && trimmed.starts_with('[') {
1207 in_table = true;
1208 in_graph = header.is_none() && is_table_header(trimmed, "graph");
1209 if in_graph {
1210 header = Some(i);
1211 } else if header.is_some() {
1212 break;
1213 }
1214 i += 1;
1215 continue;
1216 }
1217 if !st.open()
1218 && !in_table
1219 && (trimmed.starts_with("graph.") || trimmed.starts_with("graph "))
1220 {
1221 let inline = assigned_key(trimmed).is_some_and(|(k, _)| k == "graph");
1222 if inline || trimmed.starts_with("graph.") {
1223 return Err(
1224 "`graph` is written as an inline or dotted table, which this screen \
1225 cannot edit. Change it by hand."
1226 .to_owned(),
1227 );
1228 }
1229 }
1230 if !st.open()
1231 && let Some((name, _)) = assigned_key(trimmed)
1232 {
1233 let start = i;
1234 let first = lines[i].trim_start();
1235 let at = first.find('=').map_or(0, |p| p + 1);
1236 let mut end = i;
1237 scan_line(&first[at..], &mut st);
1238 while st.open() && end + 1 < lines.len() {
1239 end += 1;
1240 scan_line(lines[end], &mut st);
1241 }
1242 if in_graph {
1243 last_key_end = Some(end);
1244 if spellings.contains(&name.as_str()) {
1245 found.push((start, end));
1246 }
1247 }
1248 i = end + 1;
1249 continue;
1250 }
1251 if st.open() {
1252 scan_line(lines[i], &mut st);
1253 }
1254 i += 1;
1255 }
1256 if found.len() > 1 {
1257 return Err(format!(
1258 "`[graph]` sets `{key}` more than once (it and its alias `candidates`); keep \
1259 only one and try again."
1260 ));
1261 }
1262 let mut out: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
1263 if let Some(&(start, end)) = found.first() {
1264 let body = lines[start..=end].concat();
1265 if start != end || body.contains("{{") || body.contains("{%") {
1266 return Err(format!(
1267 "`{file_key}` is written with a template expression or over several lines, \
1268 which this screen cannot edit without losing it. Change it by hand."
1269 ));
1270 }
1271 let line = lines[start];
1272 let eq = line.find('=').unwrap_or(0) + 1;
1273 let rest = &line[eq..];
1274 let mut scratch = Scan::default();
1275 let code_len = scan_line(rest, &mut scratch).unwrap_or(rest.len());
1276 let value_end = rest[..code_len].trim_end().len();
1277 let lead = rest.len() - rest.trim_start().len();
1278 out[start] = format!(
1279 "{}{}{n}{}",
1280 &line[..eq],
1281 &rest[..lead.min(value_end)],
1282 &rest[value_end..]
1283 );
1284 return Ok(out.concat());
1285 }
1286 let new_line = format!("{key} = {n}{eol}");
1287 match (header, last_key_end) {
1288 (Some(_), Some(end)) | (Some(end), None) => {
1289 if !out[end].ends_with('\n') {
1290 out[end].push_str(eol);
1291 }
1292 out.insert(end + 1, new_line);
1293 }
1294 (None, _) => {
1295 if let Some(last) = out.last_mut()
1296 && !last.ends_with('\n')
1297 {
1298 last.push_str(eol);
1299 }
1300 if !out.is_empty() {
1301 out.push(eol.to_owned());
1302 }
1303 out.push(format!("[graph]{eol}"));
1304 out.push(new_line);
1305 }
1306 }
1307 Ok(out.concat())
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313 use tempfile::TempDir;
1314
1315 fn ids(v: &[&str]) -> Vec<String> {
1316 v.iter().map(|s| (*s).to_owned()).collect()
1317 }
1318
1319 const AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n\
1320 [[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
1321
1322 #[test]
1323 fn patch_keeps_comments_and_unrelated_keys() {
1324 let src = "# top comment\n[vars]\ncache = \"/x\" # keep\n\n[roles]\n# why\nimplementers = [\n \"a\", # first\n \"b\",\n] # tail\njudges = [\"a\"]\nfixer = \"a\"\n\n[graph]\ncandidates = 2\n";
1325 let out = patch_role(src, "implementers", &ids(&["b", "a"])).unwrap();
1326 assert_eq!(
1327 out,
1328 "# top comment\n[vars]\ncache = \"/x\" # keep\n\n[roles]\n# why\nimplementers = [\n \"b\",\n \"a\", # first\n] # tail\njudges = [\"a\"]\nfixer = \"a\"\n\n[graph]\ncandidates = 2\n"
1329 );
1330 let out = patch_role(&out, "judges", &[]).unwrap();
1331 assert!(!out.contains("judges"));
1332 assert!(out.contains("fixer = \"a\"") && out.contains("[graph]"));
1333 }
1334
1335 #[test]
1336 fn patch_does_not_read_a_role_table_out_of_a_multiline_string() {
1337 let src = "[vars]\nexample = \'\'\'\n[roles]\njudges = [\"a\"]\n\'\'\'\nother = \"\"\"\n[roles]\n\"\"\"\n";
1338 assert_eq!(patch_role(src, "judges", &[]).unwrap(), src);
1340 let out = patch_role(src, "judges", &ids(&["a"])).unwrap();
1341 assert!(out.starts_with(src), "{out}");
1342 assert!(out.ends_with("\n[roles]\njudges = [\"a\"]\n"), "{out}");
1343 }
1344
1345 #[test]
1346 fn duplicate_seats_keep_their_own_comments() {
1347 let src =
1348 "[roles]\njudges = [\n \"a\", # first seat\n \"a\", # second seat\n \"b\",\n]\n";
1349 let out = patch_role(src, "judges", &ids(&["b", "a", "a"])).unwrap();
1350 assert_eq!(
1351 out,
1352 "[roles]\njudges = [\n \"b\",\n \"a\", # first seat\n \"a\", # second seat\n]\n"
1353 );
1354 }
1355
1356 #[test]
1357 fn a_reset_keeps_the_comments_of_the_removed_key() {
1358 let out = patch_role(
1359 "[roles]\njudges = [\"a\"] # why a\nfixer = \"a\"\n",
1360 "judges",
1361 &[],
1362 )
1363 .unwrap();
1364 assert_eq!(out, "[roles]\n# why a\nfixer = \"a\"\n");
1365 let out = patch_role(
1366 "[roles]\njudges = [\n # lead\n \"a\", # why a\n]\n",
1367 "judges",
1368 &[],
1369 )
1370 .unwrap();
1371 assert_eq!(out, "[roles]\n# lead\n# why a\n");
1372 }
1373
1374 #[test]
1375 fn a_comment_follows_its_id_through_a_reorder() {
1376 let src =
1377 "[roles]\njudges = [ # head\n # lead\n \"a\", # why a\n \"b\", # why b\n] # tail\n";
1378 let out = patch_role(src, "judges", &ids(&["b", "c", "a"])).unwrap();
1379 assert_eq!(
1380 out,
1381 "[roles]\njudges = [\n # head\n # lead\n \"b\", # why b\n \"c\",\n \"a\", # why a\n] # tail\n"
1382 );
1383 }
1384
1385 #[test]
1386 fn patch_creates_the_table_and_inserts_into_it() {
1387 let out = patch_role("[vars]\nx = 1", "judges", &ids(&["a"])).unwrap();
1388 assert_eq!(out, "[vars]\nx = 1\n\n[roles]\njudges = [\"a\"]\n");
1389 let out = patch_role(
1390 "[roles]\nfixer = \"a\"\n\n[graph]\njudges = 3\n",
1391 "judges",
1392 &ids(&["a"]),
1393 )
1394 .unwrap();
1395 assert_eq!(
1396 out,
1397 "[roles]\nfixer = \"a\"\njudges = [\"a\"]\n\n[graph]\njudges = 3\n"
1398 );
1399 let out = patch_role("[graph]\njudges = 3\n", "judges", &[]).unwrap();
1401 assert_eq!(out, "[graph]\njudges = 3\n");
1402 }
1403
1404 #[test]
1405 fn fixer_is_a_string_for_one_and_an_array_for_a_chain() {
1406 let one = patch_role("", "fixer", &ids(&["a"])).unwrap();
1407 assert!(one.contains("fixer = \"a\""), "{one}");
1408 let two = patch_role("[roles]\nfixer = \"a\"\n", "fixer", &ids(&["a", "b"])).unwrap();
1409 assert!(two.contains("fixer = [\"a\", \"b\"]"), "{two}");
1410 let reset = patch_role(&two, "fixer", &[]).unwrap();
1411 assert!(!reset.contains("fixer ="), "{reset}");
1412 }
1413
1414 #[test]
1415 fn synthesizer_is_a_string_for_one_and_an_array_for_a_chain() {
1416 let one = patch_role("", "synthesizer", &ids(&["a"])).unwrap();
1417 assert!(one.contains("synthesizer = \"a\""), "{one}");
1418 let two = patch_role("", "synthesizer", &ids(&["a", "b"])).unwrap();
1419 assert!(two.contains("synthesizer = [\"a\", \"b\"]"), "{two}");
1420 }
1421
1422 const NEW_CHAIN_KEYS: [&str; 4] = ["chatter", "conductor", "language_judge", "dupes_judge"];
1423
1424 #[test]
1425 fn new_chain_roles_save_round_trip_and_reset() {
1426 for key in NEW_CHAIN_KEYS {
1427 let tmp = TempDir::new().unwrap();
1428 let repo = tmp.path().join("repo");
1429 std::fs::create_dir_all(&repo).unwrap();
1430 std::fs::write(repo.join("magi.toml"), AGENTS).unwrap();
1431 let machine = tmp.path().join("config.toml");
1432 for wanted in [ids(&["b"]), ids(&["b", "a"]), Vec::new()] {
1433 let before = view(&repo, Some(&machine));
1434 let after = save(
1435 &repo,
1436 Some(&machine),
1437 &before.revision,
1438 &BTreeMap::from([(key.to_owned(), wanted.clone())]),
1439 &BTreeMap::new(),
1440 )
1441 .unwrap();
1442 let role = after.roles.iter().find(|r| r.key == key).unwrap();
1443 assert_eq!(role.configured, wanted, "{key}");
1444 assert!(role.chain && role.count.is_none(), "{key}");
1445 let text = std::fs::read_to_string(&machine).unwrap();
1446 if wanted.is_empty() {
1447 assert!(!text.contains(&format!("{key} =")), "{text}");
1448 assert_eq!(role.source, "default");
1449 } else {
1450 assert!(text.contains(&format!("{key} = {}", value_text(key, &wanted))));
1451 assert_eq!(role.source, "machine");
1452 }
1453 let cfg = effective(&layer_paths(&repo, Some(&machine))).unwrap();
1454 assert_eq!(role_ids(&cfg, key), wanted, "{key}");
1455 }
1456 }
1457 }
1458
1459 #[test]
1460 fn new_chain_roles_refuse_repo_overrides_in_words() {
1461 for key in NEW_CHAIN_KEYS {
1462 let tmp = TempDir::new().unwrap();
1463 let repo = tmp.path().join("repo");
1464 std::fs::create_dir_all(&repo).unwrap();
1465 std::fs::write(
1466 repo.join("magi.toml"),
1467 format!("{AGENTS}\n[roles]\n{key} = \"a\"\n"),
1468 )
1469 .unwrap();
1470 let machine = tmp.path().join("config.toml");
1471 let original = "# machine settings\n[vars]\nx = 1\n";
1472 std::fs::write(&machine, original).unwrap();
1473 let before = view(&repo, Some(&machine));
1474 let role = before.roles.iter().find(|r| r.key == key).unwrap();
1475 assert_eq!(role.source, "repo");
1476 assert!(!role.editable);
1477 for wanted in [ids(&["b"]), Vec::new()] {
1478 let err = save(
1479 &repo,
1480 Some(&machine),
1481 &before.revision,
1482 &BTreeMap::from([(key.to_owned(), wanted)]),
1483 &BTreeMap::new(),
1484 )
1485 .unwrap_err();
1486 assert!(
1487 matches!(&err, SaveError::Refused(m)
1488 if m.contains(&format!("This repo overrides roles.{key}"))
1489 && m.contains("edit it there")),
1490 "{err:?}"
1491 );
1492 assert_eq!(std::fs::read_to_string(&machine).unwrap(), original);
1493 }
1494 }
1495 }
1496
1497 #[test]
1498 fn new_chain_roles_show_defaults_inheritance_and_resolution_errors() {
1499 let tmp = TempDir::new().unwrap();
1500 let repo = tmp.path().join("repo");
1501 std::fs::create_dir_all(&repo).unwrap();
1502 let config = repo.join("magi.toml");
1503 std::fs::write(&config, AGENTS).unwrap();
1504 let defaults = view(&repo, None);
1505 for key in ["chatter", "conductor", "dupes_judge"] {
1506 let role = defaults.roles.iter().find(|r| r.key == key).unwrap();
1507 assert_eq!(role.seats, ids(&["a"]), "{key}");
1508 }
1509 let language = defaults
1510 .roles
1511 .iter()
1512 .find(|r| r.key == "language_judge")
1513 .unwrap();
1514 assert!(language.seats.is_empty() && language.seats_error.is_none());
1515 assert_eq!(language.fallback, Some("built-in heuristics"));
1516 std::fs::write(&config, format!("{AGENTS}\n[roles]\nchatter = \"b\"\n")).unwrap();
1517 let inherited = view(&repo, None);
1518 let dupes = inherited
1519 .roles
1520 .iter()
1521 .find(|r| r.key == "dupes_judge")
1522 .unwrap();
1523 assert_eq!(dupes.seats, ids(&["b"]));
1524 assert_eq!(dupes.fallback, Some("chatter"));
1525 std::fs::write(
1527 &config,
1528 format!("{AGENTS}\n[roles]\nchatter = \"b\"\ndupes_judge = []\n"),
1529 )
1530 .unwrap();
1531 let explicit = view(&repo, None);
1532 let dupes = explicit
1533 .roles
1534 .iter()
1535 .find(|r| r.key == "dupes_judge")
1536 .unwrap();
1537 assert_eq!(dupes.seats, ids(&["a"]));
1538 assert_eq!(dupes.fallback, None);
1539 for key in NEW_CHAIN_KEYS {
1540 for (choice, expected) in [
1541 ("[\"missing\", \"b\"]", ids(&["b"])),
1542 ("\"missing\"", Vec::new()),
1543 ] {
1544 std::fs::write(&config, format!("{AGENTS}\n[roles]\n{key} = {choice}\n")).unwrap();
1545 let v = view(&repo, None);
1546 let role = v.roles.iter().find(|r| r.key == key).unwrap();
1547 assert_eq!(role.seats, expected, "{key}");
1548 assert_eq!(role.skipped, ids(&["missing"]), "{key}");
1549 assert_eq!(role.seats_error.is_some(), expected.is_empty(), "{key}");
1550 }
1551 }
1552 }
1553
1554 #[test]
1555 fn patch_refuses_a_templated_value() {
1556 let src = "[roles]\njudges = [\"{{ env.X | default(value='a') }}\"]\n";
1557 assert!(patch_role(src, "judges", &ids(&["a"])).is_err());
1558 }
1559
1560 #[test]
1561 fn save_writes_machine_file_only_and_keeps_comments() {
1562 let tmp = TempDir::new().unwrap();
1563 let repo = tmp.path().join("repo");
1564 std::fs::create_dir_all(&repo).unwrap();
1565 let repo_toml = format!("{AGENTS}\n[graph]\ncandidates = 1\n");
1566 std::fs::write(repo.join("magi.toml"), &repo_toml).unwrap();
1567 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
1568 std::fs::create_dir_all(machine.parent().unwrap()).unwrap();
1569 std::fs::write(
1570 &machine,
1571 "# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n",
1572 )
1573 .unwrap();
1574
1575 let v = view(&repo, Some(&machine));
1576 assert!(v.error.is_none(), "{:?}", v.error);
1577 let r = save(
1578 &repo,
1579 Some(&machine),
1580 &v.revision,
1581 &BTreeMap::from([("judges".to_owned(), ids(&["b", "a"]))]),
1582 &BTreeMap::new(),
1583 )
1584 .unwrap();
1585 let text = std::fs::read_to_string(&machine).unwrap();
1586 assert_eq!(
1587 text,
1588 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\n\n[vars]\nx = 1\n"
1589 );
1590 assert_eq!(
1591 std::fs::read_to_string(repo.join("magi.toml")).unwrap(),
1592 repo_toml
1593 );
1594 let judges = r.roles.iter().find(|x| x.key == "judges").unwrap();
1595 assert_eq!(judges.source, "machine");
1596 assert_eq!(judges.configured, ids(&["b", "a"]));
1597 let leftovers = std::fs::read_dir(machine.parent().unwrap())
1599 .unwrap()
1600 .count();
1601 assert_eq!(leftovers, 1);
1602 }
1603
1604 #[test]
1605 fn save_refuses_unknown_ids_and_leaves_the_file_alone() {
1606 let tmp = TempDir::new().unwrap();
1607 let repo = tmp.path().join("repo");
1608 std::fs::create_dir_all(&repo).unwrap();
1609 std::fs::write(repo.join("magi.toml"), AGENTS).unwrap();
1610 let machine = tmp.path().join("m").join("magi").join("config.toml");
1611 let v = view(&repo, Some(&machine));
1612 let err = save(
1613 &repo,
1614 Some(&machine),
1615 &v.revision,
1616 &BTreeMap::from([("judges".to_owned(), ids(&["nope"]))]),
1617 &BTreeMap::new(),
1618 )
1619 .unwrap_err();
1620 assert!(matches!(err, SaveError::Refused(m) if m.contains("nope")));
1621 assert!(!machine.exists());
1622 }
1623
1624 #[test]
1625 fn save_refuses_a_key_the_repo_owns_and_a_stale_revision() {
1626 let tmp = TempDir::new().unwrap();
1627 let repo = tmp.path().join("repo");
1628 std::fs::create_dir_all(&repo).unwrap();
1629 std::fs::write(
1630 repo.join("magi.toml"),
1631 format!("{AGENTS}\n[roles]\njudges = [\"a\"]\n"),
1632 )
1633 .unwrap();
1634 let machine = tmp.path().join("m").join("magi").join("config.toml");
1635 let v = view(&repo, Some(&machine));
1636 let j = v.roles.iter().find(|r| r.key == "judges").unwrap();
1637 assert!(!j.editable && j.source == "repo");
1638 let want = BTreeMap::from([("judges".to_owned(), ids(&["b"]))]);
1639 assert!(matches!(
1640 save(&repo, Some(&machine), &v.revision, &want, &BTreeMap::new()),
1641 Err(SaveError::Refused(_))
1642 ));
1643 let want = BTreeMap::from([("reviewers".to_owned(), ids(&["b"]))]);
1644 assert!(matches!(
1645 save(&repo, Some(&machine), "stale", &want, &BTreeMap::new()),
1646 Err(SaveError::Conflict(_))
1647 ));
1648 }
1649
1650 #[test]
1651 fn a_config_that_does_not_parse_is_an_error_not_an_empty_list() {
1652 let tmp = TempDir::new().unwrap();
1653 let repo = tmp.path().join("repo");
1654 std::fs::create_dir_all(&repo).unwrap();
1655 std::fs::write(repo.join("magi.toml"), "[roles\nbroken").unwrap();
1656 let v = view(&repo, None);
1657 let e = v.error.expect("an error");
1658 assert!(
1659 e.path.is_some_and(|p| p.ends_with("magi.toml")),
1660 "{}",
1661 e.message
1662 );
1663 assert!(v.roles.is_empty() && v.agents.is_empty());
1664 }
1665
1666 #[test]
1667 fn advisors_unset_falls_back_to_judges() {
1668 let tmp = TempDir::new().unwrap();
1669 let repo = tmp.path().join("repo");
1670 std::fs::create_dir_all(&repo).unwrap();
1671 std::fs::write(
1672 repo.join("magi.toml"),
1673 format!("{AGENTS}\n[roles]\njudges = [\"b\"]\n"),
1674 )
1675 .unwrap();
1676 let v = view(&repo, None);
1677 let a = v.roles.iter().find(|r| r.key == "advisors").unwrap();
1678 assert_eq!(a.fallback, Some("judges"));
1679 assert_eq!(a.source, "default");
1680 assert!(
1681 a.seats.iter().all(|s| s == "b") && !a.seats.is_empty(),
1682 "{:?}",
1683 a.seats
1684 );
1685 }
1686
1687 fn two_repos(tmp: &TempDir, other_toml: &str) -> (PathBuf, PathBuf, PathBuf) {
1690 let root = tmp.path().join("ghq");
1691 let repo = root.join("h").join("o").join("cur");
1692 let other = root.join("h").join("o").join("other");
1693 for d in [&repo, &other] {
1694 std::fs::create_dir_all(d.join(".git")).unwrap();
1695 }
1696 std::fs::write(
1697 repo.join("magi.toml"),
1698 format!(
1699 "{AGENTS}\n[repos]\nroots = [{}]\n",
1700 quote(&root.to_string_lossy())
1701 ),
1702 )
1703 .unwrap();
1704 std::fs::write(other.join("magi.toml"), other_toml).unwrap();
1705 let machine = tmp.path().join("m").join("magi").join("config.toml");
1706 (repo, other, machine)
1707 }
1708
1709 #[test]
1710 fn saving_judges_is_refused_when_another_repo_declares_them() {
1711 let tmp = TempDir::new().unwrap();
1712 let (repo, other, machine) =
1713 two_repos(&tmp, &format!("{AGENTS}\n[roles]\njudges = [\"a\"]\n"));
1714 let v = view(&repo, Some(&machine));
1715 let j = v.roles.iter().find(|r| r.key == "judges").unwrap();
1716 assert!(!j.editable, "{:?}", j.locked_reason);
1717 let err = save(
1718 &repo,
1719 Some(&machine),
1720 &v.revision,
1721 &BTreeMap::from([("judges".to_owned(), ids(&["b"]))]),
1722 &BTreeMap::new(),
1723 )
1724 .unwrap_err();
1725 assert!(
1726 matches!(&err, SaveError::Refused(m) if m.contains("o/other")),
1727 "{err:?}"
1728 );
1729 assert!(!machine.exists());
1730 assert!(Config::load_layers(&repo_layers(&other)).is_ok());
1731 save(
1733 &repo,
1734 Some(&machine),
1735 &v.revision,
1736 &BTreeMap::from([("reviewers".to_owned(), ids(&["b"]))]),
1737 &BTreeMap::new(),
1738 )
1739 .unwrap();
1740 let mut layers = vec![machine.clone()];
1741 layers.extend(repo_layers(&other));
1742 assert!(Config::load_layers(&layers).is_ok());
1743 }
1744
1745 #[test]
1746 fn saving_a_role_writes_no_agents_and_detection_stays_dynamic() {
1747 let tmp = TempDir::new().unwrap();
1748 let repo = tmp.path().join("repo");
1749 std::fs::create_dir_all(&repo).unwrap();
1750 std::fs::write(repo.join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
1751 let machine = tmp.path().join("m").join("magi").join("config.toml");
1752 let v = view(&repo, Some(&machine));
1753 let Some(first) = Config::autodetected().agents.first().map(|a| a.id.clone()) else {
1754 return; };
1756 save(
1757 &repo,
1758 Some(&machine),
1759 &v.revision,
1760 &BTreeMap::from([("judges".to_owned(), ids(&[&first]))]),
1761 &BTreeMap::new(),
1762 )
1763 .unwrap();
1764 let text = std::fs::read_to_string(&machine).unwrap();
1765 assert!(!text.contains("[[agents]]"), "{text}");
1766 let after = view(&repo, Some(&machine));
1767 assert!(after.agents.iter().all(|a| a.source == "detected"));
1768 assert_eq!(
1769 ids_of(
1770 &Config::load_layers(&layer_paths(&repo, Some(&machine)))
1771 .unwrap()
1772 .agents
1773 ),
1774 ids_of(&Config::autodetected().agents)
1775 );
1776 }
1777
1778 #[test]
1779 fn an_explicit_empty_agents_list_is_kept() {
1780 let tmp = TempDir::new().unwrap();
1781 let f = tmp.path().join("magi.toml");
1782 std::fs::write(&f, "agents = []\n").unwrap();
1783 assert!(Config::load_layers(&[f]).unwrap().agents.is_empty());
1784 }
1785
1786 #[test]
1787 fn the_repo_lock_says_the_repo_overrides_the_key() {
1788 let tmp = TempDir::new().unwrap();
1789 let repo = tmp.path().join("repo");
1790 std::fs::create_dir_all(&repo).unwrap();
1791 std::fs::write(
1792 repo.join("magi.toml"),
1793 format!("{AGENTS}\n[roles]\njudges = [\"a\"]\n"),
1794 )
1795 .unwrap();
1796 let machine = tmp.path().join("m").join("magi").join("config.toml");
1797 let v = view(&repo, Some(&machine));
1798 let j = v.roles.iter().find(|r| r.key == "judges").unwrap();
1799 let why = j.locked_reason.as_deref().unwrap();
1800 assert!(
1801 why.starts_with("This repo overrides roles.judges in "),
1802 "{why}"
1803 );
1804 }
1805
1806 #[test]
1807 fn patch_count_replaces_in_place_and_keeps_the_rest() {
1808 let src =
1809 "# top\n[graph]\n# seats\njudges = 3 # three\nreviewers = 2\n\n[vars]\nx = 1\n";
1810 let out = patch_count(src, "judges", "judges", 5).unwrap();
1811 assert_eq!(
1812 out,
1813 "# top\n[graph]\n# seats\njudges = 5 # three\nreviewers = 2\n\n[vars]\nx = 1\n"
1814 );
1815 }
1816
1817 #[test]
1818 fn patch_count_keeps_the_alias_spelling() {
1819 let src = "[graph]\ncandidates = 2\n";
1820 let out = patch_count(src, "candidates", "implementers", 4).unwrap();
1821 assert_eq!(out, "[graph]\ncandidates = 4\n");
1822 assert!(!out.contains("implementers"));
1823 }
1824
1825 #[test]
1826 fn patch_count_refuses_both_spellings() {
1827 let src = "[graph]\ncandidates = 2\nimplementers = 3\n";
1828 assert!(patch_count(src, "implementers", "implementers", 4).is_err());
1829 }
1830
1831 #[test]
1832 fn patch_count_adds_the_key_after_the_last_graph_key() {
1833 let src = "[graph]\nreviewers = 2 # r\n\n[roles]\njudges = [\"a\"]\n";
1834 let out = patch_count(src, "judges", "judges", 2).unwrap();
1835 assert_eq!(
1836 out,
1837 "[graph]\nreviewers = 2 # r\njudges = 2\n\n[roles]\njudges = [\"a\"]\n"
1838 );
1839 let empty = patch_count("[graph]\n", "advisors", "advisors", 0).unwrap();
1840 assert_eq!(empty, "[graph]\nadvisors = 0\n");
1841 }
1842
1843 #[test]
1844 fn patch_count_creates_a_missing_graph_table_and_keeps_crlf() {
1845 let out = patch_count("# c\n[roles]\njudges = [\"a\"]", "judges", "judges", 2).unwrap();
1846 assert_eq!(
1847 out,
1848 "# c\n[roles]\njudges = [\"a\"]\n\n[graph]\njudges = 2\n"
1849 );
1850 assert_eq!(
1851 patch_count("", "judges", "judges", 2).unwrap(),
1852 "[graph]\njudges = 2\n"
1853 );
1854 let crlf = patch_count("[graph]\r\nreviewers = 2\r\n", "judges", "judges", 2).unwrap();
1855 assert_eq!(crlf, "[graph]\r\nreviewers = 2\r\njudges = 2\r\n");
1856 }
1857
1858 #[test]
1859 fn patch_count_refuses_what_it_cannot_rewrite_in_place() {
1860 assert!(patch_count("[graph]\njudges = {{ vars.n }}\n", "judges", "judges", 2).is_err());
1861 assert!(patch_count("graph = { judges = 2 }\n", "judges", "judges", 3).is_err());
1862 assert!(patch_count("graph.judges = 2\n", "judges", "judges", 3).is_err());
1863 assert!(patch_count("[graph]\njudges = \\\n", "judges", "judges", 3).is_ok());
1864 }
1865
1866 #[test]
1867 fn save_writes_a_count_through_the_alias_and_refuses_bad_ones() {
1868 let tmp = TempDir::new().unwrap();
1869 let repo = tmp.path().join("repo");
1870 std::fs::create_dir_all(&repo).unwrap();
1871 std::fs::write(
1872 repo.join("magi.toml"),
1873 "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n",
1874 )
1875 .unwrap();
1876 let machine = tmp.path().join("m").join("magi").join("config.toml");
1877 std::fs::create_dir_all(machine.parent().unwrap()).unwrap();
1878 std::fs::write(&machine, "[graph]\ncandidates = 1 # old\n").unwrap();
1879 let v = view(&repo, Some(&machine));
1880 let c = |n: serde_json::Value| BTreeMap::from([("implementers".to_owned(), n)]);
1881 for bad in [
1882 serde_json::json!(0),
1883 serde_json::json!(2.5),
1884 serde_json::json!("x"),
1885 serde_json::json!(-1),
1886 ] {
1887 let err = save(
1888 &repo,
1889 Some(&machine),
1890 &v.revision,
1891 &BTreeMap::new(),
1892 &c(bad),
1893 )
1894 .unwrap_err();
1895 assert!(matches!(err, SaveError::Refused(_)), "{err:?}");
1896 }
1897 save(
1898 &repo,
1899 Some(&machine),
1900 &v.revision,
1901 &BTreeMap::new(),
1902 &c(serde_json::json!(2)),
1903 )
1904 .unwrap();
1905 assert_eq!(
1906 std::fs::read_to_string(&machine).unwrap(),
1907 "[graph]\ncandidates = 2 # old\n"
1908 );
1909 let after = view(&repo, Some(&machine));
1910 let count = after.roles[0].count.as_ref().unwrap();
1911 assert_eq!(
1912 (count.value, count.file_key, count.backups),
1913 (2, "candidates", Some(0))
1914 );
1915 save(
1917 &repo,
1918 Some(&machine),
1919 &after.revision,
1920 &BTreeMap::new(),
1921 &BTreeMap::from([("advisors".to_owned(), serde_json::json!(0))]),
1922 )
1923 .unwrap();
1924 }
1925}