1use 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#[derive(Debug, Default, Clone, PartialEq)]
28pub struct Settings {
29 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
38pub 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 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
119fn 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
142fn 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
268pub 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 write(path, &format!("{HEADER}\n{doc}"))
295}
296
297pub 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
325fn 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
337fn 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
355fn 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
372fn typed(key: &str, value: &str) -> Value {
375 match Config::kind(key) {
376 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
430pub 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
459pub 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
473pub 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
500pub 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
521pub 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
558pub 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
581pub 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 #[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 #[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 #[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}