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