use crate::cli::App;
use crate::errors::{err, ErrorCode};
use anyhow::Result;
use chrono::Utc;
use std::path::PathBuf;
use uuid::Uuid;
pub struct BackfillArgs {
pub base: Option<String>,
pub since: Option<String>,
pub include_docs: bool,
}
#[derive(Debug)]
struct CommitRow {
oid: String,
author_email: String,
date: String, subject: String,
body: String,
}
fn parse_since(s: &str) -> Option<String> {
let (num, unit) = s.split_at(s.len().saturating_sub(1));
let n: i64 = num.parse().ok()?;
let arg = match unit {
"y" => format!("{n} years ago"),
"m" => format!("{n} months ago"),
"w" => format!("{n} weeks ago"),
"d" => format!("{n} days ago"),
_ => return None,
};
Some(arg)
}
fn drafts_dir(app: &App, run_id: &str) -> PathBuf {
app.repo
.state_dir()
.join("drafts")
.join("backfill")
.join(run_id)
}
pub fn run(app: &App, args: &BackfillArgs) -> Result<()> {
let mut log_args: Vec<String> = vec![
"log".into(),
"--no-merges".into(),
"--date=iso-strict".into(),
"--format=%H%x1f%ae%x1f%aI%x1f%s%x1f%b%x1e".into(),
];
if let Some(since) = &args.since {
let since_arg = parse_since(since).ok_or_else(|| {
err(
ErrorCode::InvalidRecord,
"--since expects e.g. 2y, 6m, 12w, 90d",
)
})?;
log_args.push(format!("--since={since_arg}"));
}
if let Some(base) = &args.base {
log_args.push(base.clone());
}
let raw = app
.repo
.run(&log_args.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
let mut commits: Vec<CommitRow> = Vec::new();
for chunk in raw.split('\u{1e}') {
let chunk = chunk.trim();
if chunk.is_empty() {
continue;
}
let f: Vec<&str> = chunk.split('\u{1f}').collect();
if f.len() >= 4 {
commits.push(CommitRow {
oid: f[0].trim().to_string(),
author_email: f[1].to_string(),
date: f[2].to_string(),
subject: f[3].to_string(),
body: f.get(4).unwrap_or(&"").to_string(),
});
}
}
if commits.is_empty() {
return Err(err(
ErrorCode::InvalidRecord,
"no commits found for the requested range",
));
}
commits.reverse();
let mut clusters: Vec<Vec<&CommitRow>> = Vec::new();
for c in &commits {
let joined = if let Some(last) = clusters.last_mut() {
let prev = last.last().unwrap();
let close = chrono::DateTime::parse_from_rfc3339(&prev.date)
.and_then(|p| chrono::DateTime::parse_from_rfc3339(&c.date).map(|n| (p, n)))
.map(|(p, n)| (n - p).num_hours().abs() <= 4)
.unwrap_or(false);
if prev.author_email == c.author_email && close {
last.push(c);
true
} else {
false
}
} else {
false
};
if !joined {
clusters.push(vec![c]);
}
}
let run_id = format!("{}", Uuid::now_v7().simple());
let dir = drafts_dir(app, &run_id);
std::fs::create_dir_all(&dir)?;
let cap = app.config.backfill.max_candidates_per_cluster as usize;
let writer = app.writer_id()?;
let mut written = 0usize;
for cluster in clusters.iter().take(500) {
let lead = cluster[0];
let last_oid = cluster.last().unwrap().oid.as_str();
let paths = app
.repo
.run(&["diff", "--name-only", &format!("{}~1", lead.oid), last_oid])
.or_else(|_| {
app.repo
.run(&["diff", "--name-only", crate::gitx::EMPTY_TREE_OID, last_oid])
})
.unwrap_or_default();
let mut scopes: Vec<String> = paths
.lines()
.filter(|p| !p.starts_with(".memlay/"))
.map(|p| {
let parts: Vec<&str> = p.split('/').collect();
if parts.len() > 2 {
parts[..2].join("/")
} else {
parts[0].to_string()
}
})
.collect();
scopes.sort();
scopes.dedup();
if scopes.is_empty() {
continue;
}
let id = Uuid::now_v7();
let mut draft = String::from(
"# Backfill draft: review before applying. Not canonical, not committed.\nmemlay 1\n",
);
draft.push_str(&format!(
"id {id}\nkey change.{id}\nkind change\nop assert\n"
));
draft.push_str(&format!("summary {}\n", lead.subject.replace('\n', " ")));
draft.push_str(
"rationale TODO: confirm why (extracted commits below are evidence, not rationale)\n",
);
draft.push_str("confidence inferred\n");
draft.push_str(&format!(
"created-at {}\n",
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
));
draft.push_str(&format!("writer {writer}\n"));
draft.push_str(&format!("human {}\n", lead.author_email));
for c in cluster.iter().skip(1).take(cap) {
draft.push_str(&format!(
"detail commit: {}\n",
c.subject.replace('\n', " ")
));
}
for line in lead.body.lines().take(3) {
let line = line.trim();
if !line.is_empty() {
draft.push_str(&format!("detail {}\n", line));
}
}
for s in scopes.iter().take(6) {
draft.push_str(&format!("path {s}\n"));
}
for c in cluster.iter().take(cap) {
draft.push_str(&format!("evidence commit {}\n", c.oid));
}
draft.push_str("x-origin backfill\n");
draft.push_str(&format!("x-backfill-run {run_id}\n"));
std::fs::write(dir.join(format!("{id}.mly")), draft)?;
written += 1;
}
if args.include_docs || app.config.backfill.include_docs_by_default {
for adr_root in ["docs/adr", "docs/decisions", "architecture/decisions"] {
let root = app
.repo
.root
.join(adr_root.replace('/', std::path::MAIN_SEPARATOR_STR));
if !root.is_dir() {
continue;
}
for entry in std::fs::read_dir(&root)? {
let path = entry?.path();
if !path.extension().is_some_and(|e| e == "md") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap_or_default();
let title = text
.lines()
.find(|l| l.starts_with('#'))
.map(|l| l.trim_start_matches('#').trim().to_string())
.unwrap_or_else(|| {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
if title.is_empty() {
continue;
}
let rel = path
.strip_prefix(&app.repo.root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let slug: String = title
.to_ascii_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.take(6)
.collect::<Vec<_>>()
.join("-");
let id = Uuid::now_v7();
let mut draft = String::from(
"# Backfill draft from ADR document: review before applying.\nmemlay 1\n",
);
draft.push_str(&format!(
"id {id}\nkey decision.adr.{slug}\nkind decision\nop assert\n"
));
draft.push_str(&format!("summary {title}\n"));
draft.push_str(
"rationale TODO: extract the decision rationale from the linked document\n",
);
draft.push_str("confidence inferred\n");
draft.push_str(&format!(
"created-at {}\n",
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
));
draft.push_str(&format!("writer {writer}\n"));
draft.push_str(&format!("path {rel}\n"));
draft.push_str(&format!("evidence path {rel}\n"));
draft.push_str("x-origin backfill\n");
draft.push_str(&format!("x-backfill-run {run_id}\n"));
std::fs::write(dir.join(format!("{id}.mly")), draft)?;
written += 1;
}
}
}
if app.json {
println!(
"{}",
serde_json::json!({ "run_id": run_id, "drafts": written, "dir": dir.to_string_lossy() })
);
} else {
println!(
"Backfill run {run_id}: {written} draft(s) in {}",
dir.display()
);
println!("Review with: memlay backfill review {run_id}");
println!("Apply one with: memlay record --from-mly <draft-path>");
}
Ok(())
}
fn run_dir(app: &App, run_id: Option<&str>) -> Result<PathBuf> {
let base = app.repo.state_dir().join("drafts").join("backfill");
match run_id {
Some(r) => {
let dir = base.join(r);
if dir.is_dir() {
Ok(dir)
} else {
Err(err(
ErrorCode::BackfillDraftInvalid,
format!("unknown backfill run '{r}'"),
))
}
}
None => {
let mut runs: Vec<PathBuf> = std::fs::read_dir(&base)
.into_iter()
.flatten()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_dir())
.collect();
runs.sort();
runs.pop()
.ok_or_else(|| err(ErrorCode::BackfillDraftInvalid, "no backfill runs exist"))
}
}
}
pub fn apply(app: &App, run_id: Option<&str>, selected: &[String]) -> Result<()> {
let dir = run_dir(app, run_id)?;
let mut applied = 0usize;
let mut refused: Vec<String> = Vec::new();
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "mly"))
.collect();
entries.sort();
for path in entries {
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !selected.is_empty() && !selected.iter().any(|s| name.starts_with(s.as_str())) {
continue;
}
let bytes = std::fs::read(&path)?;
if String::from_utf8_lossy(&bytes).contains("TODO:") {
refused.push(format!(
"{name}: still contains TODO fields; edit before applying"
));
continue;
}
match crate::records::mrf::parse(&bytes) {
Ok(record) => match crate::records::store::create(&app.repo.root, &record) {
Ok(rel) => {
applied += 1;
std::fs::remove_file(&path).ok();
if !app.json {
println!("applied {name} -> {rel}");
}
}
Err(e) => refused.push(format!("{name}: {e}")),
},
Err(e) => refused.push(format!("{name}: {e}")),
}
}
if app.json {
println!(
"{}",
serde_json::json!({ "applied": applied, "refused": refused })
);
} else {
println!("{applied} draft(s) applied, {} refused", refused.len());
for r in &refused {
println!(" {r}");
}
}
Ok(())
}
pub fn reject(app: &App, run_id: Option<&str>, selected: &[String]) -> Result<()> {
let dir = run_dir(app, run_id)?;
let mut removed = 0usize;
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if !path.extension().is_some_and(|e| e == "mly") {
continue;
}
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if selected.is_empty() || selected.iter().any(|s| name.starts_with(s.as_str())) {
std::fs::remove_file(&path)?;
removed += 1;
}
}
if std::fs::read_dir(&dir)?.next().is_none() {
std::fs::remove_dir(&dir).ok();
}
if !app.json {
println!("{removed} draft(s) rejected");
}
Ok(())
}
pub fn review(app: &App, run_id: Option<&str>) -> Result<()> {
let base = app.repo.state_dir().join("drafts").join("backfill");
if !base.exists() {
println!("No backfill runs.");
return Ok(());
}
let mut runs: Vec<PathBuf> = std::fs::read_dir(&base)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_dir())
.collect();
runs.sort();
let selected = match run_id {
Some(r) => vec![base.join(r)],
None => runs.last().cloned().into_iter().collect(),
};
for run in selected {
if !run.is_dir() {
return Err(err(ErrorCode::BackfillDraftInvalid, "unknown backfill run"));
}
println!(
"run {}",
run.file_name().unwrap_or_default().to_string_lossy()
);
let mut entries: Vec<PathBuf> = std::fs::read_dir(&run)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "mly"))
.collect();
entries.sort();
for path in entries {
let text = std::fs::read_to_string(&path).unwrap_or_default();
let summary = text
.lines()
.find(|l| l.starts_with("summary "))
.map(|l| &l[8..])
.unwrap_or("?");
let todo = if text.contains("TODO:") {
" [needs edit]"
} else {
""
};
println!(
" {}{} :: {}",
path.file_name().unwrap_or_default().to_string_lossy(),
todo,
summary
);
}
}
Ok(())
}