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