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