use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadmapRow {
pub id: String,
pub title: String,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadmapSources {
pub rows: Vec<RoadmapRow>,
pub gh_snapshot: Option<String>,
}
impl RoadmapSources {
pub fn new(mut rows: Vec<RoadmapRow>, gh_snapshot: Option<String>) -> Self {
rows.sort_by(|a, b| a.id.cmp(&b.id));
rows.dedup_by(|a, b| a.id == b.id);
Self { rows, gh_snapshot }
}
pub fn content_hash(&self) -> String {
let mut hasher = Sha256::new();
for row in &self.rows {
hasher.update(row.id.as_bytes());
hasher.update(b"\x1f");
hasher.update(row.title.as_bytes());
hasher.update(b"\x1f");
hasher.update(row.status.as_bytes());
hasher.update(b"\x1e");
}
if let Some(snap) = &self.gh_snapshot {
hasher.update(b"gh:");
hasher.update(snap.as_bytes());
}
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn status_counts(&self) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for row in &self.rows {
*counts.entry(row.status.clone()).or_insert(0) += 1;
}
counts
}
}
pub fn render_roadmap(sources: &RoadmapSources, generated_at: &str) -> String {
let mut out = String::new();
out.push_str("# ROADMAP.yaml — canonical, generated by `pmat roadmap sync` (MACS-013).\n");
out.push_str(
"# Do not edit by hand; regenerate. Content hash covers sources, not wall-clock.\n",
);
out.push_str(&format!(
"generated_at: \"{generated_at}\" # excluded from content_hash\n"
));
out.push_str(&format!("content_hash: \"{}\"\n", sources.content_hash()));
if let Some(snap) = &sources.gh_snapshot {
out.push_str(&format!("gh_snapshot: \"{}\"\n", yaml_scalar(snap)));
}
out.push_str("summary:\n");
for (status, count) in sources.status_counts() {
out.push_str(&format!(" {}: {count}\n", yaml_scalar(&status)));
}
out.push_str("items:\n");
for row in &sources.rows {
out.push_str(&format!(" - id: {}\n", yaml_scalar(&row.id)));
out.push_str(&format!(" title: {}\n", yaml_quote(&row.title)));
out.push_str(&format!(" status: {}\n", yaml_scalar(&row.status)));
}
out
}
fn yaml_quote(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
fn yaml_scalar(s: &str) -> String {
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
s.to_string()
} else {
yaml_quote(s)
}
}
pub fn read_work_store_rows(project_path: &Path) -> Result<Vec<RoadmapRow>> {
let path = project_path.join("docs/roadmaps/roadmap.yaml");
let text =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
Ok(parse_rows(&text))
}
pub fn parse_rows(text: &str) -> Vec<RoadmapRow> {
let mut rows = Vec::new();
let mut cur: Option<RoadmapRow> = None;
let mut item_indent: Option<usize> = None;
for line in text.lines() {
let indent = line.len() - line.trim_start().len();
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("- id:") {
if is_top_level_item(indent, &mut item_indent) {
if let Some(row) = cur.take() {
rows.push(row);
}
cur = Some(RoadmapRow {
id: unquote(rest.trim()),
title: String::new(),
status: String::new(),
});
}
continue;
}
if item_indent.is_some_and(|base| indent > base) {
apply_field(cur.as_mut(), trimmed);
}
}
if let Some(row) = cur.take() {
rows.push(row);
}
rows.retain(|r| !r.id.is_empty());
rows
}
fn is_top_level_item(indent: usize, item_indent: &mut Option<usize>) -> bool {
match *item_indent {
None => {
*item_indent = Some(indent);
true
}
Some(base) => indent == base,
}
}
fn apply_field(row: Option<&mut RoadmapRow>, trimmed: &str) {
let Some(row) = row else { return };
if let Some(rest) = trimmed.strip_prefix("title:") {
if row.title.is_empty() {
row.title = unquote(rest.trim());
}
} else if let Some(rest) = trimmed.strip_prefix("status:") {
if row.status.is_empty() {
row.status = unquote(rest.trim());
}
}
}
fn unquote(s: &str) -> String {
let s = s.trim();
s.trim_matches('\'').trim_matches('"').to_string()
}
pub fn handle_roadmap_sync(
project_path: &Path,
gh_snapshot: Option<String>,
dry_run: bool,
generated_at: &str,
) -> Result<()> {
let rows = read_work_store_rows(project_path)?;
let sources = RoadmapSources::new(rows, gh_snapshot);
let rendered = render_roadmap(&sources, generated_at);
if dry_run {
print!("{rendered}");
return Ok(());
}
let out_path = project_path.join("ROADMAP.yaml");
std::fs::write(&out_path, &rendered)
.with_context(|| format!("write {}", out_path.display()))?;
println!(
"✅ Rendered {} ({} item(s), hash {})",
out_path.display(),
sources.rows.len(),
&sources.content_hash()[..12]
);
Ok(())
}
pub fn roadmap_is_stale(project_path: &Path) -> Option<bool> {
let ledger = project_path.join(".pmat-work/ledger.jsonl");
if !ledger.exists() {
return None; }
let roadmap = project_path.join("ROADMAP.yaml");
if !roadmap.exists() {
return Some(true);
}
let ledger_mtime = std::fs::metadata(&ledger).ok()?.modified().ok()?;
let roadmap_mtime = std::fs::metadata(&roadmap).ok()?.modified().ok()?;
Some(ledger_mtime > roadmap_mtime)
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
fn rows() -> Vec<RoadmapRow> {
vec![
RoadmapRow {
id: "MACS-002".into(),
title: "capture".into(),
status: "completed".into(),
},
RoadmapRow {
id: "MACS-001".into(),
title: "types".into(),
status: "completed".into(),
},
RoadmapRow {
id: "MACS-011".into(),
title: "sweep".into(),
status: "inprogress".into(),
},
]
}
#[test]
fn render_byte_stable_two_runs() {
let s = RoadmapSources::new(rows(), None);
assert_eq!(render_roadmap(&s, "T"), render_roadmap(&s, "T"));
let out = render_roadmap(&s, "T");
let first = out.find("MACS-001").unwrap();
let second = out.find("MACS-002").unwrap();
assert!(first < second, "items sorted by id");
}
#[test]
fn hash_covers_sources_not_wallclock() {
let s = RoadmapSources::new(rows(), None);
let a = render_roadmap(&s, "2026-07-02T00:00:00Z");
let b = render_roadmap(&s, "2027-01-01T12:00:00Z");
assert_ne!(a, b, "generated_at line differs");
let hash_line = |r: &str| {
r.lines()
.find(|l| l.starts_with("content_hash:"))
.unwrap()
.to_string()
};
assert_eq!(hash_line(&a), hash_line(&b));
let mut mutated = rows();
mutated[0].status = "planned".into();
let s2 = RoadmapSources::new(mutated, None);
assert_ne!(s.content_hash(), s2.content_hash());
}
#[test]
fn gh_snapshot_pinned_by_id() {
let base = RoadmapSources::new(rows(), None);
let pinned = RoadmapSources::new(rows(), Some("abc123".into()));
assert_ne!(
base.content_hash(),
pinned.content_hash(),
"snapshot id is a hashed source"
);
let pinned2 = RoadmapSources::new(rows(), Some("abc123".into()));
assert_eq!(pinned.content_hash(), pinned2.content_hash());
}
#[test]
fn parse_rows_reads_roadmap_yaml() {
let text = "roadmap:\n- id: MACS-001\n title: 'types'\n status: completed\n- id: MACS-002\n title: capture\n status: inprogress\n";
let parsed = parse_rows(text);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].id, "MACS-001");
assert_eq!(parsed[0].title, "types");
assert_eq!(parsed[1].status, "inprogress");
}
#[test]
fn parse_rows_ignores_nested_subtask_ids() {
let text = "roadmap:\n- id: MACS-016\n title: capstone\n status: inprogress\n subtasks:\n - id: SUB-1\n title: nested\n status: planned\n- id: MACS-017\n title: next\n status: planned\n";
let rows = parse_rows(text);
let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["MACS-016", "MACS-017"], "nested SUB-1 excluded");
assert_eq!(rows[0].title, "capstone", "nested title did not overwrite");
}
#[test]
fn stale_when_ledger_newer_than_artifact() {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join(".pmat-work");
std::fs::create_dir_all(&work).unwrap();
std::fs::write(work.join("ledger.jsonl"), "{}\n").unwrap();
assert_eq!(roadmap_is_stale(dir.path()), Some(true));
std::fs::write(dir.path().join("ROADMAP.yaml"), "items:\n").unwrap();
assert_eq!(roadmap_is_stale(dir.path()), Some(false));
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(work.join("ledger.jsonl"), "{}\n{}\n").unwrap();
assert_eq!(roadmap_is_stale(dir.path()), Some(true));
}
}