pub mod receipt;
pub mod tx;
use crate::git::GitRepo;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn generate_op_id() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap();
let secs = now.as_secs();
let datetime =
chrono::DateTime::from_timestamp(secs as i64, 0).unwrap_or_else(chrono::Utc::now);
let timestamp = datetime.format("%Y%m%dT%H%M%SZ").to_string();
let random: u32 = rand_suffix();
let suffix = format!("{:06x}", random & 0xFFFFFF);
format!("{}-{}", timestamp, suffix)
}
fn rand_suffix() -> u32 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
let mut hasher = DefaultHasher::new();
SystemTime::now().hash(&mut hasher);
std::process::id().hash(&mut hasher);
hasher.finish() as u32
}
pub fn ops_dir(git_dir: &Path) -> PathBuf {
git_dir.join("stax").join("ops")
}
pub fn ensure_ops_dir(git_dir: &Path) -> Result<PathBuf> {
let dir = ops_dir(git_dir);
std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create ops directory: {}", dir.display()))?;
Ok(dir)
}
pub fn backup_ref_prefix(op_id: &str) -> String {
format!("refs/stax/backups/{}/", op_id)
}
pub fn backup_ref_name(op_id: &str, branch: &str) -> String {
format!("refs/stax/backups/{}/{}", op_id, branch)
}
pub fn create_backup_ref(workdir: &Path, op_id: &str, branch: &str, oid: &str) -> Result<()> {
let ref_name = backup_ref_name(op_id, branch);
let status = Command::new("git")
.args(["update-ref", &ref_name, oid])
.current_dir(workdir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("Failed to run git update-ref")?;
if !status.success() {
anyhow::bail!("Failed to create backup ref {} -> {}", ref_name, oid);
}
Ok(())
}
pub fn delete_backup_refs(repo: &GitRepo, op_id: &str) -> Result<()> {
let prefix = backup_ref_prefix(op_id);
let workdir = repo.workdir()?;
let output = Command::new("git")
.args([
"for-each-ref",
"--format=%(refname)",
&format!("{}*", prefix.trim_end_matches('/')),
])
.current_dir(workdir)
.output()
.context("Failed to list backup refs")?;
if !output.status.success() {
return Ok(()); }
let refs = String::from_utf8_lossy(&output.stdout);
for ref_name in refs.lines() {
if ref_name.is_empty() {
continue;
}
let _ = repo.delete_ref(ref_name);
}
Ok(())
}
pub fn list_op_ids(git_dir: &Path) -> Result<Vec<String>> {
let dir = ops_dir(git_dir);
if !dir.exists() {
return Ok(Vec::new());
}
let mut ops: Vec<String> = std::fs::read_dir(&dir)?
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".json") {
Some(name.trim_end_matches(".json").to_string())
} else {
None
}
})
.collect();
ops.sort();
ops.reverse();
Ok(ops)
}
pub fn latest_op_id(git_dir: &Path) -> Result<Option<String>> {
let ops = list_op_ids(git_dir)?;
Ok(ops.into_iter().next())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_generate_op_id_format() {
let id = generate_op_id();
assert!(id.contains('-'));
assert!(id.len() > 20);
assert!(id.contains('Z'));
}
#[test]
fn test_generate_op_id_unique() {
let id1 = generate_op_id();
let id2 = generate_op_id();
assert_ne!(id1, id2);
}
#[test]
fn test_backup_ref_name() {
let ref_name = backup_ref_name("20251229T120500Z-abc123", "feature/foo");
assert_eq!(
ref_name,
"refs/stax/backups/20251229T120500Z-abc123/feature/foo"
);
}
#[test]
fn test_backup_ref_prefix() {
let prefix = backup_ref_prefix("20251229T120500Z-abc123");
assert_eq!(prefix, "refs/stax/backups/20251229T120500Z-abc123/");
}
#[test]
fn test_ops_dir() {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
let dir = ops_dir(&git_dir);
assert!(dir.to_string_lossy().contains("stax"));
assert!(dir.to_string_lossy().contains("ops"));
}
#[test]
fn test_ensure_ops_dir() {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let dir = ensure_ops_dir(&git_dir).unwrap();
assert!(dir.exists());
}
#[test]
fn test_list_op_ids_empty() {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
let ops = list_op_ids(&git_dir).unwrap();
assert!(ops.is_empty());
}
#[test]
fn test_list_op_ids_with_files() {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
let ops_path = ops_dir(&git_dir);
std::fs::create_dir_all(&ops_path).unwrap();
std::fs::write(ops_path.join("20251229T120000Z-aaa111.json"), "{}").unwrap();
std::fs::write(ops_path.join("20251229T120100Z-bbb222.json"), "{}").unwrap();
std::fs::write(ops_path.join("20251229T120200Z-ccc333.json"), "{}").unwrap();
std::fs::write(ops_path.join("not-an-op.txt"), "text").unwrap();
let ops = list_op_ids(&git_dir).unwrap();
assert_eq!(ops.len(), 3);
assert_eq!(ops[0], "20251229T120200Z-ccc333");
assert_eq!(ops[1], "20251229T120100Z-bbb222");
assert_eq!(ops[2], "20251229T120000Z-aaa111");
}
#[test]
fn test_latest_op_id_empty() {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
let latest = latest_op_id(&git_dir).unwrap();
assert!(latest.is_none());
}
#[test]
fn test_latest_op_id_with_files() {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
let ops_path = ops_dir(&git_dir);
std::fs::create_dir_all(&ops_path).unwrap();
std::fs::write(ops_path.join("20251229T120000Z-old.json"), "{}").unwrap();
std::fs::write(ops_path.join("20251229T120200Z-new.json"), "{}").unwrap();
let latest = latest_op_id(&git_dir).unwrap();
assert_eq!(latest, Some("20251229T120200Z-new".to_string()));
}
#[test]
fn test_rand_suffix_produces_values() {
let suffix = rand_suffix();
assert!(suffix > 0 || suffix == 0); }
}