use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::live_migrate::plan::{LivePlan, PlanValidationError};
const CHECKSUM_PREFIX: &str = "V1:";
const SHA256_HEX_LEN: usize = 64;
const CHECKSUM_LEN: usize = CHECKSUM_PREFIX.len() + SHA256_HEX_LEN;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PlanFileError {
#[error("plan file I/O at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("plan file at {path} failed to parse as JSON: {source}")]
JsonParse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("plan file serialisation failed: {source}")]
JsonSerialize {
#[source]
source: serde_json::Error,
},
#[error(
"plan file at {path} checksum mismatch: expected {expected}, computed {actual}; \
plan file edited after start — re-generate or abandon and retry"
)]
ChecksumMismatch {
path: PathBuf,
expected: String,
actual: String,
},
#[error("plan file not found at {0}")]
NotFound(PathBuf),
#[error(
"plan file at {0} already exists; live plans are immutable. \
Generate a new plan with a fresh plan_id"
)]
AlreadyExists(PathBuf),
#[error("plan file at {path} failed validation: {source}")]
Validation {
path: PathBuf,
#[source]
source: PlanValidationError,
},
#[error("malformed checksum string `{value}`: {reason}")]
MalformedChecksum { value: String, reason: &'static str },
}
pub fn plan_path(
migrations_root: &Path,
target_database: &str,
plan_id: crate::types::HeerId,
slug: &str,
) -> PathBuf {
let filename = format!("{}_{}.json", plan_id, slug);
migrations_root
.join(target_database)
.join("live")
.join(filename)
}
pub fn write_plan(migrations_root: &Path, plan: &LivePlan) -> Result<PathBuf, PlanFileError> {
let path = plan_path(
migrations_root,
&plan.header.target_database,
plan.header.plan_id,
&plan.header.slug,
);
plan.validate()
.map_err(|source| PlanFileError::Validation {
path: path.clone(),
source,
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| PlanFileError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let bytes = serde_json::to_vec_pretty(plan)
.map_err(|source| PlanFileError::JsonSerialize { source })?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|source| match source.kind() {
std::io::ErrorKind::AlreadyExists => PlanFileError::AlreadyExists(path.clone()),
_ => PlanFileError::Io {
path: path.clone(),
source,
},
})?;
file.write_all(&bytes).map_err(|source| PlanFileError::Io {
path: path.clone(),
source,
})?;
file.sync_all().map_err(|source| PlanFileError::Io {
path: path.clone(),
source,
})?;
Ok(path)
}
pub fn read_plan(plan_file_path: &Path) -> Result<LivePlan, PlanFileError> {
let bytes = read_file_bytes(plan_file_path)?;
let plan: LivePlan =
serde_json::from_slice(&bytes).map_err(|source| PlanFileError::JsonParse {
path: plan_file_path.to_path_buf(),
source,
})?;
plan.validate()
.map_err(|source| PlanFileError::Validation {
path: plan_file_path.to_path_buf(),
source,
})?;
Ok(plan)
}
pub fn compute_checksum(plan_file_path: &Path) -> Result<String, PlanFileError> {
let bytes = read_file_bytes(plan_file_path)?;
Ok(format_checksum(&bytes))
}
pub fn verify_checksum(plan_file_path: &Path, expected: &str) -> Result<(), PlanFileError> {
validate_checksum_shape(expected)?;
let actual = compute_checksum(plan_file_path)?;
if expected == actual {
Ok(())
} else {
Err(PlanFileError::ChecksumMismatch {
path: plan_file_path.to_path_buf(),
expected: expected.to_string(),
actual,
})
}
}
fn read_file_bytes(path: &Path) -> Result<Vec<u8>, PlanFileError> {
let mut file = std::fs::File::open(path).map_err(|source| match source.kind() {
std::io::ErrorKind::NotFound => PlanFileError::NotFound(path.to_path_buf()),
_ => PlanFileError::Io {
path: path.to_path_buf(),
source,
},
})?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|source| PlanFileError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(bytes)
}
fn format_checksum(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut out = String::with_capacity(CHECKSUM_LEN);
out.push_str(CHECKSUM_PREFIX);
for b in digest {
out.push(hex_digit(b >> 4));
out.push(hex_digit(b & 0x0f));
}
debug_assert_eq!(out.len(), CHECKSUM_LEN);
out
}
fn hex_digit(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + (n - 10)) as char,
_ => unreachable!("hex_digit takes a 4-bit nibble"),
}
}
fn validate_checksum_shape(s: &str) -> Result<(), PlanFileError> {
if !s.starts_with(CHECKSUM_PREFIX) {
return Err(PlanFileError::MalformedChecksum {
value: s.to_string(),
reason: "checksum string must start with the `V1:` prefix",
});
}
if s.len() != CHECKSUM_LEN {
return Err(PlanFileError::MalformedChecksum {
value: s.to_string(),
reason: "checksum string must be V1: + 64 hex chars (67 bytes total)",
});
}
let tail = &s.as_bytes()[CHECKSUM_PREFIX.len()..];
for &byte in tail {
let is_lower_hex = byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte);
if !is_lower_hex {
return Err(PlanFileError::MalformedChecksum {
value: s.to_string(),
reason: "checksum hex tail must be lowercase ASCII hex (0-9, a-f)",
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::live_migrate::plan::{
LivePlan, PlanClassification, PlanHeader, Step, StepKind, StepParameters,
};
use crate::types::HeerId;
fn sample_plan() -> LivePlan {
LivePlan {
header: PlanHeader {
plan_id: HeerId::ZERO,
slug: "demo_slug".to_string(),
classification: PlanClassification::ExpandContract,
originating_migration: "V20260428010203__demo".to_string(),
target_database: "main".to_string(),
app_label: "".to_string(),
},
steps: vec![Step {
kind: StepKind::ExpandSchema,
ordinal: 0,
parameters: StepParameters::ExpandSchema {
sql_segments: vec!["ALTER TABLE foo ADD COLUMN bar INT".to_string()],
},
}],
}
}
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!("djogi-plan-file-{label}-{pid}-{nanos}"));
std::fs::create_dir_all(&path).expect("create tempdir");
TempDir(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn plan_path_uses_target_database_live_subdir() {
let path = plan_path(
Path::new("/tmp/migrations"),
"main",
HeerId::ZERO,
"demo_slug",
);
assert_eq!(
path,
PathBuf::from("/tmp/migrations/main/live/0_demo_slug.json")
);
}
#[test]
fn write_plan_then_read_plan_round_trips() {
let tmp = TempDir::new("round-trip");
let plan = sample_plan();
let written = write_plan(tmp.path(), &plan).expect("write plan");
let back = read_plan(&written).expect("read plan");
assert_eq!(back, plan);
}
#[test]
fn write_plan_refuses_overwrite() {
let tmp = TempDir::new("overwrite");
let plan = sample_plan();
let path = write_plan(tmp.path(), &plan).expect("first write");
let err = write_plan(tmp.path(), &plan).expect_err("second write should refuse");
match err {
PlanFileError::AlreadyExists(p) => assert_eq!(p, path),
other => panic!("expected AlreadyExists, got {other:?}"),
}
}
#[test]
fn write_plan_rejects_invalid_plan() {
let tmp = TempDir::new("invalid");
let mut plan = sample_plan();
plan.steps.clear();
let err = write_plan(tmp.path(), &plan).expect_err("validate should fail");
assert!(matches!(err, PlanFileError::Validation { .. }));
}
#[test]
fn compute_checksum_is_deterministic_v1_prefixed_lowercase_hex() {
let tmp = TempDir::new("checksum-shape");
let plan = sample_plan();
let path = write_plan(tmp.path(), &plan).expect("write plan");
let cs1 = compute_checksum(&path).expect("compute 1");
let cs2 = compute_checksum(&path).expect("compute 2");
assert_eq!(cs1, cs2);
assert!(cs1.starts_with("V1:"), "expected V1: prefix; got {cs1}");
assert_eq!(cs1.len(), 67);
let tail = &cs1[3..];
assert!(
tail.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"tail must be lowercase hex; got {cs1}",
);
}
#[test]
fn verify_checksum_accepts_unchanged_file() {
let tmp = TempDir::new("verify-ok");
let plan = sample_plan();
let path = write_plan(tmp.path(), &plan).expect("write plan");
let cs = compute_checksum(&path).expect("compute");
verify_checksum(&path, &cs).expect("verify ok");
}
#[test]
fn verify_checksum_rejects_edited_file() {
let tmp = TempDir::new("verify-edited");
let plan = sample_plan();
let path = write_plan(tmp.path(), &plan).expect("write plan");
let cs = compute_checksum(&path).expect("compute");
let mut f = OpenOptions::new()
.append(true)
.open(&path)
.expect("open append");
f.write_all(b"\n").expect("append byte");
drop(f);
let err = verify_checksum(&path, &cs).expect_err("verify should fail");
match err {
PlanFileError::ChecksumMismatch {
expected, actual, ..
} => {
assert_eq!(expected, cs);
assert_ne!(actual, cs);
}
other => panic!("expected ChecksumMismatch, got {other:?}"),
}
}
#[test]
fn verify_checksum_returns_not_found_for_missing_file() {
let tmp = TempDir::new("verify-missing");
let missing = tmp.path().join("nope.json");
let well_formed = format!("V1:{}", "0".repeat(64));
let err = verify_checksum(&missing, &well_formed).expect_err("missing must fail");
assert!(matches!(err, PlanFileError::NotFound(_)));
}
#[test]
fn verify_checksum_rejects_malformed_expected_string() {
let tmp = TempDir::new("verify-malformed");
let plan = sample_plan();
let path = write_plan(tmp.path(), &plan).expect("write plan");
let err = verify_checksum(&path, "not-a-checksum").expect_err("malformed");
assert!(matches!(err, PlanFileError::MalformedChecksum { .. }));
}
#[test]
fn read_plan_returns_not_found_for_missing_file() {
let tmp = TempDir::new("read-missing");
let err = read_plan(&tmp.path().join("nope.json")).expect_err("missing");
assert!(matches!(err, PlanFileError::NotFound(_)));
}
#[test]
fn read_plan_rejects_malformed_json() {
let tmp = TempDir::new("read-malformed");
let path = tmp.path().join("malformed.json");
std::fs::write(&path, b"{ not json }").expect("write malformed");
let err = read_plan(&path).expect_err("parse should fail");
assert!(matches!(err, PlanFileError::JsonParse { .. }));
}
}