use anyhow::{anyhow, Context, Result};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub fn registry_path(project_root: &Path) -> PathBuf {
project_root.join(".straymark").join("follow-ups-backlog.md")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FuStatus {
Open,
InProgress,
SuspectedClosed,
Closed,
Superseded,
Promoted,
Unknown,
}
impl FuStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::InProgress => "in-progress",
Self::SuspectedClosed => "suspected-closed",
Self::Closed => "closed",
Self::Superseded => "superseded",
Self::Promoted => "promoted",
Self::Unknown => "unknown",
}
}
pub fn from_str_loose(s: &str) -> Self {
let exact = |v: &str| match v {
"open" => Self::Open,
"in-progress" | "in progress" => Self::InProgress,
"suspected-closed" | "suspected closed" => Self::SuspectedClosed,
"closed" => Self::Closed,
"superseded" => Self::Superseded,
"promoted" => Self::Promoted,
_ => Self::Unknown,
};
let lower = s.trim().to_lowercase();
let parsed = exact(&lower);
if parsed != Self::Unknown {
return parsed;
}
match lower.split_whitespace().next() {
Some(first) => exact(first),
None => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Normal,
Blocking,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Normal => "normal",
Self::Blocking => "blocking",
}
}
pub fn from_str_loose(s: &str) -> Option<Self> {
let exact = |v: &str| match v {
"normal" => Some(Self::Normal),
"blocking" | "prod-blocker" => Some(Self::Blocking),
_ => None,
};
let lower = s.trim().to_lowercase();
exact(&lower).or_else(|| lower.split_whitespace().next().and_then(exact))
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub fu_id: String,
pub fu_number: u32,
pub description: String,
pub bucket: String,
pub origin: Option<String>,
pub origin_class: Option<String>,
pub source_hash: Option<String>,
pub status: FuStatus,
pub status_raw: Option<String>,
pub severity: Option<Severity>,
pub trigger: Option<String>,
pub destination: Option<String>,
pub cost: Option<String>,
pub labels: Vec<String>,
pub notes: Option<String>,
pub promoted_to: Option<String>,
pub span_start: usize,
pub span_end: usize,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct Section {
pub name: String,
pub is_bucket: bool,
pub start: usize,
pub end: usize,
pub entries: Vec<Entry>,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[allow(dead_code)]
pub struct RegistryFrontmatter {
#[serde(default)]
pub schema_version: Option<String>,
#[serde(default)]
pub last_scan: Option<String>,
#[serde(default)]
pub last_scan_range: Option<String>,
#[serde(default)]
pub buckets: Vec<String>,
#[serde(default)]
pub fully_extracted_ailogs: Vec<String>,
#[serde(default)]
pub total_open: Option<u32>,
#[serde(default)]
pub total_promoted: Option<u32>,
#[serde(default)]
pub total_closed_in_session: Option<u32>,
#[serde(default)]
pub total_phase_blocked: Option<u32>,
#[serde(default)]
pub total_suspected_closed: Option<u32>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Registry {
pub path: PathBuf,
pub frontmatter: RegistryFrontmatter,
pub frontmatter_raw: String,
pub body: String,
pub sections: Vec<Section>,
pub warnings: Vec<String>,
}
impl Registry {
pub fn entries(&self) -> impl Iterator<Item = &Entry> {
self.sections.iter().flat_map(|s| s.entries.iter())
}
pub fn is_v0(&self) -> bool {
!matches!(self.frontmatter.schema_version.as_deref(), Some("v1"))
}
}
pub fn parse_registry(path: &Path) -> Result<Registry> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read registry at {}", path.display()))?;
parse_registry_str(path, &content)
}
pub fn parse_registry_str(path: &Path, content: &str) -> Result<Registry> {
let (fm_raw, body) = crate::utils::split_frontmatter(content).ok_or_else(|| {
anyhow!(
"Registry at {} has no YAML frontmatter (expected --- delimiters at top of file).\n \
hint: copy `.straymark/templates/follow-ups-backlog.md` to start a registry.",
path.display()
)
})?;
let frontmatter: RegistryFrontmatter = serde_yaml::from_str(fm_raw).unwrap_or_default();
let mut warnings = Vec::new();
let sections = parse_sections(body, &mut warnings);
Ok(Registry {
path: path.to_path_buf(),
frontmatter,
frontmatter_raw: fm_raw.to_string(),
body: body.to_string(),
sections,
warnings,
})
}
fn parse_sections(body: &str, warnings: &mut Vec<String>) -> Vec<Section> {
let mut heads: Vec<(usize, String)> = Vec::new();
let mut offset = 0usize;
for line in body.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\n', '\r']);
if let Some(rest) = trimmed.strip_prefix("## ") {
if !trimmed.starts_with("### ") {
heads.push((offset, rest.trim().to_string()));
}
}
offset += line.len();
}
let mut sections = Vec::with_capacity(heads.len());
for (i, (start, heading)) in heads.iter().enumerate() {
let end = heads.get(i + 1).map(|(o, _)| *o).unwrap_or(body.len());
let (name, is_bucket) = match heading.strip_prefix("Bucket:") {
Some(rest) => {
let name = rest
.trim()
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
(name, true)
}
None => (heading.clone(), false),
};
let entries = parse_entries(body, *start, end, &name, warnings);
sections.push(Section {
name,
is_bucket,
start: *start,
end,
entries,
});
}
sections
}
fn parse_entries(
body: &str,
start: usize,
end: usize,
bucket: &str,
warnings: &mut Vec<String>,
) -> Vec<Entry> {
let section = &body[start..end];
let mut heads: Vec<(usize, String)> = Vec::new();
let mut offset = 0usize;
for line in section.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.starts_with("### ") {
heads.push((offset, trimmed.to_string()));
}
offset += line.len();
}
let mut entries = Vec::new();
for (i, (rel_start, heading)) in heads.iter().enumerate() {
let rel_end = heads.get(i + 1).map(|(o, _)| *o).unwrap_or(section.len());
let abs_start = start + rel_start;
let abs_end = start + rel_end;
let Some((fu_id, fu_number, description)) = parse_entry_heading(heading) else {
if heading.starts_with("### FU-") {
warnings.push(format!(
"Malformed entry heading (expected `### FU-NNN — description`): {}",
heading
));
}
continue;
};
let block = §ion[*rel_start..rel_end];
let mut entry = Entry {
fu_id,
fu_number,
description,
bucket: bucket.to_string(),
origin: None,
origin_class: None,
source_hash: None,
status: FuStatus::Unknown,
status_raw: None,
severity: None,
trigger: None,
destination: None,
cost: None,
labels: Vec::new(),
notes: None,
promoted_to: None,
span_start: abs_start,
span_end: abs_end,
};
for line in block.lines() {
let Some((field, value)) = parse_field_line(line) else {
continue;
};
let value = value.trim();
match field.to_lowercase().as_str() {
"origin" => entry.origin = some_nonempty(value),
"origin-class" | "origin class" | "origin_class" => {
entry.origin_class = some_nonempty(value)
}
"source-hash" | "source hash" | "source_hash" => {
entry.source_hash = some_nonempty(value)
}
"status" => {
entry.status_raw = some_nonempty(value);
entry.status = FuStatus::from_str_loose(value);
}
"severity" => entry.severity = Severity::from_str_loose(value),
"trigger" => entry.trigger = some_nonempty(value),
"destination" => entry.destination = some_nonempty(value),
"cost" => entry.cost = some_nonempty(value),
"labels" | "tags" => {
entry.labels = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
"notes" => entry.notes = some_nonempty(value),
"promoted to" | "promoted-to" | "promoted_to" => {
entry.promoted_to = some_nonempty(value)
}
_ => {} }
}
entries.push(entry);
}
entries
}
fn some_nonempty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn parse_entry_heading(heading: &str) -> Option<(String, u32, String)> {
let rest = heading.strip_prefix("### ")?.trim();
let after_fu = rest.strip_prefix("FU-")?;
let digits: String = after_fu.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
let number: u32 = digits.parse().ok()?;
let fu_id = format!("FU-{}", digits);
let after_digits = &after_fu[digits.len()..];
let description = after_digits
.trim_start_matches([' ', '\t'])
.trim_start_matches(['—', '–', '-', ':'])
.trim()
.to_string();
Some((fu_id, number, description))
}
fn parse_field_line(line: &str) -> Option<(&str, &str)> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("- **")?;
let close = rest.find("**")?;
let field = &rest[..close];
let after = rest[close + 2..].trim_start();
let value = after.strip_prefix(':')?;
Some((field, value))
}
pub fn find_entry<'a>(registry: &'a Registry, id_input: &str) -> Option<&'a Entry> {
let trimmed = id_input.trim();
if trimmed.is_empty() {
return None;
}
if let Some(e) = registry.entries().find(|e| e.fu_id == trimmed) {
return Some(e);
}
let digits = trimmed.strip_prefix("FU-").unwrap_or(trimmed);
if let Ok(n) = digits.parse::<u32>() {
return registry.entries().find(|e| e.fu_number == n);
}
None
}
pub fn next_fu_number(registry: &Registry) -> u32 {
registry
.entries()
.map(|e| e.fu_number)
.max()
.map(|n| n + 1)
.unwrap_or(1)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Counters {
pub open: u32,
pub in_progress: u32,
pub suspected_closed: u32,
pub closed_cumulative: u32,
pub promoted: u32,
pub phase_blocked_open: u32,
pub blocking_open: u32,
pub total: u32,
}
pub fn compute_counters(registry: &Registry) -> Counters {
let mut c = Counters::default();
for e in registry.entries() {
c.total += 1;
match e.status {
FuStatus::Open => c.open += 1,
FuStatus::InProgress => c.in_progress += 1,
FuStatus::SuspectedClosed => c.suspected_closed += 1,
FuStatus::Closed | FuStatus::Superseded => c.closed_cumulative += 1,
FuStatus::Promoted => c.promoted += 1,
FuStatus::Unknown => {}
}
if e.status == FuStatus::Open && e.bucket == "phase-blocked" {
c.phase_blocked_open += 1;
}
if matches!(e.status, FuStatus::Open | FuStatus::InProgress)
&& e.severity == Some(Severity::Blocking)
{
c.blocking_open += 1;
}
}
c
}
pub fn fm_set_scalar(fm: &str, key: &str, value: &str) -> String {
let prefix = format!("{}:", key);
let mut out: Vec<String> = Vec::new();
let mut replaced = false;
for line in fm.lines() {
if !replaced && line.starts_with(&prefix) {
out.push(format!("{} {}", prefix, value));
replaced = true;
} else {
out.push(line.to_string());
}
}
if !replaced {
out.push(format!("{} {}", prefix, value));
}
out.join("\n")
}
pub fn fm_append_list_items(fm: &str, key: &str, items: &[String]) -> String {
if items.is_empty() {
return fm.to_string();
}
let prefix = format!("{}:", key);
let lines: Vec<&str> = fm.lines().collect();
let Some(key_idx) = lines.iter().position(|l| l.starts_with(&prefix)) else {
let mut out = fm.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&prefix);
out.push('\n');
for item in items {
out.push_str(&format!(" - {}\n", item));
}
return out.trim_end_matches('\n').to_string();
};
let mut out: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
let key_line = lines[key_idx];
let after_colon = key_line[prefix.len()..].trim();
if after_colon == "[]" {
out[key_idx] = prefix.clone();
let mut insert_at = key_idx + 1;
for item in items {
out.insert(insert_at, format!(" - {}", item));
insert_at += 1;
}
return out.join("\n");
}
let mut indent = " ".to_string();
let mut last_item = key_idx;
for (i, line) in lines.iter().enumerate().skip(key_idx + 1) {
let t = line.trim_start();
if t.starts_with("- ") && line.starts_with(' ') {
indent = line[..line.len() - t.len()].to_string();
last_item = i;
} else if t.starts_with('#') && last_item > key_idx {
continue;
} else {
break;
}
}
let mut insert_at = last_item + 1;
for item in items {
out.insert(insert_at, format!("{}- {}", indent, item));
insert_at += 1;
}
out.join("\n")
}
pub fn fm_apply_counters_and_v1(fm: &str, counters: &Counters) -> String {
let mut out = fm_set_scalar(fm, "schema_version", "v1");
out = fm_set_scalar(&out, "total_open", &counters.open.to_string());
out = fm_set_scalar(&out, "total_promoted", &counters.promoted.to_string());
out = fm_set_scalar(
&out,
"total_closed_in_session",
&counters.closed_cumulative.to_string(),
);
out = fm_set_scalar(
&out,
"total_phase_blocked",
&counters.phase_blocked_open.to_string(),
);
out = fm_set_scalar(
&out,
"total_suspected_closed",
&counters.suspected_closed.to_string(),
);
out
}
pub fn assemble(fm: &str, body: &str) -> String {
format!("---\n{}\n---\n{}", fm.trim_end_matches('\n'), body)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedFu {
pub description: String,
pub origin_section: String,
pub suspected_closed: bool,
}
pub fn extract_followups_from_ailog(content: &str) -> Vec<ExtractedFu> {
let mut out = Vec::new();
if let Some(section) = extract_section(content, |h| {
h.eq_ignore_ascii_case("Follow-ups") || h.eq_ignore_ascii_case("Follow-Ups")
}) {
let mut current: Option<String> = None;
for line in section.lines() {
if let Some(rest) = line.strip_prefix("- ") {
if let Some(buf) = current.take() {
push_extracted(&mut out, &buf, "§Follow-ups");
}
current = Some(rest.to_string());
} else if let Some(buf) = current.as_mut() {
if line.starts_with(" ") || line.trim().is_empty() {
buf.push('\n');
buf.push_str(line.trim_start());
} else {
let done = current.take().unwrap();
push_extracted(&mut out, &done, "§Follow-ups");
}
}
}
if let Some(buf) = current.take() {
push_extracted(&mut out, &buf, "§Follow-ups");
}
}
for line in content.lines() {
if line.contains("(new, not in Charter)") {
let cleaned = line
.trim_start()
.trim_start_matches("- ")
.replace("**", "")
.trim()
.to_string();
if cleaned.is_empty() {
continue;
}
let rn = extract_rn_token(&cleaned);
let origin = match rn {
Some(t) => format!("§{} (new, not in Charter)", t),
None => "§Risk (new, not in Charter)".to_string(),
};
let suspected = has_closure_marker(line);
out.push(ExtractedFu {
description: first_line(&cleaned),
origin_section: origin,
suspected_closed: suspected,
});
}
}
out
}
fn push_extracted(out: &mut Vec<ExtractedFu>, bullet: &str, origin: &str) {
let desc = first_line(bullet);
if desc.is_empty() {
return;
}
out.push(ExtractedFu {
description: desc,
origin_section: origin.to_string(),
suspected_closed: has_closure_marker(bullet),
});
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").trim().to_string()
}
fn extract_rn_token(line: &str) -> Option<String> {
for word in line.split(|c: char| !c.is_ascii_alphanumeric()) {
if let Some(rest) = word.strip_prefix('R') {
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
return Some(word.to_string());
}
}
}
None
}
fn extract_section(content: &str, pred: impl Fn(&str) -> bool) -> Option<String> {
let mut buf = String::new();
let mut in_section = false;
for line in content.lines() {
if let Some(h) = line.strip_prefix("## ") {
if in_section {
break;
}
if pred(h.trim()) {
in_section = true;
continue;
}
}
if in_section {
buf.push_str(line);
buf.push('\n');
}
}
if buf.trim().is_empty() {
None
} else {
Some(buf)
}
}
const CLOSURE_VERBS: [&str; 6] = [
"updated",
"corrected",
"remediated",
"resolved",
"fixed",
"closed",
];
pub fn has_closure_marker(text: &str) -> bool {
let lower = text.to_lowercase();
if lower.contains("closed in-charter")
|| lower.contains("closed in charter")
|| lower.contains("resolved in-charter")
|| lower.contains("resolved in charter")
{
return true;
}
if let Some(idx) = lower.find("fixed in batch ") {
let rest = &lower[idx + "fixed in batch ".len()..];
if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return true;
}
}
for ctx in ["in this pr", "in this commit"] {
if let Some(ctx_idx) = lower.rfind(ctx) {
let before = &lower[..ctx_idx];
if CLOSURE_VERBS.iter().any(|v| before.contains(v)) {
return true;
}
}
}
let mut chars = text.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == '`' {
let rest = &text[i + 1..];
if let Some(close) = rest.find('`') {
let inner = &rest[..close];
let len = inner.chars().count();
if (7..=40).contains(&len)
&& inner.chars().all(|c| c.is_ascii_hexdigit())
&& inner.chars().any(|c| c.is_ascii_digit())
{
return true;
}
}
}
}
false
}
pub fn ailog_id_from_path(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
if !stem.starts_with("AILOG-") {
return None;
}
let id: String = stem.split('-').take(5).collect::<Vec<_>>().join("-");
Some(id)
}
pub fn fu_content_hash(ailog_id: &str, origin_section: &str, description: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(ailog_id.as_bytes());
hasher.update([0x1f]);
hasher.update(origin_section.as_bytes());
hasher.update([0x1f]);
hasher.update(description.as_bytes());
format!("{:x}", hasher.finalize())[..12].to_string()
}
pub fn split_origin(origin: &str) -> Option<(&str, &str)> {
let trimmed = origin.trim();
let idx = trimmed.find(char::is_whitespace)?;
let ailog_id = &trimmed[..idx];
let section = trimmed[idx..].trim_start();
if ailog_id.is_empty() || section.is_empty() {
None
} else {
Some((ailog_id, section))
}
}
pub fn registry_extracted_hashes(registry: &Registry) -> HashSet<String> {
let mut set = HashSet::new();
for entry in registry.entries() {
if let Some(h) = &entry.source_hash {
set.insert(h.clone());
} else if let Some(origin) = &entry.origin {
if let Some((ailog_id, section)) = split_origin(origin) {
set.insert(fu_content_hash(ailog_id, section, &entry.description));
}
}
}
set
}
pub fn render_new_entry(
fu_number: u32,
extracted: &ExtractedFu,
ailog_id: &str,
today: &str,
) -> String {
let status = if extracted.suspected_closed {
"suspected-closed"
} else {
"open"
};
let mut notes = format!("Auto-appended by `straymark followups drift --apply` {}.", today);
if extracted.suspected_closed {
notes.push_str(" Closure marker detected in the source AILOG — confirm and mark `closed`, or reopen.");
}
let source_hash = fu_content_hash(ailog_id, &extracted.origin_section, &extracted.description);
format!(
"### FU-{:03} — {}\n\
- **Origin**: {} {}\n\
- **Source-hash**: {}\n\
- **Status**: {}\n\
- **Trigger**: TBD\n\
- **Destination**: TBD\n\
- **Cost**: TBD\n\
- **Notes**: {}\n",
fu_number, extracted.description, ailog_id, extracted.origin_section, source_hash, status, notes
)
}
pub fn insert_into_bucket(registry: &Registry, bucket: &str, block: &str) -> String {
let body = ®istry.body;
let target = registry
.sections
.iter()
.find(|s| s.is_bucket && s.name == bucket);
match target {
Some(section) => {
let head = &body[..section.end];
let tail = &body[section.end..];
let head_trimmed = head.trim_end_matches('\n');
format!("{}\n\n{}\n{}", head_trimmed, block.trim_end_matches('\n'), tail)
}
None => {
let trimmed = body.trim_end_matches('\n');
format!(
"{}\n\n## Bucket: {}\n\n{}\n",
trimmed,
bucket,
block.trim_end_matches('\n')
)
}
}
}
pub fn set_entry_field(body: &str, entry: &Entry, field: &str, value: &str) -> String {
let block = &body[entry.span_start..entry.span_end];
let mut lines: Vec<String> = block.lines().map(|s| s.to_string()).collect();
let needle = format!("- **{}**", field);
let mut replaced = false;
for line in lines.iter_mut() {
if line.trim_start().starts_with(&needle) {
*line = format!("- **{}**: {}", field, value);
replaced = true;
break;
}
}
if !replaced {
let last_bullet = lines
.iter()
.rposition(|l| l.trim_start().starts_with("- **"));
let insert_at = last_bullet.map(|i| i + 1).unwrap_or(lines.len());
lines.insert(insert_at, format!("- **{}**: {}", field, value));
}
let mut new_block = lines.join("\n");
if block.ends_with('\n') && !new_block.ends_with('\n') {
new_block.push('\n');
}
let mut out = String::with_capacity(body.len() + 32);
out.push_str(&body[..entry.span_start]);
out.push_str(&new_block);
out.push_str(&body[entry.span_end..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
const V0_REGISTRY: &str = r#"---
last_scan: 2026-05-06
schema_version: v0
total_open: 47
total_promoted: 3
total_closed_in_session: 2
total_phase_blocked: 1
buckets:
- ready
- time-triggered
- charter-triggered
- phase-blocked
- operational
fully_extracted_ailogs:
- AILOG-2026-04-11-001
- AILOG-2026-04-12-001
custom_field: kept
---
# Follow-ups Backlog
## Bucket: ready
### FU-001 — Wire the retry budget into the sync loop
- **Origin**: AILOG-2026-04-11-001 §Follow-ups
- **Status**: open
- **Trigger**: ready
- **Destination**: operations
- **Cost**: S
- **Notes**: first entry
### FU-002 — Validate flake on month boundary
- **Origin**: AILOG-2026-04-12-001 §R5 (new, not in Charter)
- **Status**: open
- **Trigger**: when next month boundary passes in CI
- **Destination**: TBD
- **Cost**: TBD
## Bucket: phase-blocked
### FU-003 — Phase 6 dashboard hook
- **Origin**: AILOG-2026-04-12-001 §Follow-ups
- **Status**: open
- **Trigger**: when Phase 6 exists
- **Destination**: Phase 6+
- **Cost**: M
## Promoted to TDE
### FU-004 — Transversal auth debt
- **Origin**: AILOG-2026-04-11-001 §R2 (new, not in Charter)
- **Status**: promoted
- **Promoted to**: TDE-2026-05-01-001
"#;
const V1_ENTRY: &str = r#"---
schema_version: v1
last_scan: 2026-06-03
buckets: [ready]
fully_extracted_ailogs: []
---
## Bucket: ready
### FU-010 — Harden staging probe
- **Origin**: AILOG-2026-06-01-002 §Follow-ups
- **Origin-class**: staging
- **Status**: open
- **Severity**: blocking
- **Trigger**: ready
- **Destination**: mini-charter
- **Cost**: M
- **Labels**: staging-hardening, reliability
- **Notes**: PROD path
"#;
fn parse(content: &str) -> Registry {
parse_registry_str(Path::new("follow-ups-backlog.md"), content).unwrap()
}
#[test]
fn parses_v0_registry_leniently() {
let reg = parse(V0_REGISTRY);
assert!(reg.is_v0());
assert_eq!(reg.frontmatter.fully_extracted_ailogs.len(), 2);
assert_eq!(reg.entries().count(), 4);
let fu1 = find_entry(®, "FU-001").unwrap();
assert_eq!(fu1.status, FuStatus::Open);
assert_eq!(fu1.bucket, "ready");
assert!(fu1.severity.is_none());
assert!(fu1.origin_class.is_none());
assert!(fu1.labels.is_empty());
assert!(reg.warnings.is_empty());
}
#[test]
fn parses_v1_entry_dimensions() {
let reg = parse(V1_ENTRY);
assert!(!reg.is_v0());
let e = find_entry(®, "10").unwrap();
assert_eq!(e.severity, Some(Severity::Blocking));
assert_eq!(e.origin_class.as_deref(), Some("staging"));
assert_eq!(e.labels, vec!["staging-hardening", "reliability"]);
assert_eq!(e.destination.as_deref(), Some("mini-charter"));
}
#[test]
fn malformed_fu_heading_is_warning_not_error() {
let content = r#"---
schema_version: v0
fully_extracted_ailogs: []
---
## Bucket: ready
### FU- — missing number
- **Status**: open
### FU-007 — good entry
- **Status**: open
"#;
let reg = parse(content);
assert_eq!(reg.entries().count(), 1);
assert_eq!(reg.warnings.len(), 1);
assert!(reg.warnings[0].contains("Malformed"));
}
#[test]
fn non_bucket_sections_still_collect_entries() {
let reg = parse(V0_REGISTRY);
let promoted = reg
.sections
.iter()
.find(|s| !s.is_bucket && s.name == "Promoted to TDE")
.unwrap();
assert_eq!(promoted.entries.len(), 1);
assert_eq!(promoted.entries[0].status, FuStatus::Promoted);
}
#[test]
fn bucket_heading_tolerates_trailing_annotation() {
let content = "---\nschema_version: v0\nfully_extracted_ailogs: []\n---\n\n## Bucket: ready (1 entry)\n\n### FU-001 — x\n- **Status**: open\n";
let reg = parse(content);
assert_eq!(reg.sections[0].name, "ready");
assert!(reg.sections[0].is_bucket);
}
#[test]
fn find_entry_accepts_bare_and_padded_numbers() {
let reg = parse(V0_REGISTRY);
assert_eq!(find_entry(®, "FU-002").unwrap().fu_number, 2);
assert_eq!(find_entry(®, "002").unwrap().fu_number, 2);
assert_eq!(find_entry(®, "2").unwrap().fu_number, 2);
assert!(find_entry(®, "99").is_none());
assert!(find_entry(®, "").is_none());
}
#[test]
fn next_fu_number_is_max_plus_one() {
let reg = parse(V0_REGISTRY);
assert_eq!(next_fu_number(®), 5);
}
#[test]
fn counters_recompute_from_statuses_not_frontmatter() {
let reg = parse(V0_REGISTRY);
let c = compute_counters(®);
assert_eq!(c.open, 3);
assert_eq!(c.promoted, 1);
assert_eq!(c.phase_blocked_open, 1);
assert_eq!(c.total, 4);
}
#[test]
fn blocking_open_counts_open_and_in_progress_blocking() {
let reg = parse(V1_ENTRY);
let c = compute_counters(®);
assert_eq!(c.blocking_open, 1);
}
#[test]
fn fm_set_scalar_replaces_and_appends() {
let fm = "schema_version: v0\ntotal_open: 47";
let out = fm_set_scalar(fm, "schema_version", "v1");
assert!(out.contains("schema_version: v1"));
let out = fm_set_scalar(&out, "total_suspected_closed", "0");
assert!(out.ends_with("total_suspected_closed: 0"));
}
#[test]
fn fm_append_list_items_extends_block_list() {
let fm = "schema_version: v0\nfully_extracted_ailogs:\n - AILOG-2026-04-11-001\nbuckets:\n - ready";
let out = fm_append_list_items(fm, "fully_extracted_ailogs", &["AILOG-2026-06-03-001".to_string()]);
let idx_old = out.find("AILOG-2026-04-11-001").unwrap();
let idx_new = out.find("AILOG-2026-06-03-001").unwrap();
assert!(idx_new > idx_old);
assert!(idx_new < out.find("buckets:").unwrap());
}
#[test]
fn fm_append_list_items_converts_empty_flow_list() {
let fm = "schema_version: v1\nfully_extracted_ailogs: []";
let out = fm_append_list_items(fm, "fully_extracted_ailogs", &["AILOG-2026-06-03-001".to_string()]);
assert!(out.contains("fully_extracted_ailogs:\n - AILOG-2026-06-03-001"));
let parsed: RegistryFrontmatter = serde_yaml::from_str(&out).unwrap();
assert_eq!(parsed.fully_extracted_ailogs.len(), 1);
}
#[test]
fn v0_upgrade_preserves_unknown_fields() {
let reg = parse(V0_REGISTRY);
let counters = compute_counters(®);
let fm = fm_apply_counters_and_v1(®.frontmatter_raw, &counters);
assert!(fm.contains("schema_version: v1"));
assert!(fm.contains("custom_field: kept"), "unknown fields must survive");
assert!(fm.contains("total_open: 3"));
let fm2 = fm_apply_counters_and_v1(&fm, &counters);
assert_eq!(fm, fm2);
}
#[test]
fn extract_followups_finds_section_bullets_and_risk_lines() {
let ailog = r#"# AILOG-2026-06-03-003 — staging run
## Risk
- **R3 (new, not in Charter)**: bus handler writes escape the unit suite.
## Follow-ups
- Extend E2E coverage to write-path-A
- Formal validation run — closed in-Charter (commit `ab12cd34ef`), 5/6 pass
## Outcome
Done.
"#;
let found = extract_followups_from_ailog(ailog);
assert_eq!(found.len(), 3);
let bullets: Vec<&str> = found.iter().map(|f| f.description.as_str()).collect();
assert!(bullets.iter().any(|b| b.contains("Extend E2E coverage")));
let closed = found
.iter()
.find(|f| f.description.contains("Formal validation"))
.unwrap();
assert!(closed.suspected_closed, "closure marker must be detected");
let open = found
.iter()
.find(|f| f.description.contains("Extend E2E"))
.unwrap();
assert!(!open.suspected_closed);
let risk = found
.iter()
.find(|f| f.origin_section.contains("R3"))
.unwrap();
assert!(risk.origin_section.contains("(new, not in Charter)"));
}
#[test]
fn closure_markers_detected_case_insensitively() {
assert!(has_closure_marker("Closed in-Charter by the runbook rewrite"));
assert!(has_closure_marker("fixed in batch 3"));
assert!(has_closure_marker("resolved in Charter close"));
assert!(has_closure_marker("see commit `deadbeef42` for the fix"));
assert!(!has_closure_marker("should be closed when X lands"));
assert!(!has_closure_marker("fixed in batch processing generally"));
assert!(!has_closure_marker("see `feedface` once")); assert!(!has_closure_marker("run `cargo test` first"));
}
#[test]
fn closure_markers_born_resolved_idiom_family() {
assert!(has_closure_marker(
"Charter `## Files to modify` row updated atomically in this PR."
));
assert!(has_closure_marker("remediated in this PR"));
assert!(has_closure_marker("the regression was Corrected in this commit"));
assert!(has_closure_marker("scope drift recognized and fixed in this PR"));
assert!(!has_closure_marker("discussed in this PR"));
assert!(!has_closure_marker("tracked in this PR for visibility"));
assert!(!has_closure_marker("will be updated in a follow-up PR"));
assert!(!has_closure_marker("should be corrected in the next commit"));
}
#[test]
fn ailog_id_from_path_takes_five_tokens() {
assert_eq!(
ailog_id_from_path(Path::new("AILOG-2026-06-03-003-staging-incident.md")).as_deref(),
Some("AILOG-2026-06-03-003")
);
assert_eq!(
ailog_id_from_path(Path::new("AILOG-2026-06-03-004.md")).as_deref(),
Some("AILOG-2026-06-03-004")
);
assert!(ailog_id_from_path(Path::new("README.md")).is_none());
}
#[test]
fn insert_into_bucket_appends_at_section_end() {
let reg = parse(V0_REGISTRY);
let block = render_new_entry(
5,
&ExtractedFu {
description: "New thing".to_string(),
origin_section: "§Follow-ups".to_string(),
suspected_closed: false,
},
"AILOG-2026-06-03-001",
"2026-06-04",
);
let new_body = insert_into_bucket(®, "ready", &block);
let idx_new = new_body.find("### FU-005").unwrap();
assert!(idx_new > new_body.find("### FU-002").unwrap());
assert!(idx_new < new_body.find("## Bucket: phase-blocked").unwrap());
let reparsed = parse(&assemble(®.frontmatter_raw, &new_body));
assert_eq!(reparsed.entries().count(), 5);
assert_eq!(find_entry(&reparsed, "FU-005").unwrap().bucket, "ready");
}
#[test]
fn insert_into_bucket_creates_missing_section() {
let content = "---\nschema_version: v1\nfully_extracted_ailogs: []\n---\n\n# Registry\n";
let reg = parse(content);
let new_body = insert_into_bucket(®, "ready", "### FU-001 — x\n- **Status**: open\n");
assert!(new_body.contains("## Bucket: ready"));
let reparsed = parse(&assemble(®.frontmatter_raw, &new_body));
assert_eq!(reparsed.entries().count(), 1);
}
#[test]
fn set_entry_field_replaces_existing_bullet() {
let reg = parse(V0_REGISTRY);
let entry = find_entry(®, "FU-001").unwrap().clone();
let new_body = set_entry_field(®.body, &entry, "Status", "promoted");
let reparsed = parse(&assemble(®.frontmatter_raw, &new_body));
assert_eq!(
find_entry(&reparsed, "FU-001").unwrap().status,
FuStatus::Promoted
);
assert_eq!(find_entry(&reparsed, "FU-002").unwrap().status, FuStatus::Open);
}
#[test]
fn set_entry_field_appends_missing_bullet() {
let reg = parse(V0_REGISTRY);
let entry = find_entry(®, "FU-001").unwrap().clone();
let new_body = set_entry_field(®.body, &entry, "Promoted to", "TDE-2026-06-04-001");
let reparsed = parse(&assemble(®.frontmatter_raw, &new_body));
assert_eq!(
find_entry(&reparsed, "FU-001").unwrap().promoted_to.as_deref(),
Some("TDE-2026-06-04-001")
);
}
#[test]
fn render_new_entry_marks_suspected_closed() {
let block = render_new_entry(
92,
&ExtractedFu {
description: "Formal run".to_string(),
origin_section: "§Follow-ups".to_string(),
suspected_closed: true,
},
"AILOG-2026-06-03-001",
"2026-06-04",
);
assert!(block.contains("### FU-092 — Formal run"));
assert!(block.contains("- **Status**: suspected-closed"));
assert!(block.contains("Closure marker detected"));
}
#[test]
fn fu_content_hash_is_stable_12_hex_and_discriminating() {
let h = fu_content_hash("AILOG-2026-06-09-001", "§Follow-ups", "Wire X into Y");
assert_eq!(h.len(), 12);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
h,
fu_content_hash("AILOG-2026-06-09-001", "§Follow-ups", "Wire X into Y")
);
assert_ne!(
h,
fu_content_hash("AILOG-2026-06-09-002", "§Follow-ups", "Wire X into Y")
);
assert_ne!(
h,
fu_content_hash("AILOG-2026-06-09-001", "§Follow-ups", "Wire X into Z")
);
}
#[test]
fn split_origin_splits_id_and_section() {
assert_eq!(
split_origin("AILOG-2026-06-09-001 §Follow-ups"),
Some(("AILOG-2026-06-09-001", "§Follow-ups"))
);
assert_eq!(
split_origin("AILOG-2026-06-09-001 §R3 (new, not in Charter)"),
Some(("AILOG-2026-06-09-001", "§R3 (new, not in Charter)"))
);
assert_eq!(split_origin("AILOG-2026-06-09-001"), None);
}
#[test]
fn render_new_entry_embeds_matching_source_hash() {
let fu = ExtractedFu {
description: "Backfill the missing migration".to_string(),
origin_section: "§Follow-ups".to_string(),
suspected_closed: false,
};
let block = render_new_entry(7, &fu, "AILOG-2026-06-09-001", "2026-06-10");
let expected = fu_content_hash("AILOG-2026-06-09-001", "§Follow-ups", &fu.description);
assert!(block.contains(&format!("- **Source-hash**: {}", expected)));
}
#[test]
fn parse_reads_source_hash_field() {
let content = "---\nschema_version: v1\nfully_extracted_ailogs: []\n---\n\n\
## Bucket: ready\n\n\
### FU-001 — x\n- **Origin**: AILOG-2026-06-09-001 §Follow-ups\n- **Source-hash**: abc123def456\n- **Status**: open\n";
let reg = parse(content);
assert_eq!(
find_entry(®, "FU-001").unwrap().source_hash.as_deref(),
Some("abc123def456")
);
}
#[test]
fn registry_extracted_hashes_prefers_stored_then_falls_back_to_legacy() {
let aid = "AILOG-2026-06-09-001";
let h_first = fu_content_hash(aid, "§Follow-ups", "First FU");
let content = format!(
"---\nschema_version: v1\nfully_extracted_ailogs:\n - {aid}\n---\n\n\
## Bucket: ready\n\n\
### FU-001 — First FU\n- **Origin**: {aid} §Follow-ups\n- **Source-hash**: {h_first}\n- **Status**: open\n\n\
### FU-002 — Second FU\n- **Origin**: {aid} §Follow-ups\n- **Status**: open\n"
);
let reg = parse(&content);
let set = registry_extracted_hashes(®);
assert!(set.contains(&h_first));
assert!(set.contains(&fu_content_hash(aid, "§Follow-ups", "Second FU")));
assert!(!set.contains(&fu_content_hash(aid, "§Follow-ups", "Third FU")));
}
#[test]
fn registry_without_frontmatter_errors_with_hint() {
let err = parse_registry_str(Path::new("x.md"), "# no frontmatter\n").unwrap_err();
assert!(err.to_string().contains("no YAML frontmatter"));
}
#[test]
fn status_tolerates_inline_annotations_after_value() {
assert_eq!(
FuStatus::from_str_loose("open — **OVERDUE** (15-Apr-2026 was 22 days ago)"),
FuStatus::Open
);
assert_eq!(
FuStatus::from_str_loose("open — mitigation in place (`-timeout` default 600s)"),
FuStatus::Open
);
assert_eq!(
FuStatus::from_str_loose("suspected-closed — confirm at triage"),
FuStatus::SuspectedClosed
);
assert_eq!(
FuStatus::from_str_loose("promoted (see TDE-2026-05-01-001)"),
FuStatus::Promoted
);
assert_eq!(FuStatus::from_str_loose("reopened — by audit"), FuStatus::Unknown);
assert_eq!(FuStatus::from_str_loose(""), FuStatus::Unknown);
}
#[test]
fn severity_tolerates_inline_annotations_after_value() {
assert_eq!(
Severity::from_str_loose("blocking — must land before prod cutover"),
Some(Severity::Blocking)
);
assert_eq!(Severity::from_str_loose("normal (default)"), Some(Severity::Normal));
assert_eq!(Severity::from_str_loose("urgent — not a vocab value"), None);
}
#[test]
fn annotated_statuses_count_into_recomputed_counters() {
let content = r#"---
schema_version: v0
fully_extracted_ailogs: []
---
## Bucket: ready
### FU-001 — annotated open
- **Status**: open — **OVERDUE** (15-Apr-2026)
### FU-002 — plain open
- **Status**: open
"#;
let reg = parse(content);
let c = compute_counters(®);
assert_eq!(c.open, 2);
assert!(reg.entries().all(|e| e.status == FuStatus::Open));
}
}