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