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; 6] = [
41 "implementers",
42 "judges",
43 "reviewers",
44 "advisors",
45 "synthesizer",
46 "fixer",
47];
48
49fn is_chain_role(key: &str) -> bool {
51 matches!(key, "synthesizer" | "fixer")
52}
53
54static SAVE_LOCK: Mutex<()> = Mutex::new(());
56
57#[derive(Debug, Serialize)]
59pub(crate) struct SettingsView {
60 pub(crate) repo: String,
62 pub(crate) machine: MachineView,
63 pub(crate) revision: String,
65 pub(crate) error: Option<ConfigError>,
68 pub(crate) roles: Vec<RoleView>,
69 pub(crate) agents: Vec<AgentView>,
70}
71
72#[derive(Debug, Serialize)]
73pub(crate) struct MachineView {
74 pub(crate) path: Option<String>,
76 pub(crate) exists: bool,
77 pub(crate) unavailable: Option<String>,
79}
80
81#[derive(Debug, Serialize)]
82pub(crate) struct ConfigError {
83 pub(crate) message: String,
84 pub(crate) path: Option<String>,
86}
87
88#[derive(Debug, Serialize)]
89pub(crate) struct RoleView {
90 pub(crate) key: &'static str,
91 pub(crate) configured: Vec<String>,
93 pub(crate) source: &'static str,
95 pub(crate) source_path: Option<String>,
96 pub(crate) editable: bool,
98 pub(crate) locked_reason: Option<String>,
99 pub(crate) fallback: Option<&'static str>,
101 pub(crate) seats: Vec<String>,
103 pub(crate) seats_error: Option<String>,
104 pub(crate) skipped: Vec<String>,
106}
107
108#[derive(Debug, Serialize)]
109pub(crate) struct AgentView {
110 pub(crate) id: String,
111 pub(crate) kind: String,
112 pub(crate) model: Option<String>,
113 pub(crate) source: &'static str,
115 pub(crate) source_path: Option<String>,
116}
117
118#[derive(Debug)]
120pub(crate) enum SaveError {
121 Conflict(String),
123 Refused(String),
125 Internal(String),
127}
128
129fn fingerprint(bytes: &[u8]) -> String {
131 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
132 for b in bytes {
133 hash ^= u64::from(*b);
134 hash = hash.wrapping_mul(0x0100_0000_01b3);
135 }
136 format!("{hash:016x}")
137}
138
139fn repo_layers(repo: &Path) -> Vec<PathBuf> {
140 Config::repo_layers(repo).unwrap_or_else(|e| {
141 tracing::warn!("could not read the repository's config layers: {e:#}");
142 Vec::new()
143 })
144}
145
146fn layer_paths_strict(repo: &Path, machine: Option<&Path>) -> anyhow::Result<Vec<PathBuf>> {
150 let mut paths: Vec<PathBuf> = machine
151 .filter(|m| m.is_file())
152 .map(Path::to_path_buf)
153 .into_iter()
154 .collect();
155 paths.extend(Config::repo_layers(repo)?);
156 Ok(paths)
157}
158
159fn layer_paths(repo: &Path, machine: Option<&Path>) -> Vec<PathBuf> {
163 let mut paths: Vec<PathBuf> = machine
164 .filter(|m| m.is_file())
165 .map(Path::to_path_buf)
166 .into_iter()
167 .collect();
168 paths.extend(repo_layers(repo));
169 paths
170}
171
172fn effective(paths: &[PathBuf]) -> anyhow::Result<Config> {
173 if paths.is_empty() {
174 return Ok(Config::autodetected());
175 }
176 Config::load_layers(paths)
177}
178
179fn declares(table: &toml::Table, key: &str) -> bool {
180 table
181 .get("roles")
182 .and_then(toml::Value::as_table)
183 .is_some_and(|r| r.contains_key(key))
184}
185
186fn choice_ids(choice: Option<&AgentChoice>) -> Vec<String> {
187 choice
188 .map(|c| c.ids().into_iter().map(str::to_owned).collect())
189 .unwrap_or_default()
190}
191
192fn role_ids(cfg: &Config, key: &str) -> Vec<String> {
193 match key {
194 "implementers" => cfg.roles.implementers.clone(),
195 "judges" => cfg.roles.judges.clone(),
196 "reviewers" => cfg.roles.reviewers.clone(),
197 "advisors" => cfg.roles.advisors.clone(),
198 "fixer" => choice_ids(cfg.roles.fixer.as_ref()),
199 _ => choice_ids(cfg.roles.synthesizer.as_ref()),
200 }
201}
202
203fn ids_of(specs: &[AgentSpec]) -> Vec<String> {
204 specs.iter().map(|s| s.id.clone()).collect()
205}
206
207pub(crate) fn view(repo: &Path, machine: Option<&Path>) -> SettingsView {
210 let bytes = machine
211 .and_then(|m| std::fs::read(m).ok())
212 .unwrap_or_default();
213 let mut out = SettingsView {
214 repo: repo.display().to_string(),
215 machine: MachineView {
216 path: machine.map(|m| m.display().to_string()),
217 exists: machine.is_some_and(Path::is_file),
218 unavailable: machine.is_none().then(|| {
219 "This machine has no machine-config location (the config directory is \
220 unset or empty), so nothing can be saved from here."
221 .to_owned()
222 }),
223 },
224 revision: fingerprint(&bytes),
225 error: None,
226 roles: Vec::new(),
227 agents: Vec::new(),
228 };
229 let paths = match layer_paths_strict(repo, machine) {
230 Ok(p) => p,
231 Err(e) => {
232 out.error = Some(ConfigError {
233 message: format!("{e:#}"),
234 path: None,
235 });
236 return out;
237 }
238 };
239 let cfg = match effective(&paths) {
240 Ok(cfg) => cfg,
241 Err(e) => {
242 let failing = Config::layer_tables(&paths)
243 .into_iter()
244 .find(|(_, t)| t.is_err())
245 .map(|(p, _)| p.display().to_string());
246 out.error = Some(ConfigError {
247 message: format!("{e:#}"),
248 path: failing,
249 });
250 return out;
251 }
252 };
253 let tables: Vec<(PathBuf, toml::Table)> = Config::layer_tables(&paths)
254 .into_iter()
255 .filter_map(|(p, t)| t.ok().map(|t| (p, t)))
256 .collect();
257 let is_machine = |p: &Path| machine.is_some_and(|m| m == p);
258
259 let mut other_declares: BTreeMap<&str, String> = BTreeMap::new();
260 for other in other_checkouts(repo, &cfg.repos.roots) {
261 let theirs = Config::layer_tables(&repo_layers(&other.path));
262 for key in ROLE_KEYS {
263 if theirs
264 .iter()
265 .any(|(_, t)| t.as_ref().is_ok_and(|t| declares(t, key)))
266 {
267 other_declares
268 .entry(key)
269 .or_insert_with(|| other.name.clone());
270 }
271 }
272 }
273 let resolved = cfg.resolve_roles();
274 let advisors = cfg.advisors();
275 for key in ROLE_KEYS {
276 let configured = role_ids(&cfg, key);
277 let owner = tables.iter().rev().find(|(_, t)| declares(t, key));
278 let (source, source_path) = match owner {
279 Some((p, _)) if is_machine(p) => ("machine", Some(p.display().to_string())),
280 Some((p, _)) => ("repo", Some(p.display().to_string())),
281 None => ("default", None),
282 };
283 let repo_owner = tables
284 .iter()
285 .find(|(p, t)| !is_machine(p) && declares(t, key));
286 let (editable, locked_reason) = if let Some((p, _)) = repo_owner {
287 (
288 false,
289 Some(format!(
290 "This repo overrides roles.{key} in {} - edit it there.",
291 p.display()
292 )),
293 )
294 } else if let Some(name) = other_declares.get(key) {
295 (
296 false,
297 Some(format!(
298 "`{name}` declares roles.{key} in its own config, so a machine setting \
299 would stop it from loading. Edit it there."
300 )),
301 )
302 } else if machine.is_none() {
303 (false, out.machine.unavailable.clone())
304 } else {
305 (true, None)
306 };
307 let mut view = RoleView {
308 key,
309 configured,
310 source,
311 source_path,
312 editable,
313 locked_reason,
314 fallback: None,
315 seats: Vec::new(),
316 seats_error: None,
317 skipped: Vec::new(),
318 };
319 let seats = match key {
320 "implementers" => resolved
321 .as_ref()
322 .map(|r| ids_of(&r.implementers))
323 .map_err(|e| anyhow::anyhow!("{e:#}")),
324 "judges" => resolved
325 .as_ref()
326 .map(|r| ids_of(&r.judges))
327 .map_err(|e| anyhow::anyhow!("{e:#}")),
328 "reviewers" => resolved
329 .as_ref()
330 .map(|r| ids_of(&r.reviewers))
331 .map_err(|e| anyhow::anyhow!("{e:#}")),
332 "advisors" => {
333 if view.configured.is_empty() {
334 view.fallback = Some("judges");
335 }
336 advisors
337 .as_ref()
338 .map(|a| ids_of(a))
339 .map_err(|e| anyhow::anyhow!("{e:#}"))
340 }
341 "fixer" if cfg.roles.fixer.is_none() => {
343 view.fallback = Some("winner's implementer");
344 Ok(Vec::new())
345 }
346 "fixer" => crate::agent::pick_chain(
347 &cfg.agents,
348 cfg.roles.fixer.as_ref(),
349 &crate::agent::installed,
350 "fixer",
351 )
352 .map(|chain| {
353 let ids = ids_of(&chain);
354 view.skipped = view
355 .configured
356 .iter()
357 .filter(|id| !ids.contains(id))
358 .cloned()
359 .collect();
360 ids
361 }),
362 _ => crate::agent::pick_chain(
363 &cfg.agents,
364 cfg.roles.synthesizer.as_ref(),
365 &crate::agent::installed,
366 "synthesizer",
367 )
368 .map(|chain| {
369 let ids = ids_of(&chain);
370 view.skipped = view
371 .configured
372 .iter()
373 .filter(|id| !ids.contains(id))
374 .cloned()
375 .collect();
376 ids
377 }),
378 };
379 match seats {
380 Ok(ids) => view.seats = ids,
381 Err(e) => view.seats_error = Some(format!("{e:#}")),
382 }
383 out.roles.push(view);
384 }
385
386 out.agents = cfg
387 .agents
388 .iter()
389 .map(|a| {
390 let owner = tables.iter().rev().find(|(_, t)| {
391 t.get("agents")
392 .and_then(toml::Value::as_array)
393 .is_some_and(|list| {
394 list.iter()
395 .any(|x| x.get("id").and_then(toml::Value::as_str) == Some(&a.id))
396 })
397 });
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(p.display().to_string())),
401 None => ("detected", None),
402 };
403 AgentView {
404 id: a.id.clone(),
405 kind: toml::Value::try_from(a.kind)
406 .ok()
407 .and_then(|v| v.as_str().map(str::to_owned))
408 .unwrap_or_default(),
409 model: a.model.clone(),
410 source,
411 source_path,
412 }
413 })
414 .collect();
415 out
416}
417
418pub(crate) fn save(
423 repo: &Path,
424 machine: Option<&Path>,
425 revision: &str,
426 roles: &BTreeMap<String, Vec<String>>,
427) -> Result<SettingsView, SaveError> {
428 let _guard = SAVE_LOCK.lock().unwrap_or_else(|p| p.into_inner());
429 let Some(machine) = machine else {
430 return Err(SaveError::Refused(
431 "This machine has no machine-config location, so there is nowhere to save to."
432 .to_owned(),
433 ));
434 };
435 let current = view(repo, Some(machine));
436 if let Some(err) = ¤t.error {
437 return Err(SaveError::Refused(format!(
438 "The current config does not load, so it cannot be edited safely: {}",
439 err.message
440 )));
441 }
442 if current.revision != revision {
443 return Err(SaveError::Conflict(
444 "The machine config changed since this screen loaded it. Reload and apply \
445 the change again."
446 .to_owned(),
447 ));
448 }
449 if roles.is_empty() {
450 return Err(SaveError::Refused("Nothing to change.".to_owned()));
451 }
452 let known: Vec<&str> = current.agents.iter().map(|a| a.id.as_str()).collect();
453 let mut edits: Vec<(&'static str, Vec<String>)> = Vec::new();
454 for (key, ids) in roles {
455 let Some(role) = current.roles.iter().find(|r| r.key == key) else {
456 return Err(SaveError::Refused(format!(
457 "`{key}` is not a role this screen edits."
458 )));
459 };
460 if !role.editable {
461 return Err(SaveError::Refused(
462 role.locked_reason
463 .clone()
464 .unwrap_or_else(|| format!("`{key}` cannot be edited here.")),
465 ));
466 }
467 let ids: Vec<String> = ids.iter().map(|i| i.trim().to_owned()).collect();
468 if let Some(bad) = ids
469 .iter()
470 .find(|i| i.is_empty() || !known.contains(&i.as_str()))
471 {
472 return Err(SaveError::Refused(format!(
473 "`{bad}` is not a defined agent. Defined: {}.",
474 known.join(", ")
475 )));
476 }
477 edits.push((role.key, ids));
478 }
479
480 let original = match std::fs::read_to_string(machine) {
481 Ok(text) => text,
482 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
483 Err(e) => {
484 return Err(SaveError::Internal(format!(
485 "reading {}: {e}",
486 machine.display()
487 )));
488 }
489 };
490 let mut text = original.clone();
491 for (key, ids) in &edits {
492 text = patch_role(&text, key, ids).map_err(SaveError::Refused)?;
493 }
494 let dir = machine
495 .parent()
496 .ok_or_else(|| SaveError::Internal("the machine config has no parent directory".into()))?;
497 std::fs::create_dir_all(dir)
498 .map_err(|e| SaveError::Internal(format!("creating {}: {e}", dir.display())))?;
499 let nonce = std::time::SystemTime::now()
500 .duration_since(std::time::UNIX_EPOCH)
501 .map_or(0, |d| d.as_nanos());
502 let tmp = dir.join(format!(".config.toml.{}.{nonce}.tmp", std::process::id()));
503 let cleanup = |e: SaveError| {
504 let _ = std::fs::remove_file(&tmp);
505 e
506 };
507 write_synced(&tmp, &text).map_err(|e| {
508 cleanup(SaveError::Internal(format!(
509 "writing {}: {e}",
510 tmp.display()
511 )))
512 })?;
513
514 let mut layers = vec![tmp.clone()];
517 match Config::repo_layers(repo) {
518 Ok(l) => layers.extend(l),
519 Err(e) => {
520 return Err(cleanup(SaveError::Refused(format!(
521 "The repository's config cannot be read, so nothing was saved: {e:#}"
522 ))));
523 }
524 }
525 let loaded = Config::load_layers(&layers).map_err(|e| {
526 cleanup(SaveError::Refused(format!(
527 "The change would leave the config unloadable, so nothing was saved: {e:#}"
528 )))
529 })?;
530 for (key, ids) in &edits {
531 let got = role_ids(&loaded, key);
532 if &got != ids {
533 return Err(cleanup(SaveError::Refused(format!(
534 "The change would not take effect as asked: `{key}` would resolve to [{}] \
535 instead of [{}] (an include or a template in the machine file overrides \
536 it). Nothing was saved.",
537 got.join(", "),
538 ids.join(", ")
539 ))));
540 }
541 }
542 other_repos_still_load(repo, machine, &tmp, &loaded.repos.roots).map_err(cleanup)?;
543 std::fs::rename(&tmp, machine).map_err(|e| {
544 cleanup(SaveError::Internal(format!(
545 "replacing {}: {e}",
546 machine.display()
547 )))
548 })?;
549 Ok(view(repo, Some(machine)))
550}
551
552fn other_checkouts(repo: &Path, roots: &[PathBuf]) -> Vec<crate::repos::Repo> {
555 let here = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
556 crate::repos::scan(roots)
557 .into_iter()
558 .filter(|r| r.path != here && !repo_layers(&r.path).is_empty())
559 .collect()
560}
561
562fn other_repos_still_load(
568 repo: &Path,
569 machine: &Path,
570 proposal: &Path,
571 roots: &[PathBuf],
572) -> Result<(), SaveError> {
573 for other in other_checkouts(repo, roots) {
574 let layers = repo_layers(&other.path);
575 let mut old = layer_paths(&other.path, Some(machine));
576 if old.is_empty() {
577 old = layers.clone();
578 }
579 if Config::load_layers(&old).is_err() {
580 continue;
581 }
582 let mut new = vec![proposal.to_path_buf()];
583 new.extend(layers);
584 if let Err(e) = Config::load_layers(&new) {
585 return Err(SaveError::Refused(format!(
586 "Nothing was saved: this machine setting would stop `{}` from loading, \
587 because that repository declares the same setting in its own config \
588 ({e:#}). Edit it there, or remove it from that repository first.",
589 other.name
590 )));
591 }
592 }
593 Ok(())
594}
595
596fn write_synced(path: &Path, text: &str) -> std::io::Result<()> {
597 use std::io::Write;
598 let mut f = std::fs::File::create(path)?;
599 f.write_all(text.as_bytes())?;
600 f.sync_all()
601}
602
603fn quote(s: &str) -> String {
604 toml::Value::String(s.to_owned()).to_string()
605}
606
607fn value_text(key: &str, ids: &[String]) -> String {
610 if is_chain_role(key) && ids.len() == 1 {
611 return quote(&ids[0]);
612 }
613 let items: Vec<String> = ids.iter().map(|i| quote(i)).collect();
614 format!("[{}]", items.join(", "))
615}
616
617#[derive(Default)]
620struct Scan {
621 depth: i32,
622 multi: Option<&'static str>,
623}
624
625impl Scan {
626 fn open(&self) -> bool {
627 self.depth > 0 || self.multi.is_some()
628 }
629}
630
631fn scan_line(line: &str, st: &mut Scan) -> Option<usize> {
635 let b = line.as_bytes();
636 let mut quote: Option<u8> = None;
637 let mut escaped = false;
638 let mut i = 0;
639 while i < b.len() {
640 if let Some(delim) = st.multi {
641 if b[i..].starts_with(delim.as_bytes()) {
642 st.multi = None;
643 i += 3;
644 } else if delim == "\"\"\"" && b[i] == b'\\' {
645 i += 2;
646 } else {
647 i += 1;
648 }
649 continue;
650 }
651 let c = b[i];
652 match quote {
653 Some(b'"') if escaped => escaped = false,
654 Some(b'"') if c == b'\\' => escaped = true,
655 Some(q) if c == q => quote = None,
656 Some(_) => {}
657 None => {
658 if b[i..].starts_with(b"\"\"\"") {
659 st.multi = Some("\"\"\"");
660 i += 3;
661 continue;
662 }
663 if b[i..].starts_with(b"'''") {
664 st.multi = Some("'''");
665 i += 3;
666 continue;
667 }
668 match c {
669 b'"' | b'\'' => quote = Some(c),
670 b'[' | b'{' => st.depth += 1,
671 b']' | b'}' => st.depth -= 1,
672 b'#' => return Some(i),
673 _ => {}
674 }
675 }
676 }
677 i += 1;
678 }
679 None
680}
681
682fn quoted_ids(code: &str) -> Vec<String> {
684 code.split('"')
685 .skip(1)
686 .step_by(2)
687 .map(str::to_owned)
688 .collect()
689}
690
691fn assigned_key(trimmed: &str) -> Option<(String, usize)> {
693 let eq = trimmed.find('=')?;
694 let raw = trimmed[..eq].trim();
695 let key = raw.trim_matches(|c| c == '"' || c == '\'');
696 let simple = !key.is_empty()
697 && key
698 .chars()
699 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
700 simple.then(|| (key.to_owned(), eq + 1))
701}
702
703fn is_roles_header(trimmed: &str) -> bool {
704 let Some(rest) = trimmed.strip_prefix('[') else {
705 return false;
706 };
707 if rest.starts_with('[') {
708 return false;
709 }
710 rest.split(']')
711 .next()
712 .is_some_and(|n| n.trim().trim_matches(|c| c == '"' || c == '\'') == "roles")
713}
714
715struct OldSpan {
718 start: usize,
719 end: usize,
720 notes: Vec<(usize, Vec<String>, String)>,
722 seats: Vec<(String, Option<String>)>,
726}
727
728fn patch_role(text: &str, key: &str, ids: &[String]) -> Result<String, String> {
735 let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
736 let lines: Vec<&str> = text.split_inclusive('\n').collect();
737
738 let mut st = Scan::default();
741 let mut in_roles = false;
742 let mut header: Option<usize> = None;
743 let mut last_key_end: Option<usize> = None;
744 let mut span: Option<OldSpan> = None;
745 let mut i = 0;
746 while i < lines.len() {
747 let trimmed = lines[i].trim();
748 if !st.open() && trimmed.starts_with('[') {
749 in_roles = header.is_none() && is_roles_header(trimmed);
750 if in_roles {
751 header = Some(i);
752 } else if header.is_some() {
753 break;
754 }
755 i += 1;
756 continue;
757 }
758 if !st.open()
759 && let Some((name, _)) = assigned_key(trimmed)
760 {
761 let start = i;
762 let first = lines[i].trim_start();
763 let at = first.find('=').map_or(0, |p| p + 1);
764 let mut end = i;
765 let mut notes = Vec::new();
766 let mut seats: Vec<(String, Option<String>)> = Vec::new();
767 let mut note = |line: usize, code: &str, hash: Option<usize>| {
768 let ids = quoted_ids(&code[..hash.unwrap_or(code.len())]);
769 let comment = hash.map(|h| code[h..].trim_end().to_owned());
770 let last = ids.len().saturating_sub(1);
771 for (n, id) in ids.iter().enumerate() {
772 seats.push((id.clone(), comment.clone().filter(|_| n == last)));
773 }
774 if let Some(c) = comment {
775 notes.push((line, ids, c));
776 }
777 };
778 let code = &first[at..];
779 let hash = scan_line(code, &mut st);
780 note(start, code, hash);
781 while st.open() && end + 1 < lines.len() {
782 end += 1;
783 let hash = scan_line(lines[end], &mut st);
784 note(end, lines[end], hash);
785 }
786 if in_roles {
787 last_key_end = Some(end);
788 if name == key {
789 let body = lines[start..=end].concat();
790 if body.contains("\"\"\"") || body.contains("'''") {
791 return Err(format!(
792 "`{key}` uses a multi-line string; edit it by hand."
793 ));
794 }
795 if body.contains("{{") || body.contains("{%") {
796 return Err(format!(
797 "`{key}` is written with a template expression, which this screen \
798 cannot edit without losing it. Change it by hand."
799 ));
800 }
801 span = Some(OldSpan {
802 start,
803 end,
804 notes,
805 seats,
806 });
807 }
808 }
809 i = end + 1;
810 continue;
811 }
812 if st.open() {
813 scan_line(lines[i], &mut st);
814 }
815 i += 1;
816 }
817
818 let mut out: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
819 let new_line = |indent: &str, comment: Option<&str>| {
820 let mut s = format!("{indent}{key} = {}", value_text(key, ids));
821 if let Some(c) = comment {
822 s.push_str(" ");
823 s.push_str(c);
824 }
825 s.push_str(eol);
826 s
827 };
828 match (span, ids.is_empty()) {
829 (Some(old), true) => {
830 let indent: String = lines[old.start]
831 .chars()
832 .take_while(|c| c.is_whitespace())
833 .collect();
834 let kept: Vec<String> = old
835 .notes
836 .iter()
837 .map(|(_, _, c)| format!("{indent}{c}{eol}"))
838 .collect();
839 out.splice(old.start..=old.end, kept);
840 }
841 (Some(old), false) => {
842 let indent: String = lines[old.start]
843 .chars()
844 .take_while(|c| c.is_whitespace())
845 .collect();
846 let single = old.start == old.end;
847 let trailing = match old.notes.last() {
850 Some((line, on_line, c)) if *line == old.end && (single || on_line.is_empty()) => {
851 Some(c.clone())
852 }
853 _ => None,
854 };
855 let interior = &old.notes[..old.notes.len() - usize::from(trailing.is_some())];
856 let replacement: Vec<String> =
857 if interior.is_empty() || (is_chain_role(key) && ids.len() == 1) {
858 let mut v: Vec<String> = interior
860 .iter()
861 .map(|(_, _, c)| format!("{indent}{c}{eol}"))
862 .collect();
863 v.push(new_line(&indent, trailing.as_deref()));
864 v
865 } else {
866 let mut v = vec![format!("{indent}{key} = [{eol}")];
867 for (_, _, c) in interior.iter().filter(|(_, l, _)| l.is_empty()) {
868 v.push(format!("{indent} {c}{eol}"));
869 }
870 let mut taken: std::collections::HashMap<&str, usize> = Default::default();
871 for id in ids {
872 let nth = taken.entry(id.as_str()).or_insert(0);
873 let note = old
874 .seats
875 .iter()
876 .filter(|(old_id, _)| old_id == id)
877 .nth(*nth)
878 .and_then(|(_, c)| c.as_ref())
879 .map(|c| format!(" {c}"))
880 .unwrap_or_default();
881 *nth += 1;
882 v.push(format!("{indent} {},{note}{eol}", quote(id)));
883 }
884 v.push(format!(
885 "{indent}]{}{eol}",
886 trailing.map(|c| format!(" {c}")).unwrap_or_default()
887 ));
888 v
889 };
890 out.splice(old.start..=old.end, replacement);
891 }
892 (None, true) => {}
893 (None, false) => match (header, last_key_end) {
894 (Some(_), Some(end)) | (Some(end), None) => {
895 if !out[end].ends_with('\n') {
896 out[end].push_str(eol);
897 }
898 out.insert(end + 1, new_line("", None));
899 }
900 (None, _) => {
901 if let Some(last) = out.last_mut()
902 && !last.ends_with('\n')
903 {
904 last.push_str(eol);
905 }
906 if !out.is_empty() {
907 out.push(eol.to_owned());
908 }
909 out.push(format!("[roles]{eol}"));
910 out.push(new_line("", None));
911 }
912 },
913 }
914 Ok(out.concat())
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920 use tempfile::TempDir;
921
922 fn ids(v: &[&str]) -> Vec<String> {
923 v.iter().map(|s| (*s).to_owned()).collect()
924 }
925
926 const AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n\
927 [[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
928
929 #[test]
930 fn patch_keeps_comments_and_unrelated_keys() {
931 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";
932 let out = patch_role(src, "implementers", &ids(&["b", "a"])).unwrap();
933 assert_eq!(
934 out,
935 "# 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"
936 );
937 let out = patch_role(&out, "judges", &[]).unwrap();
938 assert!(!out.contains("judges"));
939 assert!(out.contains("fixer = \"a\"") && out.contains("[graph]"));
940 }
941
942 #[test]
943 fn patch_does_not_read_a_role_table_out_of_a_multiline_string() {
944 let src = "[vars]\nexample = \'\'\'\n[roles]\njudges = [\"a\"]\n\'\'\'\nother = \"\"\"\n[roles]\n\"\"\"\n";
945 assert_eq!(patch_role(src, "judges", &[]).unwrap(), src);
947 let out = patch_role(src, "judges", &ids(&["a"])).unwrap();
948 assert!(out.starts_with(src), "{out}");
949 assert!(out.ends_with("\n[roles]\njudges = [\"a\"]\n"), "{out}");
950 }
951
952 #[test]
953 fn duplicate_seats_keep_their_own_comments() {
954 let src =
955 "[roles]\njudges = [\n \"a\", # first seat\n \"a\", # second seat\n \"b\",\n]\n";
956 let out = patch_role(src, "judges", &ids(&["b", "a", "a"])).unwrap();
957 assert_eq!(
958 out,
959 "[roles]\njudges = [\n \"b\",\n \"a\", # first seat\n \"a\", # second seat\n]\n"
960 );
961 }
962
963 #[test]
964 fn a_reset_keeps_the_comments_of_the_removed_key() {
965 let out = patch_role(
966 "[roles]\njudges = [\"a\"] # why a\nfixer = \"a\"\n",
967 "judges",
968 &[],
969 )
970 .unwrap();
971 assert_eq!(out, "[roles]\n# why a\nfixer = \"a\"\n");
972 let out = patch_role(
973 "[roles]\njudges = [\n # lead\n \"a\", # why a\n]\n",
974 "judges",
975 &[],
976 )
977 .unwrap();
978 assert_eq!(out, "[roles]\n# lead\n# why a\n");
979 }
980
981 #[test]
982 fn a_comment_follows_its_id_through_a_reorder() {
983 let src =
984 "[roles]\njudges = [ # head\n # lead\n \"a\", # why a\n \"b\", # why b\n] # tail\n";
985 let out = patch_role(src, "judges", &ids(&["b", "c", "a"])).unwrap();
986 assert_eq!(
987 out,
988 "[roles]\njudges = [\n # head\n # lead\n \"b\", # why b\n \"c\",\n \"a\", # why a\n] # tail\n"
989 );
990 }
991
992 #[test]
993 fn patch_creates_the_table_and_inserts_into_it() {
994 let out = patch_role("[vars]\nx = 1", "judges", &ids(&["a"])).unwrap();
995 assert_eq!(out, "[vars]\nx = 1\n\n[roles]\njudges = [\"a\"]\n");
996 let out = patch_role(
997 "[roles]\nfixer = \"a\"\n\n[graph]\njudges = 3\n",
998 "judges",
999 &ids(&["a"]),
1000 )
1001 .unwrap();
1002 assert_eq!(
1003 out,
1004 "[roles]\nfixer = \"a\"\njudges = [\"a\"]\n\n[graph]\njudges = 3\n"
1005 );
1006 let out = patch_role("[graph]\njudges = 3\n", "judges", &[]).unwrap();
1008 assert_eq!(out, "[graph]\njudges = 3\n");
1009 }
1010
1011 #[test]
1012 fn fixer_is_a_string_for_one_and_an_array_for_a_chain() {
1013 let one = patch_role("", "fixer", &ids(&["a"])).unwrap();
1014 assert!(one.contains("fixer = \"a\""), "{one}");
1015 let two = patch_role("[roles]\nfixer = \"a\"\n", "fixer", &ids(&["a", "b"])).unwrap();
1016 assert!(two.contains("fixer = [\"a\", \"b\"]"), "{two}");
1017 let reset = patch_role(&two, "fixer", &[]).unwrap();
1018 assert!(!reset.contains("fixer ="), "{reset}");
1019 }
1020
1021 #[test]
1022 fn synthesizer_is_a_string_for_one_and_an_array_for_a_chain() {
1023 let one = patch_role("", "synthesizer", &ids(&["a"])).unwrap();
1024 assert!(one.contains("synthesizer = \"a\""), "{one}");
1025 let two = patch_role("", "synthesizer", &ids(&["a", "b"])).unwrap();
1026 assert!(two.contains("synthesizer = [\"a\", \"b\"]"), "{two}");
1027 }
1028
1029 #[test]
1030 fn patch_refuses_a_templated_value() {
1031 let src = "[roles]\njudges = [\"{{ env.X | default(value='a') }}\"]\n";
1032 assert!(patch_role(src, "judges", &ids(&["a"])).is_err());
1033 }
1034
1035 #[test]
1036 fn save_writes_machine_file_only_and_keeps_comments() {
1037 let tmp = TempDir::new().unwrap();
1038 let repo = tmp.path().join("repo");
1039 std::fs::create_dir_all(&repo).unwrap();
1040 let repo_toml = format!("{AGENTS}\n[graph]\ncandidates = 1\n");
1041 std::fs::write(repo.join("magi.toml"), &repo_toml).unwrap();
1042 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
1043 std::fs::create_dir_all(machine.parent().unwrap()).unwrap();
1044 std::fs::write(
1045 &machine,
1046 "# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n",
1047 )
1048 .unwrap();
1049
1050 let v = view(&repo, Some(&machine));
1051 assert!(v.error.is_none(), "{:?}", v.error);
1052 let r = save(
1053 &repo,
1054 Some(&machine),
1055 &v.revision,
1056 &BTreeMap::from([("judges".to_owned(), ids(&["b", "a"]))]),
1057 )
1058 .unwrap();
1059 let text = std::fs::read_to_string(&machine).unwrap();
1060 assert_eq!(
1061 text,
1062 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\n\n[vars]\nx = 1\n"
1063 );
1064 assert_eq!(
1065 std::fs::read_to_string(repo.join("magi.toml")).unwrap(),
1066 repo_toml
1067 );
1068 let judges = r.roles.iter().find(|x| x.key == "judges").unwrap();
1069 assert_eq!(judges.source, "machine");
1070 assert_eq!(judges.configured, ids(&["b", "a"]));
1071 let leftovers = std::fs::read_dir(machine.parent().unwrap())
1073 .unwrap()
1074 .count();
1075 assert_eq!(leftovers, 1);
1076 }
1077
1078 #[test]
1079 fn save_refuses_unknown_ids_and_leaves_the_file_alone() {
1080 let tmp = TempDir::new().unwrap();
1081 let repo = tmp.path().join("repo");
1082 std::fs::create_dir_all(&repo).unwrap();
1083 std::fs::write(repo.join("magi.toml"), AGENTS).unwrap();
1084 let machine = tmp.path().join("m").join("magi").join("config.toml");
1085 let v = view(&repo, Some(&machine));
1086 let err = save(
1087 &repo,
1088 Some(&machine),
1089 &v.revision,
1090 &BTreeMap::from([("judges".to_owned(), ids(&["nope"]))]),
1091 )
1092 .unwrap_err();
1093 assert!(matches!(err, SaveError::Refused(m) if m.contains("nope")));
1094 assert!(!machine.exists());
1095 }
1096
1097 #[test]
1098 fn save_refuses_a_key_the_repo_owns_and_a_stale_revision() {
1099 let tmp = TempDir::new().unwrap();
1100 let repo = tmp.path().join("repo");
1101 std::fs::create_dir_all(&repo).unwrap();
1102 std::fs::write(
1103 repo.join("magi.toml"),
1104 format!("{AGENTS}\n[roles]\njudges = [\"a\"]\n"),
1105 )
1106 .unwrap();
1107 let machine = tmp.path().join("m").join("magi").join("config.toml");
1108 let v = view(&repo, Some(&machine));
1109 let j = v.roles.iter().find(|r| r.key == "judges").unwrap();
1110 assert!(!j.editable && j.source == "repo");
1111 let want = BTreeMap::from([("judges".to_owned(), ids(&["b"]))]);
1112 assert!(matches!(
1113 save(&repo, Some(&machine), &v.revision, &want),
1114 Err(SaveError::Refused(_))
1115 ));
1116 let want = BTreeMap::from([("reviewers".to_owned(), ids(&["b"]))]);
1117 assert!(matches!(
1118 save(&repo, Some(&machine), "stale", &want),
1119 Err(SaveError::Conflict(_))
1120 ));
1121 }
1122
1123 #[test]
1124 fn a_config_that_does_not_parse_is_an_error_not_an_empty_list() {
1125 let tmp = TempDir::new().unwrap();
1126 let repo = tmp.path().join("repo");
1127 std::fs::create_dir_all(&repo).unwrap();
1128 std::fs::write(repo.join("magi.toml"), "[roles\nbroken").unwrap();
1129 let v = view(&repo, None);
1130 let e = v.error.expect("an error");
1131 assert!(
1132 e.path.is_some_and(|p| p.ends_with("magi.toml")),
1133 "{}",
1134 e.message
1135 );
1136 assert!(v.roles.is_empty() && v.agents.is_empty());
1137 }
1138
1139 #[test]
1140 fn advisors_unset_falls_back_to_judges() {
1141 let tmp = TempDir::new().unwrap();
1142 let repo = tmp.path().join("repo");
1143 std::fs::create_dir_all(&repo).unwrap();
1144 std::fs::write(
1145 repo.join("magi.toml"),
1146 format!("{AGENTS}\n[roles]\njudges = [\"b\"]\n"),
1147 )
1148 .unwrap();
1149 let v = view(&repo, None);
1150 let a = v.roles.iter().find(|r| r.key == "advisors").unwrap();
1151 assert_eq!(a.fallback, Some("judges"));
1152 assert_eq!(a.source, "default");
1153 assert!(
1154 a.seats.iter().all(|s| s == "b") && !a.seats.is_empty(),
1155 "{:?}",
1156 a.seats
1157 );
1158 }
1159
1160 fn two_repos(tmp: &TempDir, other_toml: &str) -> (PathBuf, PathBuf, PathBuf) {
1163 let root = tmp.path().join("ghq");
1164 let repo = root.join("h").join("o").join("cur");
1165 let other = root.join("h").join("o").join("other");
1166 for d in [&repo, &other] {
1167 std::fs::create_dir_all(d.join(".git")).unwrap();
1168 }
1169 std::fs::write(
1170 repo.join("magi.toml"),
1171 format!(
1172 "{AGENTS}\n[repos]\nroots = [{}]\n",
1173 quote(&root.to_string_lossy())
1174 ),
1175 )
1176 .unwrap();
1177 std::fs::write(other.join("magi.toml"), other_toml).unwrap();
1178 let machine = tmp.path().join("m").join("magi").join("config.toml");
1179 (repo, other, machine)
1180 }
1181
1182 #[test]
1183 fn saving_judges_is_refused_when_another_repo_declares_them() {
1184 let tmp = TempDir::new().unwrap();
1185 let (repo, other, machine) =
1186 two_repos(&tmp, &format!("{AGENTS}\n[roles]\njudges = [\"a\"]\n"));
1187 let v = view(&repo, Some(&machine));
1188 let j = v.roles.iter().find(|r| r.key == "judges").unwrap();
1189 assert!(!j.editable, "{:?}", j.locked_reason);
1190 let err = save(
1191 &repo,
1192 Some(&machine),
1193 &v.revision,
1194 &BTreeMap::from([("judges".to_owned(), ids(&["b"]))]),
1195 )
1196 .unwrap_err();
1197 assert!(
1198 matches!(&err, SaveError::Refused(m) if m.contains("o/other")),
1199 "{err:?}"
1200 );
1201 assert!(!machine.exists());
1202 assert!(Config::load_layers(&repo_layers(&other)).is_ok());
1203 save(
1205 &repo,
1206 Some(&machine),
1207 &v.revision,
1208 &BTreeMap::from([("reviewers".to_owned(), ids(&["b"]))]),
1209 )
1210 .unwrap();
1211 let mut layers = vec![machine.clone()];
1212 layers.extend(repo_layers(&other));
1213 assert!(Config::load_layers(&layers).is_ok());
1214 }
1215
1216 #[test]
1217 fn saving_a_role_writes_no_agents_and_detection_stays_dynamic() {
1218 let tmp = TempDir::new().unwrap();
1219 let repo = tmp.path().join("repo");
1220 std::fs::create_dir_all(&repo).unwrap();
1221 std::fs::write(repo.join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
1222 let machine = tmp.path().join("m").join("magi").join("config.toml");
1223 let v = view(&repo, Some(&machine));
1224 let Some(first) = Config::autodetected().agents.first().map(|a| a.id.clone()) else {
1225 return; };
1227 save(
1228 &repo,
1229 Some(&machine),
1230 &v.revision,
1231 &BTreeMap::from([("judges".to_owned(), ids(&[&first]))]),
1232 )
1233 .unwrap();
1234 let text = std::fs::read_to_string(&machine).unwrap();
1235 assert!(!text.contains("[[agents]]"), "{text}");
1236 let after = view(&repo, Some(&machine));
1237 assert!(after.agents.iter().all(|a| a.source == "detected"));
1238 assert_eq!(
1239 ids_of(
1240 &Config::load_layers(&layer_paths(&repo, Some(&machine)))
1241 .unwrap()
1242 .agents
1243 ),
1244 ids_of(&Config::autodetected().agents)
1245 );
1246 }
1247
1248 #[test]
1249 fn an_explicit_empty_agents_list_is_kept() {
1250 let tmp = TempDir::new().unwrap();
1251 let f = tmp.path().join("magi.toml");
1252 std::fs::write(&f, "agents = []\n").unwrap();
1253 assert!(Config::load_layers(&[f]).unwrap().agents.is_empty());
1254 }
1255
1256 #[test]
1257 fn the_repo_lock_says_the_repo_overrides_the_key() {
1258 let tmp = TempDir::new().unwrap();
1259 let repo = tmp.path().join("repo");
1260 std::fs::create_dir_all(&repo).unwrap();
1261 std::fs::write(
1262 repo.join("magi.toml"),
1263 format!("{AGENTS}\n[roles]\njudges = [\"a\"]\n"),
1264 )
1265 .unwrap();
1266 let machine = tmp.path().join("m").join("magi").join("config.toml");
1267 let v = view(&repo, Some(&machine));
1268 let j = v.roles.iter().find(|r| r.key == "judges").unwrap();
1269 let why = j.locked_reason.as_deref().unwrap();
1270 assert!(
1271 why.starts_with("This repo overrides roles.judges in "),
1272 "{why}"
1273 );
1274 }
1275}