edda 0.2.1

Decision memory for coding agents — CLI and TUI
//! `edda export md --out <dir>` — deterministic human-readable projection of
//! the ledger (Foundry q335 EDDA-LEDGER-EXPORT1).
//!
//! Contract:
//! - SQLite stays the single source of truth. Export produces read-only files
//!   whose header names them "generated by edda — do not edit".
//! - Deterministic: same ledger + same flags ⇒ identical output. Timestamps are
//!   normalized to "generated at run-time" in the header only; body content is
//!   sorted so idempotent re-export doesn't create noise.
//! - Layout:
//!   <out>/INDEX.md             — table of contents
//!   <out>/decisions/<domain>.md — one file per domain (active decisions)
//!   <out>/notes.md             — chronological notes (optional)

use anyhow::{Context, Result};
use edda_core::secret_guard::redact;
use edda_ledger::Ledger;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

pub fn execute(repo_root: &Path, out_dir: &Path, include_notes: bool) -> Result<()> {
    let ledger = Ledger::open(repo_root)?;

    fs::create_dir_all(out_dir).with_context(|| format!("create out_dir {out_dir:?}"))?;
    let decisions_dir = out_dir.join("decisions");
    fs::create_dir_all(&decisions_dir).with_context(|| format!("create {decisions_dir:?}"))?;

    let active = ledger.active_decisions(None, None, None, None)?;
    let by_domain = group_by_domain(active);

    let mut domain_stats: Vec<(String, usize, PathBuf)> = Vec::with_capacity(by_domain.len());
    for (domain, mut rows) in by_domain {
        rows.sort_by(|a, b| a.key.cmp(&b.key));
        let path = decisions_dir.join(format!("{domain}.md"));
        let body = render_domain(&domain, &rows);
        write_if_changed(&path, &body)?;
        domain_stats.push((domain, rows.len(), path));
    }

    let notes_line = if include_notes {
        let note_events = ledger.iter_events_by_type("note")?;
        let path = out_dir.join("notes.md");
        let body = render_notes(&note_events);
        write_if_changed(&path, &body)?;
        Some((note_events.len(), path))
    } else {
        None
    };

    let index_body = render_index(&domain_stats, notes_line.as_ref(), out_dir);
    write_if_changed(&out_dir.join("INDEX.md"), &index_body)?;

    println!(
        "wrote {} decision files ({} total) + INDEX.md{} at {}",
        domain_stats.len(),
        domain_stats.iter().map(|(_, n, _)| n).sum::<usize>(),
        if notes_line.is_some() {
            " + notes.md"
        } else {
            ""
        },
        out_dir.display(),
    );
    Ok(())
}

fn group_by_domain(
    rows: Vec<edda_ledger::DecisionView>,
) -> BTreeMap<String, Vec<edda_ledger::DecisionView>> {
    let mut out: BTreeMap<String, Vec<edda_ledger::DecisionView>> = BTreeMap::new();
    for row in rows {
        let dom = if row.domain.is_empty() {
            "_unclassified".to_string()
        } else {
            row.domain.clone()
        };
        out.entry(dom).or_default().push(row);
    }
    out
}

const HEADER: &str =
    "<!-- edda-ledger-export v1 — GENERATED FILE, DO NOT EDIT — SQLite ledger is authoritative -->";

