use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
const LIB_PATH: &str = "src/lib.rs";
const TOMBSTONE_DIR: &str = "docs/tombstones";
const SCHEMA_PREFIX: &str = "pub const SCHEMA_VERSION: &str = \"";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() != 2 {
eprintln!("usage: lifeloop-bump-schema <new-version> \"<reason>\"");
return ExitCode::from(2);
}
let new_version = &args[0];
let reason = &args[1];
match run(
Path::new(LIB_PATH),
Path::new(TOMBSTONE_DIR),
new_version,
reason,
) {
Ok(report) => {
println!("Bumped SCHEMA_VERSION: {} -> {}", report.old, report.new);
println!("Wrote tombstone: {}", report.tombstone_path.display());
println!();
println!("Manual follow-up before commit:");
println!(" 1. Update tests/wire_contract.rs assertions that pin schema_version.");
println!(
" 2. Fill in removal criteria and migration notes in {}.",
report.tombstone_path.display()
);
println!(" 3. Update README.md if it cites the schema version.");
println!(" 4. Run: make verify");
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("error: {err}");
ExitCode::FAILURE
}
}
}
#[derive(Debug)]
struct Report {
old: String,
new: String,
tombstone_path: PathBuf,
}
fn run(
lib_path: &Path,
tombstone_dir: &Path,
new_version: &str,
reason: &str,
) -> io::Result<Report> {
let lib_text = fs::read_to_string(lib_path)?;
let old = extract_schema_version(&lib_text)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("could not find SCHEMA_VERSION in {}", lib_path.display()),
)
})?
.to_string();
if old == new_version {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("new version '{new_version}' is identical to current version"),
));
}
fs::create_dir_all(tombstone_dir)?;
let tombstone_path = tombstone_dir.join(format!("{old}.md"));
if tombstone_path.exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"tombstone {} already exists; refusing to overwrite",
tombstone_path.display()
),
));
}
let probe_path = tombstone_dir.join(format!(".probe.{}", std::process::id()));
fs::write(&probe_path, b"").map_err(|e| {
io::Error::new(
e.kind(),
format!(
"tombstone destination {} is not writable: {e}",
tombstone_dir.display()
),
)
})?;
fs::remove_file(&probe_path)?;
let new_lib_text = rewrite_schema_version(&lib_text, &old, new_version).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"SCHEMA_VERSION line for {} not found in {}",
old,
lib_path.display()
),
)
})?;
let date = utc_today();
let tombstone_content = render_tombstone(&old, new_version, &date, reason);
let pid = std::process::id();
let lib_tmp = sibling_path(lib_path, &format!(".tmp.{pid}"));
let lib_backup = sibling_path(lib_path, &format!(".backup.{pid}"));
let ts_tmp = tombstone_dir.join(format!(".{old}.tmp.{pid}"));
let _cleanup = Cleanup::new(vec![lib_tmp.clone(), ts_tmp.clone(), lib_backup.clone()]);
fs::copy(lib_path, &lib_backup)?;
fs::write(&lib_tmp, &new_lib_text)?;
fs::write(&ts_tmp, &tombstone_content)?;
fs::rename(&lib_tmp, lib_path)?;
if let Err(promote_err) = fs::rename(&ts_tmp, &tombstone_path) {
match fs::rename(&lib_backup, lib_path) {
Ok(()) => Err(io::Error::other(format!(
"tombstone rename failed: {promote_err}; rolled back SCHEMA_VERSION in {}",
lib_path.display()
))),
Err(rollback_err) => Err(io::Error::other(format!(
"CRITICAL: tombstone rename failed ({promote_err}) AND rollback also failed \
({rollback_err}); {} is bumped without a matching tombstone — recover \
manually",
lib_path.display()
))),
}
} else {
Ok(Report {
old,
new: new_version.to_string(),
tombstone_path,
})
}
}
fn extract_schema_version(text: &str) -> Option<&str> {
text.lines().find_map(|line| {
let rest = line.strip_prefix(SCHEMA_PREFIX)?;
let end = rest.find('"')?;
Some(&rest[..end])
})
}
fn rewrite_schema_version(text: &str, old: &str, new: &str) -> Option<String> {
let needle = format!("{SCHEMA_PREFIX}{old}\";");
if !text.contains(&needle) {
return None;
}
let replacement = format!("{SCHEMA_PREFIX}{new}\";");
Some(text.replacen(&needle, &replacement, 1))
}
fn render_tombstone(old: &str, new: &str, date: &str, reason: &str) -> String {
format!(
"# Tombstone: {old}\n\
\n\
**Superseded by:** {new}\n\
**Date:** {date}\n\
**Reason:** {reason}\n\
\n\
## Removal criteria\n\
\n\
<!-- AGENTS.md mandates explicit tombstones with removal criteria, not hidden\n\
compat shims. Fill in a concrete condition under which this tombstone\n\
can be deleted (e.g. \"after 2 quarters with zero {old} traffic in\n\
production receipts\"). -->\n\
TODO\n\
\n\
## Migration\n\
\n\
<!-- Describe what consumers must do to move from {old} to {new}. -->\n\
TODO\n"
)
}
fn utc_today() -> String {
Command::new("date")
.args(["-u", "+%Y-%m-%d"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn sibling_path(path: &Path, suffix: &str) -> PathBuf {
let mut name: OsString = path.file_name().unwrap_or_default().to_owned();
name.push(suffix);
path.with_file_name(name)
}
struct Cleanup {
paths: Vec<PathBuf>,
}
impl Cleanup {
fn new(paths: Vec<PathBuf>) -> Self {
Self { paths }
}
}
impl Drop for Cleanup {
fn drop(&mut self) {
for p in &self.paths {
let _ = fs::remove_file(p);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_LIB: &str = "//! header\n\
\n\
use serde::Serialize;\n\
\n\
pub const SCHEMA_VERSION: &str = \"lifeloop.v0\";\n\
\n\
fn other() {}\n";
#[test]
fn extract_finds_version() {
assert_eq!(extract_schema_version(SAMPLE_LIB), Some("lifeloop.v0"));
}
#[test]
fn extract_returns_none_when_missing() {
assert_eq!(extract_schema_version("// no schema here\n"), None);
}
#[test]
fn rewrite_replaces_version_once() {
let result = rewrite_schema_version(SAMPLE_LIB, "lifeloop.v0", "lifeloop.v1").unwrap();
assert!(result.contains("pub const SCHEMA_VERSION: &str = \"lifeloop.v1\";"));
assert!(!result.contains("\"lifeloop.v0\""));
}
#[test]
fn rewrite_returns_none_when_old_not_present() {
let result = rewrite_schema_version(SAMPLE_LIB, "lifeloop.v9", "lifeloop.v10");
assert!(result.is_none());
}
#[test]
fn render_tombstone_includes_all_fields() {
let s = render_tombstone(
"lifeloop.v0",
"lifeloop.v1",
"2026-05-08",
"renamed turn.ended -> turn.completed",
);
assert!(s.starts_with("# Tombstone: lifeloop.v0\n"));
assert!(s.contains("**Superseded by:** lifeloop.v1\n"));
assert!(s.contains("**Date:** 2026-05-08\n"));
assert!(s.contains("**Reason:** renamed turn.ended -> turn.completed\n"));
assert!(s.contains("## Removal criteria"));
assert!(s.contains("## Migration"));
}
#[test]
fn sibling_path_appends_suffix() {
let p = Path::new("src/lib.rs");
let s = sibling_path(p, ".tmp.123");
assert_eq!(s, PathBuf::from("src/lib.rs.tmp.123"));
}
fn fresh_tempdir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let base = std::env::temp_dir().join(format!(
"lifeloop-bump-schema-test-{}-{}-{seq}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&base).unwrap();
base
}
fn cleanup(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
fn seed_lib(dir: &Path, version: &str) -> PathBuf {
let p = dir.join("lib.rs");
fs::write(
&p,
format!(
"//! header\n\npub const SCHEMA_VERSION: &str = \"{version}\";\n\nfn x() {{}}\n"
),
)
.unwrap();
p
}
#[test]
fn happy_path_bumps_lib_and_writes_tombstone() {
let dir = fresh_tempdir();
let lib = seed_lib(&dir, "lifeloop.v0");
let ts_dir = dir.join("tombstones");
let report = run(&lib, &ts_dir, "lifeloop.v1", "renamed event").unwrap();
assert_eq!(report.old, "lifeloop.v0");
assert_eq!(report.new, "lifeloop.v1");
assert_eq!(report.tombstone_path, ts_dir.join("lifeloop.v0.md"));
let new_lib = fs::read_to_string(&lib).unwrap();
assert!(new_lib.contains("\"lifeloop.v1\""));
assert!(!new_lib.contains("\"lifeloop.v0\""));
let ts = fs::read_to_string(&report.tombstone_path).unwrap();
assert!(ts.contains("# Tombstone: lifeloop.v0"));
assert!(ts.contains("**Reason:** renamed event"));
cleanup(&dir);
}
#[test]
fn refuses_when_versions_match() {
let dir = fresh_tempdir();
let lib = seed_lib(&dir, "lifeloop.v0");
let ts_dir = dir.join("tombstones");
let err = run(&lib, &ts_dir, "lifeloop.v0", "noop").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
let lib_text = fs::read_to_string(&lib).unwrap();
assert!(lib_text.contains("\"lifeloop.v0\""));
assert!(!ts_dir.exists());
cleanup(&dir);
}
#[test]
fn refuses_when_tombstone_already_exists() {
let dir = fresh_tempdir();
let lib = seed_lib(&dir, "lifeloop.v0");
let ts_dir = dir.join("tombstones");
fs::create_dir_all(&ts_dir).unwrap();
let existing = ts_dir.join("lifeloop.v0.md");
fs::write(&existing, "pre-existing\n").unwrap();
let err = run(&lib, &ts_dir, "lifeloop.v1", "x").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert!(
fs::read_to_string(&lib)
.unwrap()
.contains("\"lifeloop.v0\"")
);
assert_eq!(fs::read_to_string(&existing).unwrap(), "pre-existing\n");
cleanup(&dir);
}
#[test]
fn refuses_when_lib_lacks_schema_version() {
let dir = fresh_tempdir();
let lib = dir.join("lib.rs");
fs::write(&lib, "fn nothing() {}\n").unwrap();
let ts_dir = dir.join("tombstones");
let err = run(&lib, &ts_dir, "lifeloop.v1", "x").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
cleanup(&dir);
}
}