use std::path::{Path, PathBuf};
use serde_json::json;
use crate::builder::codegen::{generate_all, GeneratedFile};
use crate::builder::draft::{Draft, DraftError};
use crate::builder::history::{append, HistoryOp};
use crate::builder::lockfile::{BuilderLock, LockError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FileVerdict {
Create,
NoOp,
Mismatch { found_hash: String },
Unowned,
}
#[derive(Debug, Clone)]
pub(crate) struct PlanEntry {
pub path: PathBuf,
pub verdict: FileVerdict,
pub schema_hash: String,
}
#[derive(Debug, Clone)]
pub(crate) struct PlanReport {
pub project_root: PathBuf,
pub entries: Vec<PlanEntry>,
pub migration: Option<PlanEntry>,
}
impl PlanReport {
pub(crate) fn is_no_op(&self) -> bool {
self.entries
.iter()
.all(|e| matches!(e.verdict, FileVerdict::NoOp))
&& self
.migration
.as_ref()
.map(|m| matches!(m.verdict, FileVerdict::NoOp))
.unwrap_or(true)
}
pub(crate) fn writable_entries(&self) -> Vec<&PlanEntry> {
let mut v: Vec<&PlanEntry> = self
.entries
.iter()
.filter(|e| !matches!(e.verdict, FileVerdict::NoOp))
.collect();
if let Some(m) = &self.migration {
if !matches!(m.verdict, FileVerdict::NoOp) {
v.push(m);
}
}
v
}
pub(crate) fn mismatched_entries(&self) -> Vec<&PlanEntry> {
let mut v: Vec<&PlanEntry> = self
.entries
.iter()
.filter(|e| {
matches!(
e.verdict,
FileVerdict::Mismatch { .. } | FileVerdict::Unowned
)
})
.collect();
if let Some(m) = &self.migration {
if matches!(
m.verdict,
FileVerdict::Mismatch { .. } | FileVerdict::Unowned
) {
v.push(m);
}
}
v
}
}
#[derive(Debug)]
pub(crate) enum LifecycleError {
Io(std::io::Error),
Draft(DraftError),
Lock(LockError),
NotInProject {
cwd: PathBuf,
},
SymlinkedRustioDir {
path: PathBuf,
},
IncrementalMigrationOutOfScope,
RefusedMismatch {
mismatched: Vec<PathBuf>,
},
DraftRead(PathBuf, std::io::Error),
DraftMissing(PathBuf),
DraftDrift,
}
impl std::fmt::Display for LifecycleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LifecycleError::Io(e) => write!(f, "I/O error: {e}"),
LifecycleError::Draft(e) => write!(f, "{e}"),
LifecycleError::Lock(e) => write!(f, "{e}"),
LifecycleError::NotInProject { cwd } => write!(
f,
"no .rustio/ directory found at {} or any parent. Run `rustio-admin new <name>` first.",
cwd.display()
),
LifecycleError::SymlinkedRustioDir { path } => write!(
f,
"refused: {} is a symlink. The Builder will not follow a symlinked .rustio/ directory; \
the link could redirect generator-owned state outside the project tree.",
path.display()
),
LifecycleError::IncrementalMigrationOutOfScope => write!(
f,
"migrations/ contains existing files and draft.toml has diverged. \
Incremental migrations are out of MVP scope. \
Either reset .rustio/ or wait for an incremental-migration release."
),
LifecycleError::RefusedMismatch { mismatched } => {
writeln!(
f,
"{} generated file(s) carry a SchemaHash that does not match the current draft:",
mismatched.len()
)?;
for p in mismatched {
writeln!(f, " - {}", p.display())?;
}
write!(
f,
"DESIGN_BUILDER.md §5.4 forbids silent overwrite. Re-run with `--force` \
to acknowledge that prior content will be moved to .rustio/forced/."
)
}
LifecycleError::DraftRead(p, e) => write!(f, "could not read {}: {e}", p.display()),
LifecycleError::DraftMissing(p) => {
write!(f, "{} not found. Is this a rustio-admin project?", p.display())
}
LifecycleError::DraftDrift => write!(
f,
"the on-disk .rustio/draft.toml has been hand-edited. \
Reconcile with the event log before running this command \
(rustio-admin status / rustio-admin merge -- out of MVP scope)."
),
}
}
}
impl std::error::Error for LifecycleError {}
impl From<std::io::Error> for LifecycleError {
fn from(e: std::io::Error) -> Self {
LifecycleError::Io(e)
}
}
impl From<DraftError> for LifecycleError {
fn from(e: DraftError) -> Self {
LifecycleError::Draft(e)
}
}
impl From<LockError> for LifecycleError {
fn from(e: LockError) -> Self {
LifecycleError::Lock(e)
}
}
pub(crate) fn find_project_root(start: &Path) -> Result<PathBuf, LifecycleError> {
let mut cur = if start.is_absolute() {
start.to_path_buf()
} else {
std::env::current_dir()
.map(|c| c.join(start))
.unwrap_or_else(|_| start.to_path_buf())
};
loop {
let candidate = cur.join(".rustio");
if candidate.is_dir() {
match std::fs::symlink_metadata(&candidate) {
Ok(md) if md.file_type().is_symlink() => {
return Err(LifecycleError::SymlinkedRustioDir { path: candidate });
}
_ => return Ok(cur),
}
}
match cur.parent() {
Some(p) if p != cur => cur = p.to_path_buf(),
_ => {
return Err(LifecycleError::NotInProject {
cwd: start.to_path_buf(),
});
}
}
}
}
fn load_draft_and_verify(project_root: &Path) -> Result<Draft, LifecycleError> {
let lock_path = project_root.join(".rustio/builder.lock");
let lock_text = std::fs::read_to_string(&lock_path)
.map_err(|e| LifecycleError::DraftRead(lock_path.clone(), e))?;
let lock = BuilderLock::from_toml(&lock_text)?;
lock.verify_against_running()?;
let draft_path = project_root.join(".rustio/draft.toml");
if !draft_path.exists() {
return Err(LifecycleError::DraftMissing(draft_path));
}
let draft_text = std::fs::read_to_string(&draft_path)
.map_err(|e| LifecycleError::DraftRead(draft_path.clone(), e))?;
let draft = Draft::from_toml(&draft_text)?;
if draft.to_toml() != draft_text {
return Err(LifecycleError::DraftDrift);
}
Ok(draft)
}
pub(crate) fn plan(start: &Path) -> Result<PlanReport, LifecycleError> {
let project_root = find_project_root(start)?;
let draft = load_draft_and_verify(&project_root)?;
let files = generate_all(&draft);
let migrations_dir = project_root.join("migrations");
let migrations_existing = list_existing_migrations(&migrations_dir)?;
let mut entries = Vec::new();
let mut migration = None;
for file in files {
let is_migration = file
.path
.components()
.any(|c| c.as_os_str() == "migrations");
let target = project_root.join(&file.path);
let verdict = classify(&target, &file);
let entry = PlanEntry {
path: file.path.clone(),
verdict,
schema_hash: file.schema_hash.clone(),
};
if is_migration {
if !migrations_existing.is_empty() && !matches!(entry.verdict, FileVerdict::NoOp) {
return Err(LifecycleError::IncrementalMigrationOutOfScope);
}
migration = Some(entry);
} else {
entries.push(entry);
}
}
Ok(PlanReport {
project_root,
entries,
migration,
})
}
fn classify(target: &Path, file: &GeneratedFile) -> FileVerdict {
if !target.exists() {
return FileVerdict::Create;
}
let existing = match std::fs::read_to_string(target) {
Ok(s) => s,
Err(_) => return FileVerdict::Unowned,
};
if existing == file.content {
return FileVerdict::NoOp;
}
if let Some(found) = parse_header_hash(&existing) {
if found == file.schema_hash {
return FileVerdict::Mismatch { found_hash: found };
}
return FileVerdict::Mismatch { found_hash: found };
}
FileVerdict::Unowned
}
fn parse_header_hash(content: &str) -> Option<String> {
for line in content.lines().take(20) {
let payload = if let Some(rest) = line.strip_prefix("// ") {
rest
} else if let Some(rest) = line.strip_prefix("-- ") {
rest
} else {
continue;
};
let Some(hash) = payload.strip_prefix("SPDX-SchemaHash: ") else {
continue;
};
if is_valid_sha256_marker(hash) {
return Some(hash.to_string());
}
return None;
}
None
}
fn is_valid_sha256_marker(s: &str) -> bool {
let Some(hex) = s.strip_prefix("sha256:") else {
return false;
};
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn list_existing_migrations(dir: &Path) -> Result<Vec<PathBuf>, LifecycleError> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.path().extension().and_then(|e| e.to_str()) == Some("sql") {
out.push(entry.path());
}
}
out.sort();
Ok(out)
}
pub(crate) fn commit(
start: &Path,
force: bool,
actor: &str,
) -> Result<CommitResult, LifecycleError> {
let report = plan(start)?;
if report.is_no_op() {
return Ok(CommitResult::NoOp);
}
if !force {
let mismatched = report.mismatched_entries();
if !mismatched.is_empty() {
return Err(LifecycleError::RefusedMismatch {
mismatched: mismatched.iter().map(|e| e.path.clone()).collect(),
});
}
}
let project_root = report.project_root.clone();
let txn_id = crate::builder::ulid_gen::new_ulid();
let tmp = project_root.join(".rustio/tmp").join(&txn_id);
std::fs::create_dir_all(&tmp)?;
let draft = load_draft_and_verify(&project_root)?;
let files = generate_all(&draft);
let mut staged: Vec<(PathBuf, PathBuf)> = Vec::new(); for file in &files {
let final_path = project_root.join(&file.path);
if let Some(entry) = report
.entries
.iter()
.chain(report.migration.as_ref())
.find(|e| e.path == file.path)
{
if matches!(entry.verdict, FileVerdict::NoOp) {
continue;
}
}
let tmp_path = tmp.join(&file.path);
if let Some(parent) = tmp_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&tmp_path, &file.content)?;
staged.push((final_path, tmp_path));
}
let forced_dir = if force {
let ts = crate::builder::ulid_gen::new_ulid();
Some(project_root.join(".rustio/forced").join(ts))
} else {
None
};
let mut written: Vec<PathBuf> = Vec::new();
for (final_path, tmp_path) in &staged {
if let Some(parent) = final_path.parent() {
std::fs::create_dir_all(parent)?;
}
if force && final_path.exists() {
if let Some(quarantine_root) = &forced_dir {
let rel = final_path.strip_prefix(&project_root).unwrap_or(final_path);
let dest = quarantine_root.join(rel);
if let Some(p) = dest.parent() {
std::fs::create_dir_all(p)?;
}
std::fs::copy(final_path, &dest)?;
}
}
std::fs::rename(tmp_path, final_path)?;
written.push(final_path.clone());
}
let _ = std::fs::remove_dir_all(&tmp);
let history_path = project_root.join(".rustio/history.jsonl");
let files_rel: Vec<String> = written
.iter()
.filter_map(|p| {
p.strip_prefix(&project_root)
.ok()
.map(|r| r.to_string_lossy().to_string())
})
.collect();
let id = append(
&history_path,
HistoryOp::Commit,
actor,
json!({
"files": files_rel,
"txn": txn_id,
}),
)?;
Ok(CommitResult::Wrote {
event_id: id,
files: written,
})
}
#[derive(Debug)]
pub(crate) enum CommitResult {
NoOp,
Wrote {
event_id: String,
files: Vec<PathBuf>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::draft::{Field, Model, Project};
use crate::builder::history::{append, HistoryOp};
use crate::builder::lockfile::BuilderLock;
use crate::builder::ulid_gen::new_ulid;
use serde_json::json;
fn tempdir() -> PathBuf {
let base = std::env::temp_dir().join(format!(
"rustio-lifecycle-test-{}-{}",
std::process::id(),
new_ulid()
));
std::fs::create_dir_all(&base).unwrap();
base
}
fn bootstrap_project(models: Vec<Model>) -> PathBuf {
let root = tempdir();
let rustio_dir = root.join(".rustio");
std::fs::create_dir_all(&rustio_dir).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(
rustio_dir.join("builder.lock"),
BuilderLock::current().to_toml(),
)
.unwrap();
let actor = "test@example.com";
let history_path = rustio_dir.join("history.jsonl");
append(
&history_path,
HistoryOp::ProjectInit,
actor,
json!({
"name": "demo",
"rust_version": "1.88",
"builder_pinned": env!("CARGO_PKG_VERSION"),
"created_at": "2026-05-15T10:30:00Z",
}),
)
.unwrap();
for m in &models {
append(
&history_path,
HistoryOp::AddModel,
actor,
json!({"name": m.name, "table": m.table}),
)
.unwrap();
for f in &m.fields {
append(
&history_path,
HistoryOp::AddField,
actor,
json!({
"model": m.name,
"name": f.name,
"type": f.r#type,
"required": f.required,
"unique": f.unique,
}),
)
.unwrap();
}
}
let draft = Draft {
schema_version: 1,
project: Project {
name: "demo".into(),
rust_version: "1.88".into(),
builder_pinned: env!("CARGO_PKG_VERSION").into(),
created_at: "2026-05-15T10:30:00Z".into(),
},
models,
};
std::fs::write(rustio_dir.join("draft.toml"), draft.to_toml()).unwrap();
root
}
fn sample_model() -> Model {
Model {
name: "Patient".into(),
table: "patients".into(),
fields: vec![Field {
name: "full_name".into(),
r#type: "text".into(),
required: true,
unique: false,
}],
}
}
#[test]
fn plan_on_fresh_project_lists_all_creates() {
let root = bootstrap_project(vec![sample_model()]);
let report = plan(&root).unwrap();
assert!(!report.is_no_op());
let creates: Vec<_> = report
.entries
.iter()
.filter(|e| matches!(e.verdict, FileVerdict::Create))
.collect();
assert_eq!(creates.len(), 4, "{:?}", report.entries);
assert!(report.migration.is_some());
assert!(matches!(
report.migration.as_ref().unwrap().verdict,
FileVerdict::Create
));
}
#[test]
fn commit_then_replan_is_no_op() {
let root = bootstrap_project(vec![sample_model()]);
let r = commit(&root, false, "alice@example.com").unwrap();
assert!(matches!(r, CommitResult::Wrote { .. }));
let report = plan(&root).unwrap();
assert!(
report.is_no_op(),
"plan after commit should be no-op: {report:?}"
);
}
#[test]
fn commit_is_idempotent() {
let root = bootstrap_project(vec![sample_model()]);
commit(&root, false, "alice@example.com").unwrap();
let history_before = std::fs::read_to_string(root.join(".rustio/history.jsonl")).unwrap();
let r = commit(&root, false, "alice@example.com").unwrap();
assert!(matches!(r, CommitResult::NoOp));
let history_after = std::fs::read_to_string(root.join(".rustio/history.jsonl")).unwrap();
assert_eq!(
history_before, history_after,
"no-op commit must not append to history.jsonl"
);
}
#[test]
fn commit_writes_every_generated_file() {
let root = bootstrap_project(vec![sample_model()]);
commit(&root, false, "alice@example.com").unwrap();
for rel in [
"src/_generated/mod.rs",
"src/_generated/admin.rs",
"src/_generated/models/mod.rs",
"src/_generated/models/patient.rs",
"migrations/0001_initial.sql",
] {
assert!(
root.join(rel).exists(),
"expected {rel} to exist after commit"
);
}
}
#[test]
fn commit_refuses_mismatched_generated_file_without_force() {
let root = bootstrap_project(vec![sample_model()]);
commit(&root, false, "alice@example.com").unwrap();
let path = root.join("src/_generated/models/patient.rs");
let edited = std::fs::read_to_string(&path)
.unwrap()
.replace("SPDX-SchemaHash: sha256:", "SPDX-SchemaHash: sha256:0000")
+ "\n// developer hand-edit\n";
std::fs::write(&path, edited).unwrap();
let err = commit(&root, false, "alice@example.com")
.expect_err("must refuse mismatched overwrite without --force");
assert!(
matches!(err, LifecycleError::RefusedMismatch { .. }),
"{err:?}"
);
}
#[test]
fn force_overwrite_quarantines_prior_content() {
let root = bootstrap_project(vec![sample_model()]);
commit(&root, false, "alice@example.com").unwrap();
let path = root.join("src/_generated/models/patient.rs");
let edited = std::fs::read_to_string(&path)
.unwrap()
.replace("SPDX-SchemaHash: sha256:", "SPDX-SchemaHash: sha256:dead")
+ "\n// developer hand-edit\n";
std::fs::write(&path, edited).unwrap();
commit(&root, true, "alice@example.com").expect("--force must succeed");
let forced_dir = root.join(".rustio/forced");
assert!(forced_dir.exists(), "forced/ must be created");
let mut found = false;
for entry in std::fs::read_dir(&forced_dir).unwrap() {
let p = entry
.unwrap()
.path()
.join("src/_generated/models/patient.rs");
if p.exists()
&& std::fs::read_to_string(&p)
.unwrap()
.contains("developer hand-edit")
{
found = true;
}
}
assert!(found, "prior content not quarantined to .rustio/forced/");
}
#[test]
fn incremental_migration_refused() {
let root = bootstrap_project(vec![sample_model()]);
commit(&root, false, "alice@example.com").unwrap();
let history_path = root.join(".rustio/history.jsonl");
append(
&history_path,
HistoryOp::AddModel,
"alice@example.com",
json!({"name": "Doctor", "table": "doctors"}),
)
.unwrap();
let replayed = crate::builder::replay::replay_from_file(&history_path).unwrap();
std::fs::write(root.join(".rustio/draft.toml"), replayed.to_toml()).unwrap();
let err = commit(&root, false, "alice@example.com").expect_err("must refuse");
assert!(
matches!(err, LifecycleError::IncrementalMigrationOutOfScope),
"{err:?}"
);
}
#[test]
fn find_project_root_walks_up() {
let root = bootstrap_project(vec![]);
let deep = root.join("src/_generated/models");
std::fs::create_dir_all(&deep).unwrap();
let found = find_project_root(&deep).unwrap();
assert_eq!(found, root);
}
#[test]
fn find_project_root_refuses_outside() {
let dir = tempdir(); let err = find_project_root(&dir).expect_err("must refuse");
assert!(matches!(err, LifecycleError::NotInProject { .. }));
}
#[test]
#[cfg(unix)]
fn find_project_root_refuses_symlinked_rustio() {
use std::os::unix::fs::symlink;
let dir = tempdir();
let real = tempdir();
std::fs::create_dir_all(real.join(".rustio")).unwrap();
symlink(real.join(".rustio"), dir.join(".rustio")).unwrap();
let err = find_project_root(&dir).expect_err("must refuse symlinked .rustio");
assert!(
matches!(err, LifecycleError::SymlinkedRustioDir { .. }),
"{err:?}"
);
}
#[test]
fn parse_header_hash_accepts_canonical_only() {
let rust = "// @generated by rustio 0.15.1 from .rustio/draft.toml\n\
// SPDX-SchemaHash: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n\
// SPDX-EmitterVersion: rio-canon-1\n";
assert_eq!(
parse_header_hash(rust),
Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into())
);
let sql = "-- @generated by rustio 0.15.1 from .rustio/draft.toml\n\
-- SPDX-SchemaHash: sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\n";
assert_eq!(
parse_header_hash(sql),
Some("sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".into())
);
}
#[test]
fn parse_header_hash_refuses_nested_prefix() {
let nested = "//! SPDX-SchemaHash: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n";
assert_eq!(parse_header_hash(nested), None);
let tripled = "/// SPDX-SchemaHash: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n";
assert_eq!(parse_header_hash(tripled), None);
}
#[test]
fn parse_header_hash_refuses_malformed_hash() {
let bad_prefix = "// SPDX-SchemaHash: md5:abc\n";
assert_eq!(parse_header_hash(bad_prefix), None);
let bad_len = "// SPDX-SchemaHash: sha256:abc\n";
assert_eq!(parse_header_hash(bad_len), None);
let uppercase = "// SPDX-SchemaHash: sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789\n";
assert_eq!(parse_header_hash(uppercase), None, "case-sensitive hash");
}
#[test]
fn parse_header_hash_only_first_20_lines() {
let mut lines = String::new();
for _ in 0..30 {
lines.push_str("// filler\n");
}
lines.push_str("// SPDX-SchemaHash: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n");
assert_eq!(parse_header_hash(&lines), None);
}
#[test]
fn lockfile_version_mismatch_refused() {
let root = bootstrap_project(vec![sample_model()]);
let mut lock = BuilderLock::current();
lock.builder = "99.99.99".into();
std::fs::write(root.join(".rustio/builder.lock"), lock.to_toml()).unwrap();
let err = plan(&root).expect_err("must refuse");
assert!(matches!(err, LifecycleError::Lock(_)), "{err:?}");
}
}