fn render_domain(domain: &str, rows: &[edda_ledger::DecisionView]) -> String {
    let mut out = String::with_capacity(1024);
    out.push_str(HEADER);
    out.push('\n');
    out.push_str(&format!("# Domain: `{}`\n\n", domain));
    out.push_str(&format!(
        "{} active decision(s), sorted by key.\n\n",
        rows.len()
    ));
    for row in rows {
        // Secret guard the reason at export time as a belt-and-braces safety
        // — decisions written before secret_guard shipped may still carry
        // secrets in older ledgers; export must never leak them to markdown.
        let (safe_reason, _) = redact(&row.reason);
        let (safe_value, _) = redact(&row.value);
        let ts = row.ts.as_deref().unwrap_or("?");
        out.push_str(&format!("## `{}`\n\n", row.key));
        out.push_str(&format!("- **Value**: `{}`\n", safe_value));
        out.push_str(&format!("- **Reason**: {}\n", safe_reason));
        out.push_str(&format!("- **Branch/ts**: `{}` · {}\n", row.branch, ts));
        if !row.affected_paths.is_empty() {
            out.push_str(&format!(
                "- **Affected paths**: {}\n",
                row.affected_paths
                    .iter()
                    .map(|p| format!("`{}`", p))
                    .collect::<Vec<_>>()
                    .join(", ")
            ));
        }
        if !row.tags.is_empty() {
            out.push_str(&format!(
                "- **Tags**: {}\n",
                row.tags
                    .iter()
                    .map(|t| format!("`{}`", t))
                    .collect::<Vec<_>>()
                    .join(", ")
            ));
        }
        out.push_str(&format!("- **event_id**: `{}`\n\n", row.event_id));
    }
    out
}

fn render_notes(events: &[edda_core::Event]) -> String {
    let mut out = String::with_capacity(1024);
    out.push_str(HEADER);
    out.push('\n');
    out.push_str("# Notes\n\n");
    let mut sorted: Vec<&edda_core::Event> = events.iter().collect();
    sorted.sort_by(|a, b| a.ts.cmp(&b.ts));
    for ev in sorted {
        let text = ev
            .payload
            .get("text")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        if text.trim().is_empty() {
            continue;
        }
        let (safe, _) = redact(text);
        out.push_str(&format!("### `{}` · `{}`\n\n", ev.ts, ev.event_id));
        out.push_str(&safe);
        out.push_str("\n\n");
    }
    out
}

fn render_index(
    domain_stats: &[(String, usize, PathBuf)],
    notes: Option<&(usize, PathBuf)>,
    out_root: &Path,
) -> String {
    let mut out = String::with_capacity(1024);
    out.push_str(HEADER);
    out.push('\n');
    out.push_str("# Ledger export index\n\n");
    out.push_str("SQLite ledger is the single source of truth. These files exist so ");
    out.push_str("humans can `git diff` a snapshot of decisions and notes.\n\n");
    out.push_str("## Decisions by domain\n\n");
    if domain_stats.is_empty() {
        out.push_str("_(no active decisions yet)_\n\n");
    } else {
        for (domain, count, path) in domain_stats {
            let rel = path.strip_prefix(out_root).unwrap_or(path);
            out.push_str(&format!(
                "- [`{}`](./{}) — {} decision(s)\n",
                domain,
                rel.display().to_string().replace('\\', "/"),
                count
            ));
        }
        out.push('\n');
    }
    if let Some((count, path)) = notes {
        let rel = path.strip_prefix(out_root).unwrap_or(path);
        out.push_str(&format!(
            "## Notes\n\n- [`notes.md`](./{}) — {} note event(s)\n",
            rel.display().to_string().replace('\\', "/"),
            count
        ));
    }
    out
}

