rustio-admin-cli 0.31.0

Command-line tools for rustio-admin: project scaffolding, migrations, user management.
//! Rendering the entry store into the generated `CLOUD.md` view, and the
//! freshness check that keeps the view in lock-step with its source.
//!
//! Implements `docs/design/DESIGN_CLOUD_IMPL.md` §2.1 (generated view with
//! a do-not-edit banner), §2.6 (freshness is enforced), and §7 (`render` /
//! `verify`). Output is deterministic so `verify` can compare it
//! byte-for-byte against the on-disk file.

use super::entry::{short, Entry};
use super::store::{Memory, Status, Store};

/// The do-not-edit banner (§2.1). Marks CLOUD.md as a generated artifact
/// whose canonical source is the entry files.
const BANNER: &str =
    "<!-- generated by `rustio-admin memory render` — edit entries in .rustio/memory/entries/, \
not this file. Contract: docs/design/DESIGN_CLOUD.md -->";

/// Render the whole memory into the `CLOUD.md` text. Entries appear in
/// `(date, id)` order; superseded and forked entries are visibly demoted
/// but never removed (§7).
pub(crate) fn render(mem: &Memory) -> String {
    let mut out = String::new();
    out.push_str(BANNER);
    out.push_str("\n\n# Project Memory\n\n");
    out.push_str(
        "> Non-authoritative project memory — the *why* behind this project's decisions.\n\
         > Subordinate to code; on any conflict, the code wins. See `docs/design/DESIGN_CLOUD.md`.\n\n",
    );

    if mem.entries.is_empty() {
        out.push_str("_No memory entries yet._\n");
        return out;
    }

    for e in &mem.entries {
        out.push_str(&render_entry(e, &mem.status_of(&e.id)));
        out.push('\n');
    }
    out
}

fn render_entry(e: &Entry, status: &Status) -> String {
    let mut s = String::new();
    let foundational = if e.foundational {
        " · ⭑ foundational"
    } else {
        ""
    };
    s.push_str(&format!(
        "## {} · {} · `{}`{}\n\n",
        e.date,
        e.entry_type.as_str(),
        short(&e.id),
        foundational
    ));
    if !e.subjects.is_empty() {
        s.push_str(&format!("*subjects:* {}\n\n", e.subjects.join(", ")));
    }
    match status {
        Status::Active => {}
        Status::Superseded(by) => {
            s.push_str(&format!("> **Superseded** by `{}`.\n\n", short(by)));
        }
        Status::Forked(bys) => {
            let list = bys
                .iter()
                .map(|b| format!("`{}`", short(b)))
                .collect::<Vec<_>>()
                .join(", ");
            s.push_str(&format!(
                "> **⚠ Open tension** — superseded by multiple entries ({list}); \
                 an unresolved fork. Resolve by appending a superseding entry.\n\n"
            ));
        }
    }
    if e.entry_type == super::entry::EntryType::OpenTension {
        s.push_str(
            "> **⚠ Open tension** — records an unresolved disagreement; closes by supersession.\n\n",
        );
    }
    if e.redacted {
        s.push_str("> 🔒 **Redacted** — prohibited content was removed from this entry.\n\n");
    }
    s.push_str(&e.body);
    s.push('\n');
    if !e.sources.is_empty() {
        s.push_str(&format!("\n*sources:* {}\n", e.sources.join(", ")));
    }
    // Provenance footer — attribution (§4) and the audit join (§6). Every
    // entry says who recorded it, who ratified it, and how to find it in
    // the audit trail.
    s.push_str(&format!(
        "\n<sub>recorded by {} · ratified by {} · audit {}</sub>\n",
        e.author, e.ratified_by, e.correlation_id
    ));
    s
}

/// Build the memory model from `store`, render it, and write the result to
/// the project-root `CLOUD.md`. Returns the entry count. This is the single
/// writer of `CLOUD.md` (§2.1) — used by `render` and after every `apply`.
pub(crate) fn write_view(store: &Store) -> Result<usize, String> {
    let mem = Memory::build(store.load_entries()?)?;
    let out = render(&mem);
    let path = store.cloud_md_path();
    std::fs::write(&path, &out).map_err(|e| format!("could not write {}: {e}", path.display()))?;
    Ok(mem.entries.len())
}

