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