Skip to main content

magi/
settings.rs

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