pmat 3.21.0

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
//! MACS F6 (Component 32): canonical ROADMAP.yaml renderer.
//!
//! Sub-spec: `docs/specifications/components/modern-agentic-coding-support.md`
//! Contract: `contracts/macs-artifacts-v1.yaml#roadmap_canonical`
//!
//! The frozen `docs/roadmaps/roadmap.yaml` is HISTORICAL; live status moved to
//! the work store + `gh issue list` + specs, and nothing rendered it back out
//! (F6 five-whys Why3). `pmat roadmap sync` renders `ROADMAP.yaml` (capital,
//! distinct from the historical file) deterministically from the work store
//! plus an optional pinned GitHub snapshot: ids sorted, BTreeMap ordering, the
//! generation timestamp EXCLUDED from the content hash, source snapshot ids
//! INCLUDED. GitHub is a mutable remote, so renders may only consume a pinned
//! snapshot id, never a live query (Why4).

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::Path;

/// One roadmap row projected from the work store (id + title + status).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadmapRow {
    pub id: String,
    pub title: String,
    pub status: String,
}

/// The canonical, hashable roadmap projection (everything the hash covers).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadmapSources {
    /// Sorted by id for byte-stability.
    pub rows: Vec<RoadmapRow>,
    /// Pinned GitHub export sha, if any (a source id — hashed).
    pub gh_snapshot: Option<String>,
}

impl RoadmapSources {
    /// Build from raw rows, sorting by id. `gh_snapshot` is a pinned export
    /// id, never a live query.
    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 }
    }

    /// SHA-256 over the sources (rows + snapshot id) — NOT wall-clock. Two
    /// renders of the same state at different times hash identically.
    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()
    }

    /// Count statuses for the summary block (BTreeMap ordered).
    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
    }
}

/// Render `ROADMAP.yaml`. `generated_at` is written for humans but is NOT part
/// of the content hash, so the render is byte-stable modulo that one line.
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('"', "\\\""))
}

/// Bare scalar if safe, else quoted.
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)
    }
}

/// Read roadmap rows from the work store's `docs/roadmaps/roadmap.yaml`.
/// Line-scan (no full parse dependency) — the source is stable YAML.
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))
}

/// Parse `- id: / title: / status:` triples from a roadmap.yaml body.
///
/// Only `- id:` list entries at the *item-list* indentation (that of the
/// first such entry) start a new row; more-deeply-indented `- id:` inside a
/// `subtasks:`/`phases:` block are ignored, so nested ids never masquerade as
/// top-level roadmap items (adversarial-review fix). `title:`/`status:` are
/// only read while inside a top-level item and at a deeper indent than it.
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;
        }
        // Field lines belong to the current top-level item only when nested
        // beneath it (strictly deeper than the item-list indent).
        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
}

/// Establish the item-list indent from the first `- id:`; only entries at
/// that exact indent are top-level items (nested subtask ids are ignored).
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,
    }
}

/// Fill a top-level item's title/status from a nested field line (first wins).
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()
}

/// `pmat roadmap sync` (MACS-013).
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(())
}

/// CB-1655 freshness: true (stale) iff `ledger.jsonl` is newer than
/// `ROADMAP.yaml` (or ROADMAP.yaml is absent while a ledger exists).
pub fn roadmap_is_stale(project_path: &Path) -> Option<bool> {
    let ledger = project_path.join(".pmat-work/ledger.jsonl");
    if !ledger.exists() {
        return None; // nothing to be stale against
    }
    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);
        // Same generated_at -> byte-identical; sorted by id.
        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);
        // Different generated_at, same sources -> same content_hash.
        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");
        // But the hash line is identical.
        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));
        // Changing a source row changes the hash.
        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"
        );
        // Same snapshot -> same hash (pinned, not live).
        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() {
        // ADVERSARIAL-REVIEW regression: nested `- id:` under subtasks/phases
        // must not be promoted to top-level roadmap items.
        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();
        // No ROADMAP.yaml yet, ledger present -> stale.
        std::fs::write(work.join("ledger.jsonl"), "{}\n").unwrap();
        assert_eq!(roadmap_is_stale(dir.path()), Some(true));
        // Write ROADMAP.yaml, then touch ledger newer.
        std::fs::write(dir.path().join("ROADMAP.yaml"), "items:\n").unwrap();
        // Fresh right after write.
        assert_eq!(roadmap_is_stale(dir.path()), Some(false));
        // Rewrite the ledger so its mtime advances past the roadmap.
        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));
    }
}