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