/// Verify the store is well-formed and the on-disk `CLOUD.md` is fresh.
/// Building the [`Memory`] catches dangling/cyclic links (§3.3); the
/// byte-comparison catches a stale or missing view (§2.6).
pub(crate) fn verify(store: &Store) -> Result<(), String> {
    let mem = Memory::build(store.load_entries()?)?;
    let expected = render(&mem);
    let actual = std::fs::read_to_string(store.cloud_md_path()).unwrap_or_default();
    if actual != expected {
        return Err(
            "CLOUD.md is stale or missing — run `rustio-admin memory render` to regenerate it"
                .to_string(),
        );
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builder::ulid_gen::new_ulid;

    fn store_with(entries: &[(&str, &str, &str, Option<&str>)]) -> Store {
        let root = std::env::temp_dir().join(format!("rustio-render-{}", new_ulid()));
        let dir = root.join(".rustio").join("memory").join("entries");
        std::fs::create_dir_all(&dir).unwrap();
        for (id, ty, date, sup) in entries {
            let content = format!(
                "+++\nid = \"{id}\"\ntype = \"{ty}\"\nsubjects = [\"core\"]\n\
                 supersedes = \"{}\"\nfoundational = false\nsources = []\n\
                 author = \"ai:test\"\nratified_by = \"t@e\"\ndate = \"{date}\"\n\
                 correlation_id = \"c\"\n+++\n\nBody of {id}.\n",
                sup.unwrap_or("")
            );
            std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
        }
        Store::new(root)
    }

    #[test]
    fn empty_memory_renders_banner_and_placeholder() {
        let s = store_with(&[]);
        let mem = Memory::build(s.load_entries().unwrap()).unwrap();
        let out = render(&mem);
        assert!(out.contains(BANNER));
        assert!(out.contains("_No memory entries yet._"));
    }

    #[test]
    fn render_includes_entry_and_marks_superseded() {
        let s = store_with(&[
            ("aaa", "assumption", "2026-01-01", None),
            ("bbb", "assumption", "2026-02-01", Some("aaa")),
        ]);
        let mem = Memory::build(s.load_entries().unwrap()).unwrap();
        let out = render(&mem);
        assert!(out.contains("Body of aaa."));
        assert!(out.contains("Body of bbb."));
        assert!(out.contains("**Superseded** by"));
    }

    #[test]
    fn render_marks_open_tension_for_fork() {
        let s = store_with(&[
            ("aaa", "assumption", "2026-01-01", None),
            ("bbb", "assumption", "2026-02-01", Some("aaa")),
            ("ccc", "assumption", "2026-03-01", Some("aaa")),
        ]);
        let mem = Memory::build(s.load_entries().unwrap()).unwrap();
        let out = render(&mem);
        assert!(out.contains("Open tension"));
    }

    #[test]
    fn render_is_deterministic() {
        let s = store_with(&[("aaa", "decision", "2026-01-01", None)]);
        let mem = Memory::build(s.load_entries().unwrap()).unwrap();
        assert_eq!(render(&mem), render(&mem));
    }

    #[test]
    fn verify_flags_missing_then_passes_after_write() {
        let s = store_with(&[("aaa", "decision", "2026-01-01", None)]);
        // No CLOUD.md yet → stale.
        assert!(verify(&s).is_err());
        // Write the rendered view → fresh.
        let mem = Memory::build(s.load_entries().unwrap()).unwrap();
        std::fs::write(s.cloud_md_path(), render(&mem)).unwrap();
        assert!(verify(&s).is_ok());
        // Corrupt it → stale again.
        std::fs::write(s.cloud_md_path(), "tampered").unwrap();
        assert!(verify(&s).is_err());
    }

    #[test]
    fn verify_propagates_referential_errors() {
        let s = store_with(&[("bbb", "assumption", "2026-02-01", Some("ghost"))]);
        let err = verify(&s).unwrap_err();
        assert!(err.contains("dangling link"), "{err}");
    }
}