use crate::todo::Priority;
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub important_threshold: f64,
pub urgent_within: i64,
pub quadrant_limit: i64,
pub tiers: [f64; 5],
pub priorities: [f64; 4],
pub weights: Weights,
pub stale_after: [Option<i64>; 5],
pub signals: SignalPriorities,
pub activity_ignore: Vec<String>,
pub activity_horizon: [i64; 5],
}
#[derive(Debug, Clone, PartialEq)]
pub struct Weights {
pub tasks: f64,
pub activity: f64,
pub ci: f64,
pub deps: f64,
pub hygiene: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SignalPriorities {
pub ci: Priority,
pub deps: Priority,
pub activity: Priority,
pub hygiene: Priority,
}
impl Default for Config {
fn default() -> Self {
Config {
important_threshold: 0.4,
urgent_within: 7,
quadrant_limit: 10,
tiers: [1.0, 0.8, 0.6, 0.4, 0.2],
priorities: [1.0, 0.5, 0.2, 0.05],
weights: Weights {
tasks: 5.0,
activity: 1.0,
ci: 3.0,
deps: 1.0,
hygiene: 2.0,
},
stale_after: [Some(30), Some(60), Some(90), None, None],
signals: SignalPriorities {
ci: Priority::High,
deps: Priority::Medium,
activity: Priority::Low,
hygiene: Priority::Medium,
},
activity_ignore: vec![".github/**".into(), "*.lock".into(), "TODO.md".into()],
activity_horizon: [30, 60, 120, 240, 365],
}
}
}
enum Slot<'a> {
Number(&'a mut f64, f64),
Count(&'a mut i64, i64),
Never(&'a mut Option<i64>),
Priority(&'a mut Priority),
List(&'a mut Vec<String>),
}
impl Config {
pub fn keys() -> Vec<String> {
let mut keys: Vec<String> = ["important_threshold", "urgent_within", "quadrant_limit"]
.map(String::from)
.into();
let per_tier = |prefix: &'static str| (1..=5).map(move |t| format!("{prefix}.{t}"));
keys.extend(per_tier("tiers"));
keys.extend(Priority::ALL.map(|p| format!("priorities.{}", p.name())));
keys.extend(["tasks", "activity", "ci", "deps", "hygiene"].map(|w| format!("weights.{w}")));
keys.extend(per_tier("stale_after"));
keys.extend(["ci", "deps", "activity", "hygiene"].map(|s| format!("signals.{s}")));
keys.push("activity.ignore".into());
keys.extend(per_tier("activity.horizon"));
keys
}
pub fn with_overrides<'a>(
rows: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Result<Config, String> {
let mut cfg = Config::default();
for (key, value) in rows {
cfg.set(key, value).map_err(|e| {
format!("stored setting {key} = {value:?} is invalid ({e}); run `pma config {key} --reset`")
})?;
}
Ok(cfg)
}
pub fn get(&self, key: &str) -> Option<String> {
let mut copy = self.clone();
Some(match copy.slot(key)? {
Slot::Number(v, _) => v.to_string(),
Slot::Count(v, _) => v.to_string(),
Slot::Never(v) => v.map_or("never".into(), |n| n.to_string()),
Slot::Priority(p) => p.name().into(),
Slot::List(v) => v.join(","),
})
}
pub fn set(&mut self, key: &str, value: &str) -> Result<(), String> {
let slot = self
.slot(key)
.ok_or_else(|| format!("unknown setting `{key}`"))?;
let value = value.trim();
match slot {
Slot::Number(v, min) => {
*v = value
.parse::<f64>()
.ok()
.filter(|n| n.is_finite() && *n >= min)
.ok_or_else(|| format!("expected a number >= {min}"))?;
}
Slot::Count(v, min) => {
*v = value
.parse::<i64>()
.ok()
.filter(|n| *n >= min)
.ok_or_else(|| format!("expected a whole number >= {min}"))?;
}
Slot::Never(v) => {
*v = match value {
"never" => None,
_ => Some(
value
.parse::<i64>()
.ok()
.filter(|n| *n >= 0)
.ok_or("expected a number of days or `never`")?,
),
};
}
Slot::Priority(p) => {
*p = Priority::parse(value).ok_or("expected critical, high, medium or low")?;
}
Slot::List(v) => {
*v = value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
}
Ok(())
}
fn slot(&mut self, key: &str) -> Option<Slot<'_>> {
let tier = |s: &str| {
s.parse::<usize>()
.ok()
.filter(|t| (1..=5).contains(t))
.map(|t| t - 1)
};
Some(match key.split_once('.') {
None => match key {
"important_threshold" => Slot::Number(&mut self.important_threshold, 0.0),
"urgent_within" => Slot::Count(&mut self.urgent_within, 0),
"quadrant_limit" => Slot::Count(&mut self.quadrant_limit, 1),
_ => return None,
},
Some(("tiers", t)) => Slot::Number(&mut self.tiers[tier(t)?], 0.0),
Some(("priorities", p)) => {
let i = Priority::ALL.iter().position(|x| x.name() == p)?;
Slot::Number(&mut self.priorities[i], 0.0)
}
Some(("weights", w)) => Slot::Number(
match w {
"tasks" => &mut self.weights.tasks,
"activity" => &mut self.weights.activity,
"ci" => &mut self.weights.ci,
"deps" => &mut self.weights.deps,
"hygiene" => &mut self.weights.hygiene,
_ => return None,
},
0.0,
),
Some(("stale_after", t)) => Slot::Never(&mut self.stale_after[tier(t)?]),
Some(("signals", s)) => Slot::Priority(match s {
"ci" => &mut self.signals.ci,
"deps" => &mut self.signals.deps,
"activity" => &mut self.signals.activity,
"hygiene" => &mut self.signals.hygiene,
_ => return None,
}),
Some(("activity", "ignore")) => Slot::List(&mut self.activity_ignore),
Some(("activity", rest)) => {
let t = rest.strip_prefix("horizon.")?;
Slot::Count(&mut self.activity_horizon[tier(t)?], 1)
}
_ => return None,
})
}
pub fn tier(&self, tier: u8) -> f64 {
self.tiers[usize::from(tier) - 1]
}
pub fn priority(&self, p: Priority) -> f64 {
self.priorities[p as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_key_reads_and_round_trips() {
let keys = Config::keys();
assert_eq!(keys.len(), 32);
let defaults = Config::default();
let mut copy = Config::default();
for key in &keys {
let value = defaults
.get(key)
.unwrap_or_else(|| panic!("{key} has no value"));
copy.set(key, &value)
.unwrap_or_else(|e| panic!("{key} = {value}: {e}"));
}
assert_eq!(copy, defaults);
}
#[test]
fn set_changes_the_named_field() {
let mut cfg = Config::default();
cfg.set("tiers.3", "0.5").unwrap();
cfg.set("priorities.high", "0.7").unwrap();
cfg.set("weights.ci", "10").unwrap();
cfg.set("stale_after.1", "never").unwrap();
cfg.set("stale_after.5", "400").unwrap();
cfg.set("signals.ci", "critical").unwrap();
cfg.set("activity.ignore", " docs/** , ,*.md").unwrap();
cfg.set("activity.horizon.2", "14").unwrap();
assert_eq!(cfg.tiers[2], 0.5);
assert_eq!(cfg.priority(Priority::High), 0.7);
assert_eq!(cfg.weights.ci, 10.0);
assert_eq!(cfg.stale_after[0], None);
assert_eq!(cfg.stale_after[4], Some(400));
assert_eq!(cfg.signals.ci, Priority::Critical);
assert_eq!(cfg.activity_ignore, ["docs/**", "*.md"]);
assert_eq!(cfg.activity_horizon[1], 14);
}
#[test]
fn invalid_values_and_keys_are_refused() {
let mut cfg = Config::default();
for (key, value) in [
("tiers.0", "1"),
("tiers.6", "1"),
("tiers.1", "-0.1"),
("tiers.1", "NaN"),
("tiers.1", "inf"),
("urgent_within", "1.5"),
("quadrant_limit", "0"),
("stale_after.2", "-1"),
("signals.ci", "urgent"),
("weights.nope", "1"),
("activity.horizon.1", "0"),
("nope", "1"),
] {
assert!(cfg.set(key, value).is_err(), "{key} = {value} was accepted");
}
assert_eq!(cfg, Config::default(), "a refused set changes nothing");
}
#[test]
fn stored_overrides_apply_or_name_the_bad_row() {
let cfg = Config::with_overrides([("tiers.1", "0.9")]).unwrap();
assert_eq!(cfg.tier(1), 0.9);
let err = Config::with_overrides([("tiers.1", "x")]).unwrap_err();
assert!(err.contains("pma config tiers.1 --reset"), "{err}");
}
}