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(¬e_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 {
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
}
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();
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![]),
];
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));
}
}