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