use crate::context::Date;
use crate::finding::{Finding, Severity};
use crate::scope::Scope;
use headwater_yaml::{Mapping, Value};
pub const RULE: &str = "adoption.task.expired";
pub const SCOPE: Scope = Scope::taxonomy();
pub const VERSION: u32 = 1;
pub const EXPORTABLE_AS: crate::scope::ExportTargets = &[];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Pair {
pub path: String,
pub rule: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Open,
Expired,
}
#[derive(Clone, Debug)]
pub struct Task {
pub id: String,
pub statement: String,
pub owner: String,
pub until: Date,
pub pairs: Vec<Pair>,
pub state: State,
pub held: usize,
pub closed: Vec<Pair>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refused {
pub task: String,
pub why: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Unread {
pub key: String,
pub why: String,
}
#[derive(Clone, Debug, Default)]
pub struct Declared {
pub tasks: Vec<Task>,
pub refused: Vec<Refused>,
pub unread: Vec<Unread>,
}
#[derive(Clone, Debug, Default)]
pub struct Ledger {
pub tasks: Vec<Task>,
pub refused: Vec<Refused>,
pub unread: Vec<Unread>,
pub pending: Vec<Finding>,
}
pub fn read(block: &Mapping, rules: &[&'static str]) -> Declared {
let mut declared = Declared::default();
for entry in block {
if entry.key.value != "tasks" {
declared.unread.push(Unread {
key: entry.key.value.clone(),
why: unread(&entry.key.value),
});
}
}
let Some(items) = block.get("tasks").and_then(|node| node.value.as_seq()) else {
declared.refused.push(Refused {
task: "adoption".to_string(),
why: "it declares no `tasks` sequence, so it accounts for nothing".to_string(),
});
return declared;
};
for (index, item) in items.iter().enumerate() {
let name = item
.value
.as_map()
.and_then(|map| scalar(map, "id"))
.map(str::to_string)
.unwrap_or_else(|| format!("task {}", index + 1));
match task(item.value.as_map(), rules) {
Ok(read) => declared.tasks.push(read),
Err(why) => declared.refused.push(Refused { task: name, why }),
}
}
declared
}
fn unread(key: &str) -> String {
match key {
"from" | "to" => "it names a migration state. Spec 7 gives one a from-version and a \
to-version, and no verb of this engine writes either into this block"
.to_string(),
"severity" => "a pending finding keeps the severity its rule gave it, and this format \
has no place to write one"
.to_string(),
_ => "nothing in this engine reads it, so what it declares is not what this run honored"
.to_string(),
}
}
fn task(map: Option<&Mapping>, rules: &[&'static str]) -> Result<Task, String> {
let map = map.ok_or("it is not a mapping")?;
for entry in map {
let key = entry.key.value.as_str();
if !["id", "statement", "owner", "until", "pairs"].contains(&key) {
return Err(format!("it declares `{key}`, and {}", unread(key)));
}
}
let id = scalar(map, "id")
.ok_or("it names no `id`, and a task a report cannot name is one nobody can close")?;
let statement = scalar(map, "statement")
.ok_or("it states no `statement`, so nothing in it says what the debt is")?;
let owner = scalar(map, "owner").ok_or(
"it names no `owner`, which is the field spec 4 ranks this above a suppression for",
)?;
let until = scalar(map, "until")
.ok_or("it states no `until`, and debt with no expiry is a permanent exemption")?;
let until = Date::parse(until)
.ok_or_else(|| format!("`until: {until}` is not a date written `YYYY-MM-DD`"))?;
let items = map
.get("pairs")
.and_then(|node| node.value.as_seq())
.ok_or("it declares no `pairs` sequence")?;
if items.is_empty() {
return Err("it declares no pair, so it accounts for nothing".to_string());
}
let mut pairs = Vec::with_capacity(items.len());
for item in items {
let entry = item.value.as_map().ok_or("a pair is not a mapping")?;
for field in entry {
let key = field.key.value.as_str();
if !["path", "rule"].contains(&key) {
return Err(format!("a pair declares `{key}`, and {}", unread(key)));
}
}
let path = entry
.get("path")
.and_then(scalar_of)
.ok_or("a pair names no `path`")?;
let rule = entry
.get("rule")
.and_then(scalar_of)
.ok_or("a pair names no `rule`")?;
if path.contains('*') {
return Err(format!(
"the pair `{path}` is a pattern, and a payload holds one document at a time. \
A pattern would blanket a document this taxonomy has not seen yet"
));
}
if !rules.contains(&rule) {
return Err(format!("no rule of this engine is named `{rule}`"));
}
if rule == self::RULE {
return Err(format!(
"a pair cannot name `{rule}`: a task cannot hold another task's own expiry, or \
its own"
));
}
pairs.push(Pair {
path: path.to_string(),
rule: rule.to_string(),
});
}
Ok(Task {
id: id.to_string(),
statement: statement.to_string(),
owner: owner.to_string(),
until,
pairs,
state: State::Open,
held: 0,
closed: Vec::new(),
})
}
fn scalar<'a>(map: &'a Mapping, key: &str) -> Option<&'a str> {
map.get(key).and_then(scalar_of)
}
fn scalar_of(node: &headwater_yaml::Spanned<Value>) -> Option<&str> {
node.value.as_scalar().map(|scalar| scalar.text.as_str())
}
pub fn apply(findings: Vec<Finding>, declared: Declared, now: Date) -> (Vec<Finding>, Ledger) {
let Declared {
mut tasks,
refused,
unread,
} = declared;
for task in &mut tasks {
if task.until < now {
task.state = State::Expired;
}
}
let mut kept = Vec::with_capacity(findings.len());
let mut pending = Vec::new();
let mut met: Vec<Vec<bool>> = tasks
.iter()
.map(|task| vec![false; task.pairs.len()])
.collect();
for finding in findings {
let hit = tasks.iter_mut().enumerate().find_map(|(index, task)| {
if task.state == State::Expired {
return None;
}
let at = task
.pairs
.iter()
.position(|pair| pair.rule == finding.rule && pair.path == finding.path)?;
Some((index, at, task))
});
match hit {
Some((index, at, task)) => {
task.held += 1;
met[index][at] = true;
pending.push(finding);
}
None => kept.push(finding),
}
}
for (index, task) in tasks.iter_mut().enumerate() {
if task.state == State::Expired {
continue;
}
task.closed = task
.pairs
.iter()
.enumerate()
.filter(|(at, _)| !met[index][*at])
.map(|(_, pair)| pair.clone())
.collect();
}
(
kept,
Ledger {
tasks,
refused,
unread,
pending,
},
)
}
pub fn expired(declared: &Declared, source: &str, now: Date) -> Vec<Finding> {
declared
.tasks
.iter()
.filter(|task| task.until < now)
.map(|task| Finding {
rule: RULE,
severity: Severity::Error,
obligation: None,
path: source.to_string(),
line: 0,
column: 0,
message: format!(
"{} lapsed on {}, and spec 7 makes that a finding against the owner, {}: {}",
task.id, task.until, task.owner, task.statement
),
remediation: format!(
"move `until` on {} to a new date, which is the paper trail spec 7 asks a \
renewal to carry, or close every pair it names and delete the task",
task.id
),
patch: None,
})
.collect()
}
impl Ledger {
pub fn is_empty(&self) -> bool {
self.tasks.is_empty() && self.refused.is_empty() && self.unread.is_empty()
}
pub fn open(&self) -> usize {
self.tasks
.iter()
.filter(|task| task.state == State::Open)
.map(|task| task.pairs.len() - task.closed.len())
.sum()
}
pub fn held(&self) -> usize {
self.tasks.iter().map(|task| task.held).sum()
}
pub fn closed(&self) -> usize {
self.tasks
.iter()
.filter(|task| task.state == State::Open)
.map(|task| task.closed.len())
.sum()
}
pub fn by_rule(&self) -> Vec<(&str, usize)> {
let mut counts: Vec<(&str, usize)> = Vec::new();
for finding in &self.pending {
match counts.iter_mut().find(|(rule, _)| *rule == finding.rule) {
Some((_, count)) => *count += 1,
None => counts.push((finding.rule, 1)),
}
}
counts.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(right.0)));
counts
}
pub fn render(&self, mode: crate::paint::ColorMode) -> String {
use crate::paint::{paint, Role};
use std::fmt::Write;
let mut out = String::new();
if self.is_empty() {
return out;
}
out.push_str("adoption\n");
let _ = writeln!(
out,
" {} pairs open, {} closed, holding {} {}, in {} {}",
self.open(),
self.closed(),
self.held(),
verb(self.held(), "finding", "findings"),
self.tasks.len(),
verb(self.tasks.len(), "task", "tasks")
);
for task in &self.tasks {
match task.state {
State::Open => {
let plain = format!(
" {} {} open, {} closed, holding {} {}, owner {}, until {}\n",
task.id,
task.pairs.len() - task.closed.len(),
task.closed.len(),
task.held,
verb(task.held, "finding", "findings"),
task.owner,
task.until
);
let folded = crate::fill::filled(&plain, crate::fill::WIDTH);
out.push_str(&folded.replacen(
&task.id,
&paint(Role::Obligation, &task.id, mode),
1,
));
}
State::Expired => {
let plain = format!(
" {} lapsed on {}, and the {} pairs it named are reported, owner {}\n",
task.id,
task.until,
task.pairs.len(),
task.owner
);
let folded = crate::fill::filled(&plain, crate::fill::WIDTH);
out.push_str(&folded.replacen(
&task.id,
&paint(Role::Obligation, &task.id, mode),
1,
));
}
}
out.push_str(&crate::fill::filled(
&format!(" {}", task.statement),
crate::fill::WIDTH,
));
}
for refused in &self.refused {
out.push_str(&crate::fill::filled(
&format!(" {} holds nothing: {}", refused.task, refused.why),
crate::fill::WIDTH,
));
}
for unread in &self.unread {
out.push_str(&crate::fill::filled(
&format!(
" the block declares `{}`, which nothing here reads: {}",
unread.key, unread.why
),
crate::fill::WIDTH,
));
}
out
}
}
fn verb(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
match count {
1 => singular,
_ => plural,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(source: &str) -> Mapping {
headwater_yaml::load(source)
.expect("the payload loads")
.value
.as_map()
.expect("it is a mapping")
.clone()
}
fn finding(path: &str, rule: &'static str) -> Finding {
Finding {
rule,
severity: Severity::Warn,
obligation: None,
path: path.to_string(),
line: 1,
column: 1,
message: "a message".to_string(),
remediation: "a remediation".to_string(),
patch: None,
}
}
const RULES: [&str; 3] = [
"facet.required.missing",
"voice.forbidden_construction",
RULE,
];
fn one_task(pairs: &str) -> Mapping {
payload(&format!(
"\
tasks:
- id: AD-1
statement: the corpus states no summary
owner: the docs guild
until: 2027-01-01
pairs:
{pairs}
"
))
}
fn day(text: &str) -> Date {
Date::parse(text).expect("a date")
}
#[test]
fn a_task_holds_one_pair_and_not_the_document_or_the_rule_around_it() {
let block = one_task(" - {path: docs/a.md, rule: facet.required.missing}");
let declared = read(&block, &RULES);
assert!(declared.refused.is_empty(), "{:?}", declared.refused);
let findings = vec![
finding("docs/a.md", "facet.required.missing"),
finding("docs/a.md", "voice.forbidden_construction"),
finding("docs/b.md", "facet.required.missing"),
];
let (kept, ledger) = apply(findings, declared, day("2026-08-13"));
assert_eq!(ledger.pending.len(), 1);
assert_eq!(ledger.pending[0].path, "docs/a.md");
assert_eq!(ledger.pending[0].rule, "facet.required.missing");
assert_eq!(kept.len(), 2, "the two neighbors stayed loud");
assert_eq!(ledger.open(), 1);
}
#[test]
fn a_pair_that_stopped_failing_is_reported_closed() {
let block = one_task(
" - {path: docs/a.md, rule: facet.required.missing}\n\
\x20 - {path: docs/b.md, rule: facet.required.missing}",
);
let (kept, ledger) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
read(&block, &RULES),
day("2026-08-13"),
);
assert!(kept.is_empty());
assert_eq!(ledger.open(), 1);
assert_eq!(ledger.closed(), 1);
assert_eq!(ledger.tasks[0].closed[0].path, "docs/b.md");
}
#[test]
fn an_expired_task_holds_nothing_and_the_findings_come_back() {
let block = one_task(" - {path: docs/a.md, rule: facet.required.missing}");
let (kept, ledger) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
read(&block, &RULES),
day("2027-01-02"),
);
assert_eq!(kept.len(), 1, "the finding is reported");
assert!(ledger.pending.is_empty());
assert_eq!(ledger.tasks[0].state, State::Expired);
assert_eq!(ledger.open(), 0);
let (kept, _) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
read(&block, &RULES),
day("2027-01-01"),
);
assert!(kept.is_empty(), "`until` is inclusive");
}
#[test]
fn a_pattern_is_refused_because_it_would_blanket_a_document_nothing_has_seen() {
let block = one_task(" - {path: docs/*.md, rule: facet.required.missing}");
let declared = read(&block, &RULES);
assert!(declared.tasks.is_empty());
assert_eq!(declared.refused.len(), 1);
assert!(
declared.refused[0].why.contains("one document at a time"),
"{:?}",
declared.refused
);
}
#[test]
fn a_task_with_no_owner_is_refused() {
let block = payload(
"\
tasks:
- id: AD-1
statement: the corpus states no summary
until: 2027-01-01
pairs:
- {path: docs/a.md, rule: facet.required.missing}
",
);
let declared = read(&block, &RULES);
assert!(declared.tasks.is_empty());
assert!(
declared.refused[0].why.contains("`owner`"),
"{:?}",
declared.refused
);
let (kept, ledger) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
declared,
day("2026-08-13"),
);
assert_eq!(kept.len(), 1);
assert_eq!(ledger.refused.len(), 1);
}
#[test]
fn a_task_with_no_expiry_or_an_unreadable_one_is_refused() {
for line in ["", " until: soon\n"] {
let block = payload(&format!(
"\
tasks:
- id: AD-1
statement: a statement
owner: an owner
{line} pairs:
- {{path: docs/a.md, rule: facet.required.missing}}
"
));
let declared = read(&block, &RULES);
assert!(declared.tasks.is_empty(), "accepted `{line}`");
assert_eq!(declared.refused.len(), 1);
}
}
#[test]
fn a_pair_naming_a_rule_this_engine_does_not_carry_is_refused() {
let block = one_task(" - {path: docs/a.md, rule: voice.no_such_rule}");
let declared = read(&block, &RULES);
assert!(declared.tasks.is_empty());
assert!(
declared.refused[0].why.contains("voice.no_such_rule"),
"{:?}",
declared.refused
);
}
#[test]
fn a_block_level_key_this_engine_does_not_read_is_named_and_holds_nothing() {
let block = payload(
"\
from: 99.0.0
severity: quiet
tasks:
- id: AD-1
statement: a statement
owner: an owner
until: 2027-01-01
pairs:
- {path: docs/a.md, rule: facet.required.missing}
",
);
let declared = read(&block, &RULES);
assert!(declared.refused.is_empty(), "{:?}", declared.refused);
assert_eq!(declared.tasks.len(), 1, "the task beside it is still read");
assert_eq!(declared.unread.len(), 2);
assert_eq!(declared.unread[0].key, "from");
assert!(
declared.unread[0].why.contains("migration state"),
"{:?}",
declared.unread
);
assert_eq!(declared.unread[1].key, "severity");
assert!(
declared.unread[1].why.contains("no place to write one"),
"{:?}",
declared.unread
);
let (kept, ledger) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
declared,
day("2026-08-13"),
);
assert!(kept.is_empty());
assert_eq!(ledger.unread.len(), 2);
assert!(
ledger
.render(crate::paint::ColorMode::Plain)
.contains("`from`"),
"{}",
ledger.render(crate::paint::ColorMode::Plain)
);
}
#[test]
fn a_task_level_key_this_engine_does_not_read_refuses_the_task() {
let block = payload(
"\
tasks:
- id: AD-1
statement: a statement
owner: an owner
until: 2027-01-01
to: 4.0.0
pairs:
- {path: docs/a.md, rule: facet.required.missing}
",
);
let declared = read(&block, &RULES);
assert!(declared.unread.is_empty(), "{:?}", declared.unread);
assert!(declared.tasks.is_empty());
assert_eq!(declared.refused.len(), 1);
assert_eq!(declared.refused[0].task, "AD-1");
assert!(
declared.refused[0].why.contains("`to`"),
"{:?}",
declared.refused
);
let (kept, _) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
declared,
day("2026-08-13"),
);
assert_eq!(kept.len(), 1);
}
#[test]
fn a_pair_key_this_engine_does_not_read_refuses_the_task() {
let block = one_task(" - {path: docs/a.md, rule: facet.required.missing, note: soon}");
let declared = read(&block, &RULES);
assert!(declared.tasks.is_empty());
assert!(
declared.refused[0].why.contains("a pair declares `note`"),
"{:?}",
declared.refused
);
}
#[test]
fn a_block_with_no_tasks_is_refused_rather_than_read_as_no_debt() {
let declared = read(&payload("to: acme/x\n"), &RULES);
assert!(declared.tasks.is_empty());
assert_eq!(declared.refused.len(), 1);
assert_eq!(declared.unread.len(), 1);
assert_eq!(declared.unread[0].key, "to");
}
#[test]
fn a_task_with_an_empty_pair_list_is_refused() {
let block = payload(
"\
tasks:
- id: AD-1
statement: a statement
owner: an owner
until: 2027-01-01
pairs: []
",
);
let declared = read(&block, &RULES);
assert!(declared.tasks.is_empty());
assert!(
declared.refused[0].why.contains("accounts for nothing"),
"{:?}",
declared.refused
);
}
#[test]
fn a_pair_named_by_two_tasks_is_held_once() {
let block = payload(
"\
tasks:
- id: AD-1
statement: first
owner: an owner
until: 2027-01-01
pairs:
- {path: docs/a.md, rule: facet.required.missing}
- id: AD-2
statement: second
owner: another owner
until: 2027-01-01
pairs:
- {path: docs/a.md, rule: facet.required.missing}
",
);
let (kept, ledger) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
read(&block, &RULES),
day("2026-08-13"),
);
assert!(kept.is_empty());
assert_eq!(ledger.pending.len(), 1);
assert_eq!(ledger.open(), 1);
assert_eq!(ledger.tasks[0].held, 1);
assert_eq!(ledger.tasks[1].held, 0);
assert_eq!(ledger.tasks[1].closed.len(), 1);
}
#[test]
fn an_expired_task_raises_a_finding_naming_it_and_its_owner() {
let block = one_task(" - {path: docs/a.md, rule: facet.required.missing}");
let declared = read(&block, &RULES);
let findings = expired(&declared, "SOURCE", day("2027-01-02"));
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, RULE);
assert_eq!(findings[0].severity, Severity::Error);
assert_eq!(findings[0].path, "SOURCE");
assert!(findings[0].message.contains("AD-1"), "{:?}", findings[0]);
assert!(
findings[0].message.contains("the docs guild"),
"{:?}",
findings[0]
);
}
#[test]
fn a_task_not_yet_expired_raises_no_finding() {
let block = one_task(" - {path: docs/a.md, rule: facet.required.missing}");
let declared = read(&block, &RULES);
assert!(expired(&declared, "SOURCE", day("2027-01-01")).is_empty());
assert!(expired(&declared, "SOURCE", day("2026-12-31")).is_empty());
}
#[test]
fn no_task_at_all_raises_no_finding() {
let declared = Declared::default();
assert!(expired(&declared, "SOURCE", day("2099-01-01")).is_empty());
}
#[test]
fn a_pair_naming_this_rule_is_refused() {
let block = one_task(" - {path: SOURCE, rule: adoption.task.expired}");
let declared = read(&block, &RULES);
assert!(declared.tasks.is_empty());
assert_eq!(declared.refused.len(), 1);
assert!(
declared.refused[0].why.contains("adoption.task.expired"),
"{:?}",
declared.refused
);
}
#[test]
fn every_task_identifier_carries_color_only_under_ansi() {
let block = one_task(" - {path: docs/a.md, rule: facet.required.missing}");
let declared = read(&block, &RULES);
let (_, ledger) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
declared,
day("2026-08-13"),
);
let plain = ledger.render(crate::paint::ColorMode::Plain);
assert!(!plain.contains('\x1b'), "{plain:?}");
assert!(plain.contains("AD-1"), "{plain:?}");
let ansi = ledger.render(crate::paint::ColorMode::Ansi);
assert!(ansi.contains('\x1b'), "{ansi:?}");
assert!(ansi.contains("AD-1"), "{ansi:?}");
}
#[test]
fn a_folded_ledger_breaks_where_a_plain_one_does() {
let block = one_task(" - {path: docs/a.md, rule: facet.required.missing}");
let declared = read(&block, &RULES);
let (_, ledger) = apply(
vec![finding("docs/a.md", "facet.required.missing")],
declared,
day("2026-08-13"),
);
let plain = ledger.render(crate::paint::ColorMode::Plain);
let ansi = ledger.render(crate::paint::ColorMode::Ansi);
let stripped = strip_ansi(&ansi);
assert_eq!(stripped, plain, "stripped ANSI output does not match plain");
}
#[allow(clippy::while_let_on_iterator)]
fn strip_ansi(text: &str) -> String {
let mut result = String::new();
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
while let Some(c) = chars.next() {
if c == 'm' {
break;
}
}
} else {
result.push(ch);
}
}
result
}
}