Skip to main content

pma_core/
settings.rs

1//! `settings.toml`: the configuration a user edits. It holds the `config`
2//! keys, roots, workers, presets and each project's tier and tags. The
3//! store's tables of the same names mirror it, so queries keep their joins;
4//! the file wins. Workflow and route revisions stay in the store, since
5//! activating one is a gate a hand edit would skip.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
11
12use crate::config::Config;
13use crate::store::Preset;
14use crate::worker::{Parser, Worker};
15
16type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
17
18pub const FILE: &str = "settings.toml";
19
20const HEADER: &str = "\
21# pma settings. Edit by hand, or with `pma config`, `pma root`, `pma project`,
22# `pma agent` and `pma preset`, which keep comments. An invalid file stops
23# every command until it is fixed.
24";
25
26/// What the file holds, read and checked.
27#[derive(Debug, Default, Clone, PartialEq)]
28pub struct Settings {
29    /// Flat keys, as `pma config` names them, including `projects.<n>.*`.
30    pub config: Vec<(String, String)>,
31    pub roots: Vec<PathBuf>,
32    pub tiers: BTreeMap<String, u8>,
33    pub tags: Vec<(String, String)>,
34    pub agents: Vec<Worker>,
35    pub presets: Vec<Preset>,
36}
37
38/// The file's text and what it says. An unreadable or invalid file is an
39/// error naming the path and the key.
40pub fn read(path: &Path) -> Result<(String, Settings)> {
41    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
42    let doc: DocumentMut = text
43        .parse()
44        .map_err(|e| format!("{}: {e}", path.display()))?;
45    let settings = parse(&doc).map_err(|e| format!("{}: {e}", path.display()))?;
46    Ok((text, settings))
47}
48
49fn parse(doc: &DocumentMut) -> Result<Settings> {
50    let mut s = Settings::default();
51    for (key, item) in doc.iter() {
52        match key {
53            "roots" => {
54                s.roots = strings(item, "roots")?
55                    .into_iter()
56                    .map(PathBuf::from)
57                    .collect()
58            }
59            "config" => flatten(table(item, "config")?, "", &mut s.config)?,
60            "projects" => {
61                for (name, item) in table(item, "projects")?.iter() {
62                    let at = format!("projects.{name}");
63                    for (field, v) in table(item, &at)?.iter() {
64                        let at = format!("{at}.{field}");
65                        match field {
66                            "tier" => {
67                                let t = v
68                                    .as_integer()
69                                    .and_then(|t| u8::try_from(t).ok())
70                                    .filter(|t| (1..=5).contains(t))
71                                    .ok_or(format!("{at}: expected a tier from 1 to 5"))?;
72                                s.tiers.insert(name.to_string(), t);
73                            }
74                            "tags" => {
75                                for tag in strings(v, &at)? {
76                                    s.tags.push((name.to_string(), tag));
77                                }
78                            }
79                            "verify" | "sandbox" => s.config.push((
80                                format!("projects.{name}.{field}"),
81                                scalar(v).ok_or(format!("{at}: expected a value"))?,
82                            )),
83                            _ => return Err(format!("{at}: unknown field").into()),
84                        }
85                    }
86                }
87            }
88            "agents" => {
89                for (name, item) in table(item, "agents")?.iter() {
90                    s.agents
91                        .push(worker(name, table(item, &format!("agents.{name}"))?)?);
92                }
93            }
94            "presets" => {
95                for (name, item) in table(item, "presets")?.iter() {
96                    s.presets
97                        .push(preset(name, table(item, &format!("presets.{name}"))?)?);
98                }
99            }
100            _ => return Err(format!("{key}: unknown section").into()),
101        }
102    }
103    // Checked here, so a bad value names the file rather than failing later.
104    let mut cfg = Config::default();
105    for (key, value) in &s.config {
106        if crate::config::retired(key).is_none() {
107            cfg.set(key, value)
108                .map_err(|e| format!("{}: {e}", toml_path(key)))?;
109        }
110    }
111    for p in &s.presets {
112        if !s.agents.iter().any(|w| w.name == p.agent) {
113            return Err(format!("presets.{}: no worker `{}`", p.name, p.agent).into());
114        }
115    }
116    Ok(s)
117}
118
119/// Where a flat key sits in the file, for messages.
120fn toml_path(key: &str) -> String {
121    match key.strip_prefix("projects.") {
122        Some(rest) => format!("projects.{rest}"),
123        None => format!("config.{key}"),
124    }
125}
126
127fn table<'a>(item: &'a Item, at: &str) -> Result<&'a Table> {
128    item.as_table()
129        .ok_or_else(|| format!("{at}: expected a table").into())
130}
131
132fn strings(item: &Item, at: &str) -> Result<Vec<String>> {
133    item.as_array()
134        .and_then(|a| {
135            a.iter()
136                .map(|v| v.as_str().map(String::from))
137                .collect::<Option<Vec<_>>>()
138        })
139        .ok_or_else(|| format!("{at}: expected a list of strings").into())
140}
141
142/// A value as `Config::set` reads it.
143fn scalar(item: &Item) -> Option<String> {
144    let v = item.as_value()?;
145    Some(match v {
146        Value::String(s) => s.value().clone(),
147        Value::Integer(i) => i.value().to_string(),
148        Value::Float(f) => f.value().to_string(),
149        Value::Boolean(b) => if *b.value() { "on" } else { "off" }.into(),
150        Value::Array(a) => a
151            .iter()
152            .map(|v| v.as_str().map(String::from))
153            .collect::<Option<Vec<_>>>()?
154            .join(","),
155        _ => return None,
156    })
157}
158
159fn flatten(t: &Table, prefix: &str, out: &mut Vec<(String, String)>) -> Result<()> {
160    for (key, item) in t.iter() {
161        let key = format!("{prefix}{key}");
162        match item.as_table_like() {
163            Some(_) if !item.is_inline_table() => {
164                flatten(table(item, &key)?, &format!("{key}."), out)?
165            }
166            _ => out.push((
167                key.clone(),
168                scalar(item).ok_or(format!("config.{key}: expected a value"))?,
169            )),
170        }
171    }
172    Ok(())
173}
174
175fn worker(name: &str, t: &Table) -> Result<Worker> {
176    let at = |f: &str| format!("agents.{name}.{f}");
177    let text = |f: &str| -> Result<String> {
178        t.get(f)
179            .and_then(Item::as_str)
180            .map(String::from)
181            .ok_or_else(|| format!("{}: expected text", at(f)).into())
182    };
183    let flag = |f: &str| -> Result<bool> {
184        match t.get(f) {
185            None => Ok(false),
186            Some(v) => v
187                .as_bool()
188                .ok_or_else(|| format!("{}: expected true or false", at(f)).into()),
189        }
190    };
191    for (field, _) in t.iter() {
192        if !matches!(
193            field,
194            "command"
195                | "args"
196                | "allow"
197                | "parse"
198                | "reports_cost"
199                | "enforces_budget"
200                | "sandbox"
201                | "resumes"
202                | "env"
203        ) {
204            return Err(format!("{}: unknown field", at(field)).into());
205        }
206    }
207    let parse = text("parse")?;
208    let env = match t.get("env") {
209        None => BTreeMap::new(),
210        Some(item) => item
211            .as_table_like()
212            .ok_or(format!("{}: expected a table", at("env")))?
213            .iter()
214            .map(|(k, v)| {
215                v.as_str()
216                    .map(|v| (k.to_string(), v.to_string()))
217                    .ok_or(format!("{}.{k}: expected text", at("env")))
218            })
219            .collect::<std::result::Result<_, _>>()?,
220    };
221    Ok(Worker {
222        name: name.to_string(),
223        command: text("command")?,
224        args: match t.get("args") {
225            None => Vec::new(),
226            Some(v) => strings(v, &at("args"))?,
227        },
228        allow: t
229            .get("allow")
230            .and_then(Item::as_str)
231            .filter(|a| !a.is_empty())
232            .map(String::from),
233        parse: Parser::parse(&parse).ok_or(format!("{}: unknown parser `{parse}`", at("parse")))?,
234        reports_cost: flag("reports_cost")?,
235        enforces_budget: flag("enforces_budget")?,
236        sandbox: flag("sandbox")?,
237        resumes: flag("resumes")?,
238        env,
239    })
240}
241
242fn preset(name: &str, t: &Table) -> Result<Preset> {
243    let at = |f: &str| format!("presets.{name}.{f}");
244    for (field, _) in t.iter() {
245        if !matches!(field, "agent" | "model" | "args") {
246            return Err(format!("{}: unknown field", at(field)).into());
247        }
248    }
249    Ok(Preset {
250        name: name.to_string(),
251        agent: t
252            .get("agent")
253            .and_then(Item::as_str)
254            .map(String::from)
255            .ok_or(format!("{}: expected text", at("agent")))?,
256        model: t
257            .get("model")
258            .and_then(Item::as_str)
259            .filter(|m| !m.is_empty())
260            .map(String::from),
261        args: match t.get("args") {
262            None => Vec::new(),
263            Some(v) => strings(v, &at("args"))?,
264        },
265    })
266}
267
268/// Writes a new file holding `s`, for the first run after the tables held
269/// the configuration.
270pub fn create(path: &Path, s: &Settings) -> Result<()> {
271    let mut doc = DocumentMut::new();
272    if !s.roots.is_empty() {
273        doc["roots"] = Item::Value(Value::Array(array(
274            s.roots.iter().map(|p| p.to_string_lossy().into_owned()),
275        )));
276    }
277    for (key, value) in &s.config {
278        set_config(&mut doc, key, value);
279    }
280    for (name, tier) in &s.tiers {
281        set_tier(&mut doc, name, Some(*tier));
282    }
283    for (project, tag) in &s.tags {
284        tag_project(&mut doc, project, tag, true);
285    }
286    for w in &s.agents {
287        set_agent(&mut doc, w);
288    }
289    for p in &s.presets {
290        set_preset(&mut doc, p);
291    }
292    // Leading text, so it stays above the first entry when parsed again; a
293    // document's own comments are kept at its end.
294    write(path, &format!("{HEADER}\n{doc}"))
295}
296
297/// Applies `f` to the file under an exclusive lock, so two commands editing
298/// it at once do not lose a change. The result is checked before it is
299/// written; a missing file starts empty.
300pub fn edit<T>(path: &Path, f: impl FnOnce(&mut DocumentMut) -> T) -> Result<T> {
301    let lock_path = path.with_extension("lock");
302    let lock = std::fs::OpenOptions::new()
303        .write(true)
304        .create(true)
305        .truncate(false)
306        .open(&lock_path)
307        .map_err(|e| format!("{}: {e}", lock_path.display()))?;
308    lock.lock()
309        .map_err(|e| format!("{}: {e}", lock_path.display()))?;
310    let (text, header) = match std::fs::read_to_string(path) {
311        Ok(t) => (t, ""),
312        Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), HEADER),
313        Err(e) => return Err(format!("{}: {e}", path.display()).into()),
314    };
315    let mut doc: DocumentMut = text
316        .parse()
317        .map_err(|e| format!("{}: {e}", path.display()))?;
318    let out = f(&mut doc);
319    let text = format!("{header}{}{doc}", if header.is_empty() { "" } else { "\n" });
320    parse(&text.parse::<DocumentMut>()?).map_err(|e| format!("{}: {e}", path.display()))?;
321    write(path, &text)?;
322    Ok(out)
323}
324
325/// Through a temporary file and a rename, so a reader never sees half of it.
326fn write(path: &Path, text: &str) -> Result<()> {
327    let tmp = path.with_extension("toml.tmp");
328    std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
329    std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))?;
330    Ok(())
331}
332
333fn array(items: impl IntoIterator<Item = String>) -> Array {
334    items.into_iter().collect()
335}
336
337/// The `[section.name]` table, made if absent.
338fn entry<'a>(doc: &'a mut DocumentMut, section: &str, name: &str) -> &'a mut Table {
339    let outer = doc
340        .entry(section)
341        .or_insert_with(|| {
342            let mut t = Table::new();
343            t.set_implicit(true);
344            Item::Table(t)
345        })
346        .as_table_mut()
347        .expect("checked by parse");
348    outer
349        .entry(name)
350        .or_insert_with(|| Item::Table(Table::new()))
351        .as_table_mut()
352        .expect("checked by parse")
353}
354
355/// Drops `[section.name]` once it holds nothing, and `[section]` with it.
356fn prune(doc: &mut DocumentMut, section: &str, name: &str) {
357    let Some(outer) = doc.get_mut(section).and_then(Item::as_table_mut) else {
358        return;
359    };
360    if outer
361        .get(name)
362        .and_then(Item::as_table)
363        .is_some_and(Table::is_empty)
364    {
365        outer.remove(name);
366    }
367    if outer.is_empty() {
368        doc.remove(section);
369    }
370}
371
372/// A value of the type its key takes, so the file reads as TOML rather than
373/// as quoted text.
374fn typed(key: &str, value: &str) -> Value {
375    match Config::kind(key) {
376        // A whole number reads as one: `agent_budget = 3`, not `3.0`.
377        Some("number") => match (value.parse::<i64>(), value.parse::<f64>()) {
378            (Ok(i), _) => Value::from(i),
379            (_, Ok(f)) => Value::from(f),
380            _ => value.into(),
381        },
382        Some("count") => value
383            .parse::<i64>()
384            .map(Value::from)
385            .unwrap_or_else(|_| value.into()),
386        Some("tier") => value
387            .parse::<i64>()
388            .map(Value::from)
389            .unwrap_or_else(|_| value.into()),
390        Some("list") => Value::Array(array(
391            value
392                .split(',')
393                .map(str::trim)
394                .filter(|s| !s.is_empty())
395                .map(String::from),
396        )),
397        Some("switch") => Value::from(value == "on"),
398        _ => value.into(),
399    }
400}
401
402pub fn set_config(doc: &mut DocumentMut, key: &str, value: &str) {
403    if let Some((name, field)) = key
404        .strip_prefix("projects.")
405        .and_then(|r| r.rsplit_once('.'))
406    {
407        entry(doc, "projects", name)[field] = Item::Value(typed(key, value));
408        return;
409    }
410    let mut t = doc
411        .entry("config")
412        .or_insert_with(|| Item::Table(Table::new()))
413        .as_table_mut()
414        .expect("checked by parse");
415    let parts: Vec<&str> = key.split('.').collect();
416    for part in &parts[..parts.len() - 1] {
417        t = t
418            .entry(part)
419            .or_insert_with(|| {
420                let mut sub = Table::new();
421                sub.set_dotted(true);
422                Item::Table(sub)
423            })
424            .as_table_mut()
425            .expect("checked by parse");
426    }
427    t[parts[parts.len() - 1]] = Item::Value(typed(key, value));
428}
429
430/// Returns whether the key was set.
431pub fn reset_config(doc: &mut DocumentMut, key: &str) -> bool {
432    if let Some((name, field)) = key
433        .strip_prefix("projects.")
434        .and_then(|r| r.rsplit_once('.'))
435    {
436        let removed = doc
437            .get_mut("projects")
438            .and_then(Item::as_table_mut)
439            .and_then(|t| t.get_mut(name))
440            .and_then(Item::as_table_mut)
441            .and_then(|t| t.remove(field))
442            .is_some();
443        prune(doc, "projects", name);
444        return removed;
445    }
446    let Some(mut t) = doc.get_mut("config").and_then(Item::as_table_mut) else {
447        return false;
448    };
449    let parts: Vec<&str> = key.split('.').collect();
450    for part in &parts[..parts.len() - 1] {
451        match t.get_mut(part).and_then(Item::as_table_mut) {
452            Some(sub) => t = sub,
453            None => return false,
454        }
455    }
456    t.remove(parts[parts.len() - 1]).is_some()
457}
458
459/// Returns false when the root was already listed.
460pub fn add_root(doc: &mut DocumentMut, path: &str) -> bool {
461    let roots = doc
462        .entry("roots")
463        .or_insert_with(|| Item::Value(Value::Array(Array::new())))
464        .as_array_mut()
465        .expect("checked by parse");
466    if roots.iter().any(|v| v.as_str() == Some(path)) {
467        return false;
468    }
469    roots.push(path);
470    true
471}
472
473/// Returns false when the root was not listed.
474pub fn remove_root(doc: &mut DocumentMut, path: &str) -> bool {
475    let Some(roots) = doc.get_mut("roots").and_then(Item::as_array_mut) else {
476        return false;
477    };
478    let before = roots.len();
479    roots.retain(|v| v.as_str() != Some(path));
480    before != roots.len()
481}
482
483pub fn set_tier(doc: &mut DocumentMut, name: &str, tier: Option<u8>) {
484    match tier {
485        Some(t) => entry(doc, "projects", name)["tier"] = Item::Value(i64::from(t).into()),
486        None => {
487            if let Some(t) = doc
488                .get_mut("projects")
489                .and_then(Item::as_table_mut)
490                .and_then(|t| t.get_mut(name))
491                .and_then(Item::as_table_mut)
492            {
493                t.remove("tier");
494            }
495            prune(doc, "projects", name);
496        }
497    }
498}
499
500/// Adds or removes one tag. Returns whether the file changed.
501pub fn tag_project(doc: &mut DocumentMut, project: &str, tag: &str, on: bool) -> bool {
502    let t = entry(doc, "projects", project);
503    let tags = t
504        .entry("tags")
505        .or_insert_with(|| Item::Value(Value::Array(Array::new())))
506        .as_array_mut()
507        .expect("checked by parse");
508    let had = tags.iter().any(|v| v.as_str() == Some(tag));
509    match (on, had) {
510        (true, false) => tags.push(tag),
511        (false, true) => tags.retain(|v| v.as_str() != Some(tag)),
512        _ => {}
513    }
514    if tags.is_empty() {
515        t.remove("tags");
516    }
517    prune(doc, "projects", project);
518    on != had
519}
520
521/// Drops a project's tier and tags, as `pma project forget` drops its record.
522/// Its `verify` and `sandbox` stay: they are settings, not facts.
523pub fn forget_project(doc: &mut DocumentMut, name: &str) {
524    if let Some(t) = doc
525        .get_mut("projects")
526        .and_then(Item::as_table_mut)
527        .and_then(|t| t.get_mut(name))
528        .and_then(Item::as_table_mut)
529    {
530        t.remove("tier");
531        t.remove("tags");
532    }
533    prune(doc, "projects", name);
534}
535
536pub fn set_agent(doc: &mut DocumentMut, w: &Worker) {
537    let mut t = Table::new();
538    t["command"] = Item::Value(w.command.as_str().into());
539    t["args"] = Item::Value(Value::Array(array(w.args.iter().cloned())));
540    if let Some(allow) = &w.allow {
541        t["allow"] = Item::Value(allow.as_str().into());
542    }
543    t["parse"] = Item::Value(w.parse.name().into());
544    t["reports_cost"] = Item::Value(w.reports_cost.into());
545    t["enforces_budget"] = Item::Value(w.enforces_budget.into());
546    t["sandbox"] = Item::Value(w.sandbox.into());
547    t["resumes"] = Item::Value(w.resumes.into());
548    if !w.env.is_empty() {
549        let mut env = InlineTable::new();
550        for (k, v) in &w.env {
551            env.insert(k, v.as_str().into());
552        }
553        t["env"] = Item::Value(Value::InlineTable(env));
554    }
555    *entry(doc, "agents", &w.name) = t;
556}
557
558/// Returns false when no worker has the name.
559pub fn remove_agent(doc: &mut DocumentMut, name: &str) -> bool {
560    let removed = doc
561        .get_mut("agents")
562        .and_then(Item::as_table_mut)
563        .and_then(|t| t.remove(name))
564        .is_some();
565    prune(doc, "agents", name);
566    removed
567}
568
569pub fn set_preset(doc: &mut DocumentMut, p: &Preset) {
570    let mut t = Table::new();
571    t["agent"] = Item::Value(p.agent.as_str().into());
572    if let Some(m) = &p.model {
573        t["model"] = Item::Value(m.as_str().into());
574    }
575    if !p.args.is_empty() {
576        t["args"] = Item::Value(Value::Array(array(p.args.iter().cloned())));
577    }
578    *entry(doc, "presets", &p.name) = t;
579}
580
581/// Returns false when no preset has the name.
582pub fn remove_preset(doc: &mut DocumentMut, name: &str) -> bool {
583    let removed = doc
584        .get_mut("presets")
585        .and_then(Item::as_table_mut)
586        .and_then(|t| t.remove(name))
587        .is_some();
588    prune(doc, "presets", name);
589    removed
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    fn scratch(name: &str) -> PathBuf {
597        let dir = std::env::temp_dir().join(format!("pma-settings-{name}-{}", std::process::id()));
598        let _ = std::fs::remove_dir_all(&dir);
599        std::fs::create_dir_all(&dir).unwrap();
600        dir.join(FILE)
601    }
602
603    /// What `create` writes, `read` reads back, typed as TOML.
604    #[test]
605    fn settings_round_trip_through_the_file() {
606        let path = scratch("round");
607        let s = Settings {
608            config: vec![
609                ("activity.ignore".into(), "docs/**,*.lock".into()),
610                ("agent_budget".into(), "3".into()),
611                ("default_tier".into(), "never".into()),
612                ("notify".into(), "notify-send pma".into()),
613                ("projects.argdec.sandbox".into(), "off".into()),
614                ("projects.argdec.verify".into(), "make test".into()),
615                ("tiers.2".into(), "0.7".into()),
616            ],
617            roots: vec![PathBuf::from("/home/u/projects")],
618            tiers: BTreeMap::from([("argdec".to_string(), 2)]),
619            tags: vec![("argdec".into(), "py".into())],
620            agents: vec![Worker::claude()],
621            presets: vec![Preset {
622                name: "cheap".into(),
623                agent: "claude".into(),
624                model: Some("haiku".into()),
625                args: vec![],
626            }],
627        };
628        create(&path, &s).unwrap();
629        let (text, back) = read(&path).unwrap();
630        assert!(text.contains("agent_budget = 3"), "{text}");
631        assert!(text.contains("tiers.2 = 0.7"), "{text}");
632        assert!(
633            text.contains("ignore = [\"docs/**\", \"*.lock\"]"),
634            "{text}"
635        );
636        assert!(text.contains("[projects.argdec]\n"), "{text}");
637        assert!(text.contains("sandbox = false"), "{text}");
638        let mut config = back.config.clone();
639        config.sort();
640        assert_eq!(config, s.config);
641        assert_eq!(
642            (back.roots, back.tiers, back.tags),
643            (s.roots, s.tiers, s.tags)
644        );
645        assert_eq!((back.agents, back.presets), (s.agents, s.presets));
646    }
647
648    /// An edit keeps what the user wrote around it, comments included.
649    #[test]
650    fn edits_keep_comments() {
651        let path = scratch("comments");
652        std::fs::write(
653            &path,
654            "# mine\nroots = [\"/a\"]\n\n[config]\n# spend\nagent_budget = 3 # per run\n",
655        )
656        .unwrap();
657        edit(&path, |doc| {
658            set_config(doc, "max_parallel", "1");
659            add_root(doc, "/b");
660        })
661        .unwrap();
662        let text = std::fs::read_to_string(&path).unwrap();
663        for kept in [
664            "# mine",
665            "# spend",
666            "agent_budget = 3 # per run",
667            "max_parallel = 1",
668        ] {
669            assert!(text.contains(kept), "{kept}:\n{text}");
670        }
671        assert!(text.contains("\"/a\", \"/b\""), "{text}");
672        assert!(edit(&path, |doc| reset_config(doc, "agent_budget")).unwrap());
673        assert!(
674            !std::fs::read_to_string(&path)
675                .unwrap()
676                .contains("agent_budget")
677        );
678    }
679
680    /// A bad value names its place in the file; an edit that would write one
681    /// writes nothing.
682    #[test]
683    fn invalid_files_name_the_key() {
684        let path = scratch("invalid");
685        for (text, says) in [
686            ("[config]\ntiers.2 = \"high\"\n", "config.tiers.2"),
687            ("[config]\nnonsense = 1\n", "unknown setting"),
688            ("[projects.a]\ntier = 9\n", "projects.a.tier"),
689            (
690                "[agents.x]\ncommand = \"x\"\nparse = \"nope\"\n",
691                "agents.x.parse",
692            ),
693            ("[presets.p]\nagent = \"ghost\"\n", "no worker `ghost`"),
694            ("[colours]\n", "unknown section"),
695            ("roots = [\n", "settings.toml"),
696        ] {
697            std::fs::write(&path, text).unwrap();
698            let e = read(&path).unwrap_err().to_string();
699            assert!(e.contains(says), "{text}: {e}");
700        }
701        std::fs::write(&path, "[config]\nagent_budget = 3\n").unwrap();
702        assert!(edit(&path, |doc| set_config(doc, "agent_budget", "x")).is_err());
703        assert!(
704            std::fs::read_to_string(&path)
705                .unwrap()
706                .contains("agent_budget = 3")
707        );
708    }
709
710    #[test]
711    fn tags_tiers_and_forgetting_tidy_up() {
712        let path = scratch("tags");
713        edit(&path, |doc| {
714            assert!(tag_project(doc, "a", "x", true));
715            assert!(!tag_project(doc, "a", "x", true));
716            set_tier(doc, "a", Some(2));
717            set_config(doc, "projects.a.verify", "make");
718        })
719        .unwrap();
720        let (_, s) = read(&path).unwrap();
721        assert_eq!(s.tags, [("a".to_string(), "x".to_string())]);
722        assert_eq!(s.tiers.get("a"), Some(&2));
723        edit(&path, |doc| forget_project(doc, "a")).unwrap();
724        let (text, s) = read(&path).unwrap();
725        assert!(s.tags.is_empty() && s.tiers.is_empty(), "{text}");
726        assert_eq!(
727            s.config,
728            [("projects.a.verify".to_string(), "make".to_string())]
729        );
730        edit(&path, |doc| reset_config(doc, "projects.a.verify")).unwrap();
731        assert!(!std::fs::read_to_string(&path).unwrap().contains("projects"));
732    }
733}