use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct HistoryEntry {
pub id: usize,
pub hash: String,
pub subject: String,
pub operation: String,
pub target: String,
pub files: Vec<String>,
pub message: Option<String>,
pub workflow: Option<String>,
pub git_head: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
#[serde(default)]
pub struct ShadowConfig {
pub enabled: Option<bool>,
pub warn_on_delete: Option<bool>,
}
impl ShadowConfig {
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn warn_on_delete(&self) -> bool {
self.warn_on_delete.unwrap_or(true)
}
}
pub struct EditInfo {
pub operation: String,
pub target: String,
pub files: Vec<PathBuf>,
pub message: Option<String>,
pub workflow: Option<String>,
}
pub struct ValidationResult {
pub success: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
pub struct Shadow {
root: PathBuf,
shadow_dir: PathBuf,
worktree: PathBuf,
}
impl Shadow {
pub fn new(root: &Path) -> Self {
let shadow_dir = root.join(".normalize").join("shadow");
let worktree = shadow_dir.join("worktree");
Self {
root: root.to_path_buf(),
shadow_dir,
worktree,
}
}
pub fn exists(&self) -> bool {
self.shadow_dir.join(".git").exists()
}
fn init(&self) -> Result<(), ShadowError> {
if self.exists() {
return Ok(());
}
std::fs::create_dir_all(&self.worktree)
.map_err(|e| ShadowError::Init(format!("Failed to create shadow directory: {}", e)))?;
let status = Command::new("git")
.args([
"init",
"--quiet",
&format!(
"--separate-git-dir={}",
self.shadow_dir.join(".git").display()
),
])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Init(format!("Failed to run git init: {}", e)))?;
if !status.success() {
return Err(ShadowError::Init("git init failed".to_string()));
}
let _ = Command::new("git")
.args(["config", "user.email", "shadow@normalize.local"])
.current_dir(&self.worktree)
.status();
let _ = Command::new("git")
.args(["config", "user.name", "Normalize Shadow"])
.current_dir(&self.worktree)
.status();
Ok(())
}
fn get_real_git_head(&self) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(&self.root)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn copy_to_worktree(&self, file: &Path) -> Result<PathBuf, ShadowError> {
let rel_path = file
.strip_prefix(&self.root)
.map_err(|_| ShadowError::Commit("File not under project root".to_string()))?;
let dest = self.worktree.join(rel_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ShadowError::Commit(format!("Failed to create directories: {}", e)))?;
}
std::fs::copy(file, &dest)
.map_err(|e| ShadowError::Commit(format!("Failed to copy file: {}", e)))?;
Ok(rel_path.to_path_buf())
}
pub fn before_edit(&self, files: &[&Path]) -> Result<(), ShadowError> {
self.init()?;
for file in files {
if file.exists() {
self.copy_to_worktree(file)?;
}
}
Ok(())
}
pub fn after_edit(&self, info: &EditInfo) -> Result<(), ShadowError> {
for file in &info.files {
if file.exists() {
self.copy_to_worktree(file)?;
}
}
let status = Command::new("git")
.args(["add", "-A"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to stage changes: {}", e)))?;
if !status.success() {
return Err(ShadowError::Commit("git add failed".to_string()));
}
let status = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to check diff: {}", e)))?;
if status.success() {
return Ok(());
}
let git_head = self
.get_real_git_head()
.unwrap_or_else(|| "none".to_string());
let files_str: Vec<String> = info
.files
.iter()
.filter_map(|f| f.strip_prefix(&self.root).ok())
.map(|p| p.display().to_string())
.collect();
let mut commit_msg = format!("normalize edit: {} {}\n\n", info.operation, info.target);
if let Some(ref msg) = info.message {
commit_msg.push_str(&format!("Message: {}\n", msg));
}
if let Some(ref wf) = info.workflow {
commit_msg.push_str(&format!("Workflow: {}\n", wf));
}
commit_msg.push_str(&format!("Operation: {}\n", info.operation));
commit_msg.push_str(&format!("Target: {}\n", info.target));
commit_msg.push_str(&format!("Files: {}\n", files_str.join(", ")));
commit_msg.push_str(&format!("Git-HEAD: {}\n", git_head));
let status = Command::new("git")
.args(["commit", "-m", &commit_msg])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to commit: {}", e)))?;
if !status.success() {
return Err(ShadowError::Commit("git commit failed".to_string()));
}
Ok(())
}
pub fn history(&self, file_filter: Option<&str>, limit: usize) -> Vec<HistoryEntry> {
if !self.exists() {
return Vec::new();
}
let mut args = vec![
"log".to_string(),
"--format=%H%x1f%s%x1f%b%x1f%aI%x1e".to_string(),
format!("-{}", limit),
];
if let Some(file) = file_filter {
args.push("--".to_string());
args.push(file.to_string());
}
let output = Command::new("git")
.args(&args)
.current_dir(&self.worktree)
.output();
let output = match output {
Ok(out) if out.status.success() => out,
_ => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
let blocks: Vec<&str> = stdout
.split('\x1e')
.filter(|b| !b.trim().is_empty())
.collect();
let total = blocks.len();
for (idx, block) in blocks.into_iter().enumerate() {
let parts: Vec<&str> = block.split('\x1f').collect();
if parts.len() < 4 {
continue;
}
let hash = parts[0].trim();
let subject = parts[1].trim();
let body = parts[2].trim();
let timestamp = parts[3].trim();
let mut operation = String::new();
let mut target = String::new();
let mut files = Vec::new();
let mut message = None;
let mut workflow = None;
let mut git_head = String::new();
for line in body.lines() {
if let Some(val) = line.strip_prefix("Operation: ") {
operation = val.to_string();
} else if let Some(val) = line.strip_prefix("Target: ") {
target = val.to_string();
} else if let Some(val) = line.strip_prefix("Files: ") {
files = val.split(", ").map(String::from).collect();
} else if let Some(val) = line.strip_prefix("Message: ") {
message = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("Workflow: ") {
workflow = Some(val.to_string());
} else if let Some(val) = line.strip_prefix("Git-HEAD: ") {
git_head = val.to_string();
}
}
entries.push(HistoryEntry {
id: total - idx, hash: hash.to_string(),
subject: subject.to_string(),
operation,
target,
files,
message,
workflow,
git_head,
timestamp: timestamp.to_string(),
});
}
entries
}
pub fn diff(&self, commit_ref: &str) -> Option<String> {
if !self.exists() {
return None;
}
let output = Command::new("git")
.args(["show", "--format=", commit_ref])
.current_dir(&self.worktree)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).to_string())
} else {
None
}
}
pub fn tree(&self, limit: usize) -> Option<String> {
if !self.exists() {
return None;
}
let output = Command::new("git")
.args([
"log",
"--graph",
"--all",
"--oneline",
"--decorate",
&format!("-{}", limit),
])
.current_dir(&self.worktree)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).to_string())
} else {
None
}
}
pub fn checkpoint(&self) -> Option<String> {
self.history(None, 1)
.first()
.map(|e| e.git_head.clone())
.filter(|h| h != "none")
}
pub fn validate(&self, cmd: &str, args: &[&str]) -> Result<ValidationResult, ShadowError> {
if !self.exists() {
return Err(ShadowError::Init("No shadow worktree exists".to_string()));
}
let output = Command::new(cmd)
.args(args)
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Validation {
message: format!("Failed to run {}: {}", cmd, e),
exit_code: -1,
})?;
Ok(ValidationResult {
success: output.status.success(),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
pub fn apply_to_real(&self) -> Result<Vec<PathBuf>, ShadowError> {
if !self.exists() {
return Err(ShadowError::Init("No shadow worktree exists".to_string()));
}
let output = Command::new("git")
.args(["diff", "--name-only", "HEAD~1", "HEAD"])
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Validation {
message: format!("git diff failed: {}", e),
exit_code: -1,
})?;
if !output.status.success() {
return Err(ShadowError::Validation {
message: "Failed to get changed files".to_string(),
exit_code: -1,
});
}
let files: Vec<PathBuf> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| self.root.join(l))
.collect();
for file in &files {
let rel = file.strip_prefix(&self.root).unwrap_or(file.as_path());
let shadow_file = self.worktree.join(rel);
if shadow_file.exists() {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent).map_err(|e| ShadowError::Validation {
message: format!("mkdir failed: {}", e),
exit_code: -1,
})?;
}
std::fs::copy(&shadow_file, file).map_err(|e| ShadowError::Validation {
message: format!("copy failed: {}", e),
exit_code: -1,
})?;
}
}
Ok(files)
}
pub fn edit_count(&self) -> usize {
if !self.exists() {
return 0;
}
let output = Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.current_dir(&self.worktree)
.output();
match output {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.unwrap_or(0),
_ => 0,
}
}
pub fn prune(&self, keep: usize) -> Result<usize, ShadowError> {
if !self.exists() {
return Err(ShadowError::Init("No shadow history exists".to_string()));
}
let total = self.edit_count();
if total <= keep {
return Ok(0);
}
let to_prune = total - keep;
let new_root_output = Command::new("git")
.args(["rev-parse", &format!("HEAD~{}", keep - 1)])
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Init(format!("Failed to find root commit: {}", e)))?;
if !new_root_output.status.success() {
return Err(ShadowError::Init(
"Failed to find commit to keep".to_string(),
));
}
let new_root = String::from_utf8_lossy(&new_root_output.stdout)
.trim()
.to_string();
let _ = Command::new("git")
.args(["replace", "--graft", &new_root])
.current_dir(&self.worktree)
.output();
let filter_result = Command::new("git")
.args(["filter-branch", "--force", "--", "--all"])
.current_dir(&self.worktree)
.output();
if let Err(e) = filter_result {
return Err(ShadowError::Init(format!("Filter-branch failed: {}", e)));
}
let _ = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", "refs/original/"])
.current_dir(&self.worktree)
.output()
.map(|out| {
for refname in String::from_utf8_lossy(&out.stdout).lines() {
let _ = Command::new("git")
.args(["update-ref", "-d", refname])
.current_dir(&self.worktree)
.output();
}
});
let _ = Command::new("git")
.args(["replace", "-d", &new_root])
.current_dir(&self.worktree)
.output();
let _ = Command::new("git")
.args(["gc", "--prune=now", "--aggressive"])
.current_dir(&self.worktree)
.output();
Ok(to_prune)
}
pub fn undo(
&self,
count: usize,
file_filter: Option<&str>,
cross_checkpoint: bool,
dry_run: bool,
force: bool,
) -> Result<Vec<UndoResult>, ShadowError> {
if !self.exists() {
return Err(ShadowError::Undo("No shadow history exists".to_string()));
}
let entries = self.history(None, count);
if entries.is_empty() {
return Err(ShadowError::Undo("No edits to undo".to_string()));
}
let entries: Vec<_> = if let Some(filter) = file_filter {
entries
.into_iter()
.filter(|e| e.files.iter().any(|f| f.contains(filter) || f == filter))
.collect()
} else {
entries
};
if entries.is_empty() {
return Err(ShadowError::Undo(
"No edits found matching the file filter".to_string(),
));
}
if !cross_checkpoint && entries.len() > 1 {
let first_git_head = &entries[0].git_head;
for entry in entries.iter().skip(1) {
if entry.git_head != *first_git_head && entry.git_head != "none" {
return Err(ShadowError::Undo(format!(
"Cannot undo past checkpoint (git commit {}). Use --cross-checkpoint to override.",
entry.git_head
)));
}
}
}
if !force && !dry_run {
let conflicts = self.detect_conflicts(&entries);
if !conflicts.is_empty() {
let files_str = conflicts.join(", ");
return Err(ShadowError::Undo(format!(
"Files modified externally since last edit: {}. Use --force to override.",
files_str
)));
}
}
let mut results = Vec::new();
for entry in entries.iter().take(count) {
let files_to_undo: Vec<_> = if let Some(filter) = file_filter {
entry
.files
.iter()
.filter(|f| f.contains(filter) || *f == filter)
.cloned()
.collect()
} else {
entry.files.clone()
};
if dry_run {
let conflicts = self.detect_conflicts(std::slice::from_ref(entry));
results.push(UndoResult {
files: files_to_undo.iter().map(PathBuf::from).collect(),
undone_commit: entry.hash.clone(),
description: format!("{}: {}", entry.operation, entry.target),
conflicts,
});
continue;
}
let parent_ref = format!("{}^", entry.hash);
self.restore_files_from_ref(&files_to_undo, &parent_ref)?;
let add_status = Command::new("git")
.args(["add", "-A"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to stage undo: {}", e)))?;
if !add_status.success() {
return Err(ShadowError::Commit(
"git add failed during undo".to_string(),
));
}
let undo_msg = format!(
"normalize edit: undo {}\n\nOperation: undo\nTarget: {}\nUndone-Commit: {}\nFiles: {}\nGit-HEAD: {}\n",
entry.target,
entry.target,
entry.hash,
files_to_undo.join(", "),
self.get_real_git_head()
.unwrap_or_else(|| "none".to_string())
);
let commit_status = Command::new("git")
.args(["commit", "-m", &undo_msg, "--allow-empty"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to commit undo: {}", e)))?;
if !commit_status.success() {
return Err(ShadowError::Commit(
"git commit failed during undo".to_string(),
));
}
results.push(UndoResult {
files: files_to_undo.iter().map(PathBuf::from).collect(),
undone_commit: entry.hash.clone(),
description: format!("{}: {}", entry.operation, entry.target),
conflicts: vec![], });
}
Ok(results)
}
fn restore_files_from_ref(&self, files: &[String], git_ref: &str) -> Result<(), ShadowError> {
for file_path in files {
let worktree_file = self.worktree.join(file_path);
let actual_file = self.root.join(file_path);
let show_output = Command::new("git")
.args(["show", &format!("{}:{}", git_ref, file_path)])
.current_dir(&self.worktree)
.output();
match show_output {
Ok(output) if output.status.success() => {
if let Some(parent) = actual_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&actual_file, &output.stdout).map_err(|e| {
ShadowError::Undo(format!("Failed to write {}: {}", file_path, e))
})?;
if let Some(parent) = worktree_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&worktree_file, &output.stdout);
}
_ => {
if actual_file.exists() {
std::fs::remove_file(&actual_file).map_err(|e| {
ShadowError::Undo(format!("Failed to delete {}: {}", file_path, e))
})?;
}
let _ = std::fs::remove_file(&worktree_file);
}
}
}
Ok(())
}
fn detect_conflicts(&self, entries: &[HistoryEntry]) -> Vec<String> {
let mut conflicts = Vec::new();
for entry in entries {
for file_path in &entry.files {
let actual_file = self.root.join(file_path);
let show_output = Command::new("git")
.args(["show", &format!("HEAD:{}", file_path)])
.current_dir(&self.worktree)
.output();
match show_output {
Ok(output) if output.status.success() => {
if actual_file.exists() {
if let Ok(actual_content) = std::fs::read(&actual_file)
&& actual_content != output.stdout
{
conflicts.push(file_path.clone());
}
} else {
conflicts.push(file_path.clone());
}
}
_ => {
if actual_file.exists() {
conflicts.push(file_path.clone());
}
}
}
}
}
conflicts
}
pub fn redo(&self) -> Result<UndoResult, ShadowError> {
if !self.exists() {
return Err(ShadowError::Undo("No shadow history exists".to_string()));
}
let entries = self.history(None, 1);
let latest = entries
.first()
.ok_or_else(|| ShadowError::Undo("No history to redo".to_string()))?;
if latest.operation != "undo" {
return Err(ShadowError::Undo(
"Last operation was not an undo - nothing to redo".to_string(),
));
}
let log_output = Command::new("git")
.args(["log", "-1", "--format=%B", &latest.hash])
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Undo(format!("Failed to get log: {}", e)))?;
let body = String::from_utf8_lossy(&log_output.stdout);
let undone_hash = body
.lines()
.find_map(|line| line.strip_prefix("Undone-Commit: "))
.ok_or_else(|| ShadowError::Undo("Cannot find undone commit reference".to_string()))?;
let files_output = Command::new("git")
.args(["show", "--format=", "--name-only", undone_hash])
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Undo(format!("Failed to get files: {}", e)))?;
let files: Vec<String> = String::from_utf8_lossy(&files_output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
self.restore_files_from_ref(&files, undone_hash)?;
let add_status = Command::new("git")
.args(["add", "-A"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to stage redo: {}", e)))?;
if !add_status.success() {
return Err(ShadowError::Commit(
"git add failed during redo".to_string(),
));
}
let redo_msg = format!(
"normalize edit: redo {}\n\nOperation: redo\nTarget: {}\nRedone-Commit: {}\nFiles: {}\nGit-HEAD: {}\n",
latest.target,
latest.target,
undone_hash,
files.join(", "),
self.get_real_git_head()
.unwrap_or_else(|| "none".to_string())
);
let commit_status = Command::new("git")
.args(["commit", "-m", &redo_msg, "--allow-empty"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to commit redo: {}", e)))?;
if !commit_status.success() {
return Err(ShadowError::Commit(
"git commit failed during redo".to_string(),
));
}
Ok(UndoResult {
files: files.iter().map(PathBuf::from).collect(),
undone_commit: undone_hash.to_string(),
description: format!("redo: {}", latest.target),
conflicts: vec![], })
}
pub fn goto(
&self,
ref_str: &str,
dry_run: bool,
force: bool,
) -> Result<UndoResult, ShadowError> {
if !self.exists() {
return Err(ShadowError::Undo("No shadow history exists".to_string()));
}
let rev_parse = Command::new("git")
.args(["rev-parse", ref_str])
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Undo(format!("Failed to resolve ref: {}", e)))?;
if !rev_parse.status.success() {
return Err(ShadowError::Undo(format!(
"Invalid ref '{}': not found in shadow history",
ref_str
)));
}
let target_hash = String::from_utf8_lossy(&rev_parse.stdout)
.trim()
.to_string();
let files_output = Command::new("git")
.args(["show", "--format=", "--name-only", &target_hash])
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Undo(format!("Failed to get files: {}", e)))?;
let files: Vec<String> = String::from_utf8_lossy(&files_output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
let log_output = Command::new("git")
.args(["log", "-1", "--format=%s", &target_hash])
.current_dir(&self.worktree)
.output()
.map_err(|e| ShadowError::Undo(format!("Failed to get log: {}", e)))?;
let description = String::from_utf8_lossy(&log_output.stdout)
.trim()
.to_string();
if dry_run {
return Ok(UndoResult {
files: files.iter().map(PathBuf::from).collect(),
undone_commit: target_hash,
description,
conflicts: vec![],
});
}
if !force {
let fake_entry = HistoryEntry {
id: 0,
hash: target_hash.clone(),
subject: description.clone(),
operation: "goto".to_string(),
target: ref_str.to_string(),
files: files.clone(),
message: None,
workflow: None,
git_head: String::new(),
timestamp: String::new(),
};
let conflicts = self.detect_conflicts(&[fake_entry]);
if !conflicts.is_empty() {
let files_str = conflicts.join(", ");
return Err(ShadowError::Undo(format!(
"Files modified externally: {}. Use --force to override.",
files_str
)));
}
}
for file_path in &files {
let worktree_file = self.worktree.join(file_path);
let actual_file = self.root.join(file_path);
let show_output = Command::new("git")
.args(["show", &format!("{}:{}", target_hash, file_path)])
.current_dir(&self.worktree)
.output();
match show_output {
Ok(output) if output.status.success() => {
if let Some(parent) = actual_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&actual_file, &output.stdout).map_err(|e| {
ShadowError::Undo(format!("Failed to write {}: {}", file_path, e))
})?;
if let Some(parent) = worktree_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&worktree_file, &output.stdout);
}
_ => {
if actual_file.exists() {
std::fs::remove_file(&actual_file).map_err(|e| {
ShadowError::Undo(format!("Failed to delete {}: {}", file_path, e))
})?;
}
let _ = std::fs::remove_file(&worktree_file);
}
}
}
let add_status = Command::new("git")
.args(["add", "-A"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to stage goto: {}", e)))?;
if !add_status.success() {
return Err(ShadowError::Commit(
"git add failed during goto".to_string(),
));
}
let goto_msg = format!(
"normalize edit: goto {}\n\nOperation: goto\nTarget: {}\nGoto-Commit: {}\nFiles: {}\nGit-HEAD: {}\n",
ref_str,
ref_str,
target_hash,
files.join(", "),
self.get_real_git_head()
.unwrap_or_else(|| "none".to_string())
);
let commit_status = Command::new("git")
.args(["commit", "-m", &goto_msg, "--allow-empty"])
.current_dir(&self.worktree)
.status()
.map_err(|e| ShadowError::Commit(format!("Failed to commit goto: {}", e)))?;
if !commit_status.success() {
return Err(ShadowError::Commit(
"git commit failed during goto".to_string(),
));
}
Ok(UndoResult {
files: files.iter().map(PathBuf::from).collect(),
undone_commit: target_hash,
description,
conflicts: vec![],
})
}
}
pub struct UndoResult {
pub files: Vec<PathBuf>,
pub undone_commit: String,
pub description: String,
pub conflicts: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ShadowError {
#[error("failed to initialize shadow worktree: {0}")]
Init(String),
#[error("failed to commit in shadow worktree: {0}")]
Commit(String),
#[error("failed to undo shadow operation: {0}")]
Undo(String),
#[error("validation failed: {message} (exit code {exit_code})")]
Validation { message: String, exit_code: i32 },
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_shadow_new() {
let dir = TempDir::new().unwrap();
let shadow = Shadow::new(dir.path());
assert!(!shadow.exists());
assert_eq!(
shadow.shadow_dir,
dir.path().join(".normalize").join("shadow")
);
}
#[test]
fn test_shadow_init() {
let dir = TempDir::new().unwrap();
let shadow = Shadow::new(dir.path());
shadow.init().unwrap();
assert!(shadow.exists());
assert!(shadow.worktree.exists());
}
#[test]
fn test_shadow_before_after_edit() {
let dir = TempDir::new().unwrap();
let test_file = dir.path().join("test.rs");
std::fs::write(&test_file, "fn foo() {}").unwrap();
let shadow = Shadow::new(dir.path());
shadow.before_edit(&[&test_file]).unwrap();
std::fs::write(&test_file, "fn bar() {}").unwrap();
let info = EditInfo {
operation: "replace".to_string(),
target: "test.rs/foo".to_string(),
files: vec![test_file.clone()],
message: Some("Renamed foo to bar".to_string()),
workflow: None,
};
shadow.after_edit(&info).unwrap();
assert_eq!(shadow.edit_count(), 1);
}
}