/// Write only when content differs — keeps mtime / git status quiet on no-op re-exports.
fn write_if_changed(path: &Path, body: &str) -> Result<()> {
    if let Ok(existing) = fs::read_to_string(path) {
        if existing == body {
            return Ok(());
        }
    }
    fs::write(path, body).with_context(|| format!("write {path:?}"))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use edda_ledger::DecisionView;

    fn dec(key: &str, value: &str, domain: &str, paths: Vec<&str>) -> DecisionView {
        DecisionView {
            event_id: format!("evt_{}", key.replace('.', "_")),
            branch: "main".into(),
            ts: Some("2026-07-08T00:00:00Z".into()),
            key: key.into(),
            value: value.into(),
            reason: "why".into(),
            domain: domain.into(),
            status: "active".into(),
            authority: "human".into(),
            reversibility: "medium".into(),
            affected_paths: paths.into_iter().map(String::from).collect(),
            tags: vec![],
            propagation: "local".into(),
            supersedes_id: None,
            review_after: None,
            village_id: None,
        }
    }

    #[test]
    fn group_by_domain_buckets_and_fallback_unclassified() {
        let rows = vec![
            dec("a.x", "1", "a", vec![]),
            dec("b.x", "1", "b", vec![]),
            dec("orphan", "1", "", vec![]),
        ];
        let grouped = group_by_domain(rows);
        assert_eq!(grouped.get("a").unwrap().len(), 1);
        assert_eq!(grouped.get("b").unwrap().len(), 1);
        assert_eq!(grouped.get("_unclassified").unwrap().len(), 1);
    }

    #[test]
    fn render_domain_names_all_columns_including_affected_paths() {
        let rows = vec![dec("db.engine", "sqlite", "db", vec!["src/db.rs"])];
        let md = render_domain("db", &rows);
        assert!(md.contains("GENERATED FILE"));
        assert!(md.contains("# Domain: `db`"));
        assert!(md.contains("`db.engine`"));
        assert!(md.contains("`sqlite`"));
        assert!(md.contains("`src/db.rs`"));
        assert!(md.contains("`evt_db_engine`"));
    }

    #[test]
    fn render_domain_scrubs_secrets_in_reason() {
        let mut row = dec("test.key", "val", "test", vec![]);
        row.reason = "temp token sk-abcdefghijklmnopqrstuvwxyz012345".into();
        let md = render_domain("test", &[row]);
        assert!(
            md.contains("[REDACTED:openai_api_key]"),
            "export must not leak old secrets: {md}"
        );
        assert!(!md.contains("sk-abcdefghijklmnopqrstuvwxyz012345"));
    }

    #[test]
    fn render_index_lists_domains_with_counts() {
        let stats = vec![
            ("a".to_string(), 2, PathBuf::from("/root/decisions/a.md")),
            ("b".to_string(), 5, PathBuf::from("/root/decisions/b.md")),
        ];
        let md = render_index(&stats, None, Path::new("/root"));
        assert!(md.contains("`a`"));
        assert!(md.contains("2 decision"));
        assert!(md.contains("`b`"));
        assert!(md.contains("5 decision"));
    }

    #[test]
    fn render_index_notes_line_when_present() {
        let notes = Some((7usize, PathBuf::from("/root/notes.md")));
        let md = render_index(&[], notes.as_ref(), Path::new("/root"));
        assert!(md.contains("7 note event"));
        assert!(md.contains("notes.md"));
    }

    #[test]
    fn render_index_no_notes_line_when_absent() {
        let md = render_index(&[], None, Path::new("/root"));
        assert!(!md.contains("notes.md"));
    }

    #[test]
    fn write_if_changed_skips_when_body_matches() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("x.md");
        fs::write(&path, "hello").unwrap();
        let before = fs::metadata(&path).unwrap().modified().unwrap();
        // sleep enough for mtime to change if we did rewrite
        std::thread::sleep(std::time::Duration::from_millis(50));
        write_if_changed(&path, "hello").unwrap();
        let after = fs::metadata(&path).unwrap().modified().unwrap();
        assert_eq!(before, after, "identical body must not rewrite");
    }

    #[test]
    fn write_if_changed_rewrites_when_body_differs() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("y.md");
        fs::write(&path, "old").unwrap();
        write_if_changed(&path, "new").unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "new");
    }

    #[test]
    fn render_domain_output_is_deterministic() {
        let rows = vec![
            dec("z.last", "x", "z", vec![]),
            dec("a.first", "y", "a", vec![]),
        ];
        // Simulate the sort done in execute()
        let mut a = rows.clone();
        a.sort_by(|x, y| x.key.cmp(&y.key));
        let mut b = rows;
        b.sort_by(|x, y| x.key.cmp(&y.key));
        assert_eq!(render_domain("mix", &a), render_domain("mix", &b));
    }
}