mod tests {
use super::*;
#[cfg(feature = "test-utils")]
mod workspace_tests {
use super::*;
use crate::checkpoint::execution_history::FileSnapshot;
use crate::checkpoint::state::calculate_checksum_from_bytes;
use crate::workspace::{MemoryWorkspace, Workspace};
use std::path::Path;
#[test]
fn test_file_system_state_new() {
let state = FileSystemState::new();
assert!(state.files.is_empty());
assert!(state.git_head_oid.is_none());
assert!(state.git_branch.is_none());
}
fn snapshot_from_workspace(workspace: &dyn Workspace, path: &str) -> FileSnapshot {
let data = workspace
.read_bytes(Path::new(path))
.expect("workspace file should exist when capturing snapshot");
let checksum = calculate_checksum_from_bytes(&data);
FileSnapshot::new(path, checksum, data.len() as u64, true)
}
fn missing_snapshot(path: &str) -> FileSnapshot {
FileSnapshot::not_found(path)
}
#[test]
fn test_capture_file_with_workspace() {
let workspace = MemoryWorkspace::new_test().with_file("test.txt", "content");
let mut state = FileSystemState::new();
state.files.insert(
"test.txt".to_string(),
snapshot_from_workspace(&workspace, "test.txt"),
);
assert!(state.files.contains_key("test.txt"));
let snapshot = &state.files["test.txt"];
assert!(snapshot.exists);
assert_eq!(snapshot.size, 7);
}
#[test]
fn test_capture_file_with_workspace_nonexistent() {
let _workspace = MemoryWorkspace::new_test();
let mut state = FileSystemState::new();
state.files.insert(
"nonexistent.txt".to_string(),
missing_snapshot("nonexistent.txt"),
);
assert!(state.files.contains_key("nonexistent.txt"));
let snapshot = &state.files["nonexistent.txt"];
assert!(!snapshot.exists);
assert_eq!(snapshot.size, 0);
}
#[test]
fn test_validate_with_workspace_success() {
let workspace = MemoryWorkspace::new_test().with_file("test.txt", "content");
let mut state = FileSystemState::new();
state.files.insert(
"test.txt".to_string(),
snapshot_from_workspace(&workspace, "test.txt"),
);
let errors = state.validate_with_workspace(&workspace, None);
assert!(errors.is_empty());
}
#[test]
fn test_validate_with_workspace_file_missing() {
let workspace_with_file = MemoryWorkspace::new_test().with_file("test.txt", "content");
let mut state = FileSystemState::new();
state.files.insert(
"test.txt".to_string(),
snapshot_from_workspace(&workspace_with_file, "test.txt"),
);
let workspace_without_file = MemoryWorkspace::new_test();
let errors = state.validate_with_workspace(&workspace_without_file, None);
assert!(!errors.is_empty());
assert!(matches!(errors[0], ValidationError::FileMissing { .. }));
}
#[test]
fn test_validate_with_workspace_file_changed() {
let workspace_original = MemoryWorkspace::new_test().with_file("test.txt", "content");
let mut state = FileSystemState::new();
state.files.insert(
"test.txt".to_string(),
snapshot_from_workspace(&workspace_original, "test.txt"),
);
let workspace_modified = MemoryWorkspace::new_test().with_file("test.txt", "modified");
let errors = state.validate_with_workspace(&workspace_modified, None);
assert!(!errors.is_empty());
assert!(matches!(
errors[0],
ValidationError::FileContentChanged { .. }
));
}
#[test]
fn test_validate_with_workspace_file_unexpectedly_exists() {
let _workspace_empty = MemoryWorkspace::new_test();
let mut state = FileSystemState::new();
state
.files
.insert("test.txt".to_string(), missing_snapshot("test.txt"));
let workspace_with_file = MemoryWorkspace::new_test().with_file("test.txt", "content");
let errors = state.validate_with_workspace(&workspace_with_file, None);
assert!(!errors.is_empty());
assert!(matches!(
errors[0],
ValidationError::FileUnexpectedlyExists { .. }
));
}
}
#[cfg(feature = "test-utils")]
mod interrupt_tests {
use super::*;
use crate::executor::MockProcessExecutor;
use crate::interrupt::{
request_user_interrupt, reset_user_interrupted_occurred, take_user_interrupt_request,
};
use crate::workspace::MemoryWorkspace;
#[test]
fn capture_with_workspace_skips_git_state_when_interrupted() {
let _lock = crate::interrupt::interrupt_test_lock();
take_user_interrupt_request();
reset_user_interrupted_occurred();
let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# task");
let executor = MockProcessExecutor::new();
request_user_interrupt();
let _state = FileSystemState::capture_with_workspace(&workspace, &executor);
take_user_interrupt_request();
reset_user_interrupted_occurred();
let git_calls: Vec<_> = executor
.execute_calls()
.into_iter()
.filter(|(cmd, _, _, _)| cmd == "git")
.collect();
assert!(
git_calls.is_empty(),
"capture_with_workspace must not call git when a user interrupt is pending; \
got {} git call(s): {:?}",
git_calls.len(),
git_calls
);
}
}
#[test]
fn test_validation_error_display() {
let err = ValidationError::FileMissing {
path: "test.txt".to_string(),
};
assert_eq!(err.to_string(), "File missing: test.txt");
let err = ValidationError::FileContentChanged {
path: "test.txt".to_string(),
};
assert_eq!(err.to_string(), "File content changed: test.txt");
}
#[test]
fn test_validation_error_recovery_suggestion() {
use crate::common::domain_types::GitOid;
let err = ValidationError::FileMissing {
path: "test.txt".to_string(),
};
let (problem, commands) = err.recovery_commands();
assert!(problem.contains("test.txt"));
assert!(!commands.is_empty());
let expected_hex = "a".repeat(40);
let err = ValidationError::GitHeadChanged {
expected: GitOid::from(expected_hex.as_str()),
actual: GitOid::from("b".repeat(40).as_str()),
};
let (problem, commands) = err.recovery_commands();
assert!(problem.contains(&expected_hex));
assert!(commands.iter().any(|c| c.contains("git reset")));
}
#[test]
fn test_validation_error_recovery_commands_file_missing() {
let err = ValidationError::FileMissing {
path: "PROMPT.md".to_string(),
};
let (problem, commands) = err.recovery_commands();
assert!(problem.contains("missing"));
assert!(problem.contains("PROMPT.md"));
assert!(!commands.is_empty());
assert!(commands.iter().any(|c| c.contains("find")));
}
#[test]
fn test_validation_error_recovery_commands_git_head_changed() {
use crate::common::domain_types::GitOid;
let expected_hex = "a".repeat(40);
let actual_hex = "b".repeat(40);
let err = ValidationError::GitHeadChanged {
expected: GitOid::from(expected_hex.as_str()),
actual: GitOid::from(actual_hex.as_str()),
};
let (problem, commands) = err.recovery_commands();
assert!(problem.contains("changed"));
assert!(problem.contains(&expected_hex));
assert!(problem.contains(&actual_hex));
assert!(!commands.is_empty());
assert!(commands.iter().any(|c| c.contains("git reset")));
assert!(commands.iter().any(|c| c.contains("git log")));
}
#[test]
fn test_validation_error_recovery_commands_working_tree_changed() {
let err = ValidationError::GitWorkingTreeChanged {
changes: "M file1.txt\nM file2.txt".to_string(),
};
let (problem, commands) = err.recovery_commands();
assert!(problem.contains("uncommitted changes"));
assert!(!commands.is_empty());
assert!(commands.iter().any(|c| c.contains("git status")));
assert!(commands.iter().any(|c| c.contains("git stash")));
assert!(commands.iter().any(|c| c.contains("git commit")));
}
#[test]
fn test_validation_error_recovery_commands_git_state_invalid() {
let err = ValidationError::GitStateInvalid {
reason: "detached HEAD state".to_string(),
};
let (problem, commands) = err.recovery_commands();
assert!(problem.contains("detached HEAD state"));
assert!(!commands.is_empty());
assert!(commands.iter().any(|c| c.contains("git checkout")));
}
#[test]
fn test_validation_error_recovery_commands_file_content_changed() {
let err = ValidationError::FileContentChanged {
path: "PROMPT.md".to_string(),
};
let (problem, commands) = err.recovery_commands();
assert!(problem.contains("changed"));
assert!(problem.contains("PROMPT.md"));
assert!(!commands.is_empty());
assert!(commands.iter().any(|c| c.contains("git diff")));
}
#[test]
fn test_git_head_changed_payload_types_are_git_oid() {
use crate::common::domain_types::GitOid;
let expected_oid = GitOid::from("a".repeat(40));
let actual_oid = GitOid::from("b".repeat(40));
let err = ValidationError::GitHeadChanged {
expected: expected_oid.clone(),
actual: actual_oid.clone(),
};
if let ValidationError::GitHeadChanged { expected, actual } = &err {
assert_eq!(expected.as_str(), "a".repeat(40).as_str());
assert_eq!(actual.as_str(), "b".repeat(40).as_str());
} else {
panic!("expected GitHeadChanged variant");
}
let display = err.to_string();
assert!(display.contains(&"a".repeat(40)));
assert!(display.contains(&"b".repeat(40)));
}
}