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