Skip to main content

pma_core/
config.rs

1//! Configuration: built-in defaults plus overrides stored in the database,
2//! addressed by flat keys such as `tiers.2`, `signals.ci` or
3//! `projects.cyllama.verify`.
4
5use std::collections::BTreeMap;
6
7use crate::todo::Priority;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct Config {
11    pub important_threshold: f64,
12    /// Days before `due:` at which a task becomes urgent.
13    pub urgent_within: i64,
14    pub quadrant_limit: i64,
15    /// Multiplier per tier, index 0 is tier 1.
16    pub tiers: [f64; 5],
17    /// Weight per priority, in `Priority::ALL` order.
18    pub priorities: [f64; 4],
19    /// Priority of each signal task.
20    pub signals: SignalPriorities,
21    /// Globs for paths whose commits do not count as activity.
22    pub activity_ignore: Vec<String>,
23    /// Days without activity at which the activity signal reaches 1, per tier.
24    pub activity_horizon: [i64; 5],
25    /// The worker `pma dispatch` runs, by name in the `agents` table.
26    pub agent: String,
27    /// The preset a dispatch takes when nothing names one; `None` falls back to
28    /// `agent` and that worker's own model.
29    pub preset: Option<String>,
30    /// Tier for a project that has none, so untiered projects are ranked
31    /// rather than invisible. `None` leaves them out, as before.
32    pub default_tier: Option<i64>,
33    pub attribution: Attribution,
34    /// Agents running at once.
35    pub max_parallel: i64,
36    /// USD a dispatch batch may spend.
37    pub batch_budget: f64,
38    /// USD one agent run may spend.
39    pub agent_budget: f64,
40    /// The most a workflow revision's worst case may cost, per unit of input,
41    /// before `pma workflow activate` refuses it.
42    pub workflow_budget: f64,
43    /// Minutes an agent run, or a verify run, may take.
44    pub timeout: i64,
45    /// A shell command run when a detached job ends, with `PMA_JOB_ID`,
46    /// `PMA_JOB_STATE` and `PMA_JOB_SUMMARY` set.
47    pub notify: Option<String>,
48    pub projects: BTreeMap<String, ProjectSettings>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Attribution {
53    User,
54    CoAuthor,
55}
56
57#[derive(Debug, Clone, PartialEq)]
58pub struct ProjectSettings {
59    /// Shell command that checks an agent's work; `None` detects one.
60    pub verify: Option<String>,
61    /// Whether verify's writes are confined to the worktree.
62    pub sandbox: bool,
63}
64
65impl Default for ProjectSettings {
66    fn default() -> Self {
67        ProjectSettings {
68            verify: None,
69            sandbox: true,
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq)]
75pub struct SignalPriorities {
76    pub ci: Priority,
77    pub deps: Priority,
78    pub activity: Priority,
79    pub hygiene: Priority,
80}
81
82impl Default for Config {
83    fn default() -> Self {
84        Config {
85            important_threshold: 0.4,
86            urgent_within: 7,
87            quadrant_limit: 10,
88            tiers: [1.0, 0.8, 0.6, 0.4, 0.2],
89            priorities: [1.0, 0.5, 0.2, 0.05],
90            signals: SignalPriorities {
91                ci: Priority::High,
92                deps: Priority::Medium,
93                activity: Priority::Low,
94                hygiene: Priority::Medium,
95            },
96            // TODO.md is here so that editing the list, or the commit that
97            // added an empty one to many repos at once, is not maintenance.
98            activity_ignore: vec![".github/**".into(), "*.lock".into(), "TODO.md".into()],
99            activity_horizon: [30, 60, 120, 240, 365],
100            agent: "claude".into(),
101            preset: None,
102            default_tier: None,
103            attribution: Attribution::User,
104            max_parallel: 2,
105            batch_budget: 5.0,
106            agent_budget: 1.0,
107            workflow_budget: 25.0,
108            timeout: 30,
109            notify: None,
110            projects: BTreeMap::new(),
111        }
112    }
113}
114
115/// A mutable reference to one setting, with its validation.
116enum Slot<'a> {
117    /// A finite number of at least the given minimum.
118    Number(&'a mut f64, f64),
119    Count(&'a mut i64, i64),
120    /// 1 to 5, or `never` for none.
121    Tier(&'a mut Option<i64>),
122    Priority(&'a mut Priority),
123    List(&'a mut Vec<String>),
124    Attribution(&'a mut Attribution),
125    /// Empty text is `None`.
126    OptText(&'a mut Option<String>),
127    /// Text that may not be empty.
128    Text(&'a mut String),
129    /// `on` or `off`.
130    Switch(&'a mut bool),
131}
132
133/// Settings that no longer exist, with what replaced them. A retired name
134/// stays here so `pma config <old>` explains it. `with_overrides` skips a
135/// stored row for one, because an unknown key is a hard error on every
136/// command and an upgrade must not brick a store that set it.
137pub const RETIRED: [(&str, &str); 14] = [
138    (
139        "model",
140        "a model belongs to a preset: `pma preset set <name> <agent> <model>`",
141    ),
142    (
143        "dispatch_quadrants",
144        "dispatch draws from eligible tasks: the ci and deps signals, and items tagged #agent",
145    ),
146    (
147        "overflow_quadrants",
148        "dispatch draws from eligible tasks; there is no overflow list",
149    ),
150    (
151        "stale_after.1",
152        "age no longer makes a task urgent; see `pma stale`",
153    ),
154    (
155        "stale_after.2",
156        "age no longer makes a task urgent; see `pma stale`",
157    ),
158    (
159        "stale_after.3",
160        "age no longer makes a task urgent; see `pma stale`",
161    ),
162    (
163        "stale_after.4",
164        "age no longer makes a task urgent; see `pma stale`",
165    ),
166    (
167        "stale_after.5",
168        "age no longer makes a task urgent; see `pma stale`",
169    ),
170    ("weights.tasks", WEIGHTS),
171    ("weights.activity", WEIGHTS),
172    ("weights.ci", WEIGHTS),
173    ("weights.deps", WEIGHTS),
174    ("weights.hygiene", WEIGHTS),
175    (
176        "publish",
177        "where a run goes is the command now: `pma pr <id>` opens a pull request, `pma push <id>` pushes to the default branch",
178    ),
179];
180
181const WEIGHTS: &str =
182    "the health score is gone; `pma status` sorts by the worst state a project is in";
183
184pub fn retired(key: &str) -> Option<&'static str> {
185    // Per project as well as global: every `projects.<name>.publish` went too.
186    if key.starts_with("projects.") && key.ends_with(".publish") {
187        return retired("publish");
188    }
189    RETIRED
190        .iter()
191        .find(|(name, _)| *name == key)
192        .map(|(_, why)| *why)
193}
194
195impl Config {
196    /// A project's tier, else `default_tier`: what ranks it, and so what
197    /// routes it.
198    pub fn tier_or_default(&self, tier: Option<u8>) -> Option<u8> {
199        tier.or_else(|| self.default_tier.and_then(|t| u8::try_from(t).ok()))
200    }
201
202    pub fn keys() -> Vec<String> {
203        let mut keys: Vec<String> = ["important_threshold", "urgent_within", "quadrant_limit"]
204            .map(String::from)
205            .into();
206        let per_tier = |prefix: &'static str| (1..=5).map(move |t| format!("{prefix}.{t}"));
207        keys.extend(per_tier("tiers"));
208        keys.extend(Priority::ALL.map(|p| format!("priorities.{}", p.name())));
209        keys.extend(["ci", "deps", "activity", "hygiene"].map(|s| format!("signals.{s}")));
210        keys.push("activity.ignore".into());
211        keys.extend(per_tier("activity.horizon"));
212        keys.extend(
213            [
214                "agent",
215                "preset",
216                "default_tier",
217                "attribution",
218                "max_parallel",
219                "batch_budget",
220                "agent_budget",
221                "workflow_budget",
222                "timeout",
223                "notify",
224            ]
225            .map(String::from),
226        );
227        keys
228    }
229
230    /// Applies stored overrides to the defaults.
231    pub fn with_overrides<'a>(
232        rows: impl IntoIterator<Item = (&'a str, &'a str)>,
233    ) -> Result<Config, String> {
234        let mut cfg = Config::default();
235        for (key, value) in rows {
236            // A row the migration has not dropped yet, or one written by
237            // another binary. Retirement must not make every command fail.
238            if retired(key).is_some() {
239                continue;
240            }
241            cfg.set(key, value).map_err(|e| {
242                format!("stored setting {key} = {value:?} is invalid ({e}); run `pma config {key} --reset`")
243            })?;
244        }
245        Ok(cfg)
246    }
247
248    /// The kind of value `key` takes, so `settings.toml` stores it typed.
249    pub fn kind(key: &str) -> Option<&'static str> {
250        Some(match Config::default().slot(key)? {
251            Slot::Number(..) => "number",
252            Slot::Count(..) => "count",
253            Slot::Tier(_) => "tier",
254            Slot::List(_) => "list",
255            Slot::Switch(_) => "switch",
256            Slot::Priority(_) | Slot::Attribution(_) | Slot::OptText(_) | Slot::Text(_) => "text",
257        })
258    }
259
260    pub fn get(&self, key: &str) -> Option<String> {
261        let mut copy = self.clone();
262        Some(match copy.slot(key)? {
263            Slot::Number(v, _) => v.to_string(),
264            Slot::Count(v, _) => v.to_string(),
265            Slot::Tier(v) => v.map_or("never".into(), |n| n.to_string()),
266            Slot::Priority(p) => p.name().into(),
267            Slot::List(v) => v.join(","),
268            Slot::Attribution(a) => match a {
269                Attribution::User => "user".into(),
270                Attribution::CoAuthor => "co-author".into(),
271            },
272            Slot::OptText(v) => v.clone().unwrap_or_default(),
273            Slot::Text(v) => v.clone(),
274            Slot::Switch(v) => if *v { "on" } else { "off" }.into(),
275        })
276    }
277
278    /// Sets one value; on error, nothing changes.
279    pub fn set(&mut self, key: &str, value: &str) -> Result<(), String> {
280        let mut next = self.clone();
281        next.set_in_place(key, value)?;
282        *self = next;
283        Ok(())
284    }
285
286    fn set_in_place(&mut self, key: &str, value: &str) -> Result<(), String> {
287        let slot = self.slot(key).ok_or_else(|| match retired(key) {
288            Some(why) => format!("`{key}` was retired: {why}"),
289            None => format!("unknown setting `{key}`"),
290        })?;
291        let value = value.trim();
292        match slot {
293            Slot::Number(v, min) => {
294                *v = value
295                    .parse::<f64>()
296                    .ok()
297                    .filter(|n| n.is_finite() && *n >= min)
298                    .ok_or_else(|| format!("expected a number >= {min}"))?;
299            }
300            Slot::Count(v, min) => {
301                *v = value
302                    .parse::<i64>()
303                    .ok()
304                    .filter(|n| *n >= min)
305                    .ok_or_else(|| format!("expected a whole number >= {min}"))?;
306            }
307            Slot::Tier(v) => {
308                *v = match value {
309                    "never" => None,
310                    _ => Some(
311                        value
312                            .parse::<i64>()
313                            .ok()
314                            .filter(|n| (1..=5).contains(n))
315                            .ok_or("expected a tier from 1 to 5, or `never`")?,
316                    ),
317                };
318            }
319            Slot::Priority(p) => {
320                *p = Priority::parse(value).ok_or("expected critical, high, medium or low")?;
321            }
322            Slot::List(v) => {
323                *v = value
324                    .split(',')
325                    .map(str::trim)
326                    .filter(|s| !s.is_empty())
327                    .map(String::from)
328                    .collect();
329            }
330            Slot::Attribution(a) => {
331                *a = match value {
332                    "user" => Attribution::User,
333                    "co-author" => Attribution::CoAuthor,
334                    _ => return Err("expected user or co-author".into()),
335                }
336            }
337            Slot::OptText(v) => *v = (!value.is_empty()).then(|| value.to_string()),
338            Slot::Text(v) => {
339                if value.is_empty() {
340                    return Err("expected a name".into());
341                }
342                *v = value.to_string();
343            }
344            Slot::Switch(v) => {
345                *v = match value {
346                    "on" => true,
347                    "off" => false,
348                    _ => return Err("expected on or off".into()),
349                }
350            }
351        }
352        Ok(())
353    }
354
355    fn slot(&mut self, key: &str) -> Option<Slot<'_>> {
356        let tier = |s: &str| {
357            s.parse::<usize>()
358                .ok()
359                .filter(|t| (1..=5).contains(t))
360                .map(|t| t - 1)
361        };
362        Some(match key.split_once('.') {
363            None => match key {
364                "important_threshold" => Slot::Number(&mut self.important_threshold, 0.0),
365                "urgent_within" => Slot::Count(&mut self.urgent_within, 0),
366                "quadrant_limit" => Slot::Count(&mut self.quadrant_limit, 1),
367                "agent" => Slot::Text(&mut self.agent),
368                "preset" => Slot::OptText(&mut self.preset),
369                "default_tier" => Slot::Tier(&mut self.default_tier),
370                "attribution" => Slot::Attribution(&mut self.attribution),
371                "max_parallel" => Slot::Count(&mut self.max_parallel, 1),
372                "batch_budget" => Slot::Number(&mut self.batch_budget, 0.0),
373                "agent_budget" => Slot::Number(&mut self.agent_budget, 0.0),
374                "workflow_budget" => Slot::Number(&mut self.workflow_budget, 0.0),
375                "timeout" => Slot::Count(&mut self.timeout, 1),
376                "notify" => Slot::OptText(&mut self.notify),
377                _ => return None,
378            },
379            Some(("tiers", t)) => Slot::Number(&mut self.tiers[tier(t)?], 0.0),
380            Some(("priorities", p)) => {
381                let i = Priority::ALL.iter().position(|x| x.name() == p)?;
382                Slot::Number(&mut self.priorities[i], 0.0)
383            }
384            Some(("signals", s)) => Slot::Priority(match s {
385                "ci" => &mut self.signals.ci,
386                "deps" => &mut self.signals.deps,
387                "activity" => &mut self.signals.activity,
388                "hygiene" => &mut self.signals.hygiene,
389                _ => return None,
390            }),
391            Some(("activity", "ignore")) => Slot::List(&mut self.activity_ignore),
392            Some(("activity", rest)) => {
393                let t = rest.strip_prefix("horizon.")?;
394                Slot::Count(&mut self.activity_horizon[tier(t)?], 1)
395            }
396            Some(("projects", rest)) => {
397                let (name, field) = rest.rsplit_once('.')?;
398                if name.is_empty() || name.contains('.') {
399                    return None;
400                }
401                if !matches!(field, "verify" | "sandbox") {
402                    return None;
403                }
404                let settings = self.projects.entry(name.to_string()).or_default();
405                match field {
406                    "verify" => Slot::OptText(&mut settings.verify),
407                    _ => Slot::Switch(&mut settings.sandbox),
408                }
409            }
410            _ => return None,
411        })
412    }
413
414    pub fn tier(&self, tier: u8) -> f64 {
415        self.tiers[usize::from(tier) - 1]
416    }
417
418    pub fn priority(&self, p: Priority) -> f64 {
419        self.priorities[p as usize]
420    }
421
422    pub fn project(&self, name: &str) -> ProjectSettings {
423        self.projects.get(name).cloned().unwrap_or_default()
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn every_key_reads_and_round_trips() {
433        let keys = Config::keys();
434        assert_eq!(keys.len(), 32);
435        let defaults = Config::default();
436        let mut copy = Config::default();
437        for key in &keys {
438            let value = defaults
439                .get(key)
440                .unwrap_or_else(|| panic!("{key} has no value"));
441            copy.set(key, &value)
442                .unwrap_or_else(|e| panic!("{key} = {value}: {e}"));
443        }
444        assert_eq!(copy, defaults);
445    }
446
447    #[test]
448    fn set_changes_the_named_field() {
449        let mut cfg = Config::default();
450        cfg.set("tiers.3", "0.5").unwrap();
451        cfg.set("priorities.high", "0.7").unwrap();
452        cfg.set("default_tier", "3").unwrap();
453        cfg.set("signals.ci", "critical").unwrap();
454        cfg.set("activity.ignore", " docs/** , ,*.md").unwrap();
455        cfg.set("activity.horizon.2", "14").unwrap();
456        assert_eq!(cfg.tiers[2], 0.5);
457        assert_eq!(cfg.priority(Priority::High), 0.7);
458        assert_eq!(cfg.default_tier, Some(3));
459        assert_eq!(cfg.signals.ci, Priority::Critical);
460        assert_eq!(cfg.activity_ignore, ["docs/**", "*.md"]);
461        assert_eq!(cfg.activity_horizon[1], 14);
462
463        cfg.set("agent", "codex").unwrap();
464        // `model` is retired: a model belongs to the worker record.
465        assert!(cfg.set("model", "haiku").is_err());
466        cfg.set("attribution", "co-author").unwrap();
467        cfg.set("projects.cyllama.verify", " make check ").unwrap();
468        assert_eq!(cfg.agent, "codex");
469        assert_eq!(cfg.attribution, Attribution::CoAuthor);
470        assert_eq!(cfg.project("cyllama").verify.as_deref(), Some("make check"));
471        assert!(
472            cfg.project("cyllama").sandbox,
473            "verify is confined by default"
474        );
475        cfg.set("projects.cyllama.sandbox", "off").unwrap();
476        assert!(!cfg.project("cyllama").sandbox);
477        assert_eq!(cfg.get("projects.cyllama.sandbox").as_deref(), Some("off"));
478        // Where a run goes is the command now, so both settings are retired.
479        for key in ["publish", "projects.cyllama.publish"] {
480            let e = cfg.set(key, "pr").unwrap_err();
481            assert!(e.contains("was retired") && e.contains("pma pr"), "{e}");
482        }
483    }
484
485    /// A store that set a retired key must still open, and asking about the
486    /// name must say what replaced it.
487    #[test]
488    fn retired_keys_explain_themselves_instead_of_failing() {
489        let cfg = Config::with_overrides([
490            ("stale_after.1", "30"),
491            ("dispatch_quadrants", "q1"),
492            ("urgent_within", "3"),
493        ])
494        .expect("a retired row does not break the config");
495        assert_eq!(cfg.urgent_within, 3);
496
497        let e = Config::default()
498            .set("stale_after.1", "30")
499            .expect_err("setting one is refused");
500        assert!(e.contains("was retired") && e.contains("pma stale"), "{e}");
501        assert!(
502            Config::default()
503                .set("nonsense", "1")
504                .unwrap_err()
505                .contains("unknown setting")
506        );
507    }
508
509    #[test]
510    fn invalid_values_and_keys_are_refused() {
511        let mut cfg = Config::default();
512        for (key, value) in [
513            ("tiers.0", "1"),
514            ("tiers.6", "1"),
515            ("tiers.1", "-0.1"),
516            ("tiers.1", "NaN"),
517            ("tiers.1", "inf"),
518            ("urgent_within", "1.5"),
519            ("quadrant_limit", "0"),
520            ("default_tier", "-1"),
521            ("default_tier", "0"),
522            ("default_tier", "6"),
523            ("signals.ci", "urgent"),
524            ("weights.ci", "1"),
525            ("activity.horizon.1", "0"),
526            ("agent", ""),
527            ("attribution", "agent"),
528            ("max_parallel", "0"),
529            ("projects.a.b.verify", "x"),
530            ("projects.a.colour", "x"),
531            ("projects.a.sandbox", "no"),
532            ("nope", "1"),
533        ] {
534            assert!(cfg.set(key, value).is_err(), "{key} = {value} was accepted");
535        }
536        assert_eq!(cfg, Config::default(), "a refused set changes nothing");
537    }
538
539    #[test]
540    fn stored_overrides_apply_or_name_the_bad_row() {
541        let cfg = Config::with_overrides([("tiers.1", "0.9")]).unwrap();
542        assert_eq!(cfg.tier(1), 0.9);
543        let err = Config::with_overrides([("tiers.1", "x")]).unwrap_err();
544        assert!(err.contains("pma config tiers.1 --reset"), "{err}");
545    }
546}