1use std::collections::BTreeMap;
6
7use crate::todo::Priority;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct Config {
11 pub important_threshold: f64,
12 pub urgent_within: i64,
14 pub quadrant_limit: i64,
15 pub tiers: [f64; 5],
17 pub priorities: [f64; 4],
19 pub signals: SignalPriorities,
21 pub activity_ignore: Vec<String>,
23 pub activity_horizon: [i64; 5],
25 pub agent: String,
27 pub preset: Option<String>,
30 pub default_tier: Option<i64>,
33 pub attribution: Attribution,
34 pub max_parallel: i64,
36 pub batch_budget: f64,
38 pub agent_budget: f64,
40 pub workflow_budget: f64,
43 pub timeout: i64,
45 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 pub verify: Option<String>,
61 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 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
115enum Slot<'a> {
117 Number(&'a mut f64, f64),
119 Count(&'a mut i64, i64),
120 Tier(&'a mut Option<i64>),
122 Priority(&'a mut Priority),
123 List(&'a mut Vec<String>),
124 Attribution(&'a mut Attribution),
125 OptText(&'a mut Option<String>),
127 Text(&'a mut String),
129 Switch(&'a mut bool),
131}
132
133pub 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 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 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 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 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 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 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 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 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 #[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}