use crate::types::{ExecutionStatus, Hook, HookResult};
use crate::{Error, Result};
use chrono::{DateTime, Utc};
#[allow(unused_imports)] use fs4::fs_std::FileExt as SyncFileExt;
use fs4::tokio::AsyncFileExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::fs::OpenOptions;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{debug, error, info, warn};
#[derive(Debug, Clone)]
pub struct StateManager {
state_dir: PathBuf,
}
impl StateManager {
#[must_use]
pub fn new(state_dir: PathBuf) -> Self {
Self { state_dir }
}
pub fn default_state_dir() -> Result<PathBuf> {
let base = dirs::state_dir()
.or_else(dirs::data_dir)
.ok_or_else(|| Error::configuration("Could not determine state directory"))?;
Ok(base.join("cuenv").join("hooks"))
}
pub fn with_default_dir() -> Result<Self> {
Ok(Self::new(Self::default_state_dir()?))
}
#[must_use]
pub fn get_state_dir(&self) -> &Path {
&self.state_dir
}
pub async fn ensure_state_dir(&self) -> Result<()> {
if !self.state_dir.exists() {
fs::create_dir_all(&self.state_dir)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(self.state_dir.clone().into_boxed_path()),
operation: "create_dir_all".to_string(),
})?;
debug!("Created state directory: {}", self.state_dir.display());
}
Ok(())
}
fn state_file_path(&self, instance_hash: &str) -> PathBuf {
self.state_dir.join(format!("{}.json", instance_hash))
}
#[must_use]
pub fn get_state_file_path(&self, instance_hash: &str) -> PathBuf {
self.state_dir.join(format!("{}.json", instance_hash))
}
pub async fn save_state(&self, state: &HookExecutionState) -> Result<()> {
self.ensure_state_dir().await?;
let state_file = self.state_file_path(&state.instance_hash);
let json = serde_json::to_string_pretty(state)
.map_err(|e| Error::serialization(format!("Failed to serialize state: {e}")))?;
let temp_path = state_file.with_extension("tmp");
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&temp_path)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(temp_path.clone().into_boxed_path()),
operation: "open".to_string(),
})?;
file.lock_exclusive().map_err(|e| {
Error::configuration(format!(
"Failed to acquire exclusive lock on state temp file: {}",
e
))
})?;
file.write_all(json.as_bytes())
.await
.map_err(|e| Error::Io {
source: e,
path: Some(temp_path.clone().into_boxed_path()),
operation: "write_all".to_string(),
})?;
file.sync_all().await.map_err(|e| Error::Io {
source: e,
path: Some(temp_path.clone().into_boxed_path()),
operation: "sync_all".to_string(),
})?;
drop(file);
fs::rename(&temp_path, &state_file)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(state_file.clone().into_boxed_path()),
operation: "rename".to_string(),
})?;
debug!(
"Saved execution state for directory hash: {}",
state.instance_hash
);
Ok(())
}
pub async fn load_state(&self, instance_hash: &str) -> Result<Option<HookExecutionState>> {
let state_file = self.state_file_path(instance_hash);
if !state_file.exists() {
return Ok(None);
}
let mut file = match OpenOptions::new().read(true).open(&state_file).await {
Ok(f) => f,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return Ok(None);
}
return Err(Error::Io {
source: e,
path: Some(state_file.clone().into_boxed_path()),
operation: "open".to_string(),
});
}
};
file.lock_shared().map_err(|e| {
Error::configuration(format!(
"Failed to acquire shared lock on state file: {}",
e
))
})?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(state_file.clone().into_boxed_path()),
operation: "read_to_string".to_string(),
})?;
drop(file);
let state: HookExecutionState = serde_json::from_str(&contents)
.map_err(|e| Error::serialization(format!("Failed to deserialize state: {e}")))?;
debug!(
"Loaded execution state for directory hash: {}",
instance_hash
);
Ok(Some(state))
}
pub async fn remove_state(&self, instance_hash: &str) -> Result<()> {
let state_file = self.state_file_path(instance_hash);
if state_file.exists() {
fs::remove_file(&state_file).await.map_err(|e| Error::Io {
source: e,
path: Some(state_file.into_boxed_path()),
operation: "remove_file".to_string(),
})?;
debug!(
"Removed execution state for directory hash: {}",
instance_hash
);
}
Ok(())
}
pub async fn list_active_states(&self) -> Result<Vec<HookExecutionState>> {
if !self.state_dir.exists() {
return Ok(Vec::new());
}
let mut states = Vec::new();
let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
source: e,
path: Some(self.state_dir.clone().into_boxed_path()),
operation: "read_dir".to_string(),
})?;
while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
source: e,
path: Some(self.state_dir.clone().into_boxed_path()),
operation: "next_entry".to_string(),
})? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& let Ok(Some(state)) = self.load_state(stem).await
{
states.push(state);
}
}
Ok(states)
}
#[must_use]
pub fn compute_directory_key(path: &Path) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
hasher.update(canonical.to_string_lossy().as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
fn directory_marker_path(&self, directory_key: &str) -> PathBuf {
self.state_dir.join(format!("{}.marker", directory_key))
}
pub async fn create_directory_marker(
&self,
directory_path: &Path,
instance_hash: &str,
) -> Result<()> {
self.ensure_state_dir().await?;
let dir_key = Self::compute_directory_key(directory_path);
let marker_path = self.directory_marker_path(&dir_key);
fs::write(&marker_path, instance_hash)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(marker_path.into_boxed_path()),
operation: "write_marker".to_string(),
})?;
debug!(
"Created directory marker for {} -> {}",
directory_path.display(),
instance_hash
);
Ok(())
}
pub async fn remove_directory_marker(&self, directory_path: &Path) -> Result<()> {
let dir_key = Self::compute_directory_key(directory_path);
let marker_path = self.directory_marker_path(&dir_key);
if marker_path.exists() {
fs::remove_file(&marker_path).await.ok(); debug!("Removed directory marker for {}", directory_path.display());
}
Ok(())
}
#[must_use]
pub fn has_active_marker(&self, directory_path: &Path) -> bool {
let dir_key = Self::compute_directory_key(directory_path);
self.directory_marker_path(&dir_key).exists()
}
pub async fn get_marker_instance_hash(&self, directory_path: &Path) -> Option<String> {
let dir_key = Self::compute_directory_key(directory_path);
let marker_path = self.directory_marker_path(&dir_key);
fs::read_to_string(&marker_path)
.await
.ok()
.map(|s| s.trim().to_string())
}
#[must_use]
pub fn get_marker_instance_hash_sync(&self, directory_path: &Path) -> Option<String> {
let dir_key = Self::compute_directory_key(directory_path);
let marker_path = self.directory_marker_path(&dir_key);
std::fs::read_to_string(&marker_path)
.ok()
.map(|s| s.trim().to_string())
}
pub fn load_state_sync(&self, instance_hash: &str) -> Result<Option<HookExecutionState>> {
let state_file = self.state_file_path(instance_hash);
if !state_file.exists() {
return Ok(None);
}
let mut file = match std::fs::File::open(&state_file) {
Ok(f) => f,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return Ok(None);
}
return Err(Error::Io {
source: e,
path: Some(state_file.clone().into_boxed_path()),
operation: "open".to_string(),
});
}
};
file.lock_shared().map_err(|e| {
Error::configuration(format!(
"Failed to acquire shared lock on state file: {}",
e
))
})?;
let mut contents = String::new();
file.read_to_string(&mut contents).map_err(|e| Error::Io {
source: e,
path: Some(state_file.clone().into_boxed_path()),
operation: "read_to_string".to_string(),
})?;
drop(file);
let state: HookExecutionState = serde_json::from_str(&contents)
.map_err(|e| Error::serialization(format!("Failed to deserialize state: {e}")))?;
Ok(Some(state))
}
pub async fn cleanup_state_directory(&self) -> Result<usize> {
if !self.state_dir.exists() {
return Ok(0);
}
let mut cleaned_count = 0;
let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
source: e,
path: Some(self.state_dir.clone().into_boxed_path()),
operation: "read_dir".to_string(),
})?;
while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
source: e,
path: Some(self.state_dir.clone().into_boxed_path()),
operation: "next_entry".to_string(),
})? {
let path = entry.path();
let extension = path.extension().and_then(|s| s.to_str());
if extension == Some("json") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
match self.load_state(stem).await {
Ok(Some(state)) if state.is_complete() => {
if let Err(e) = fs::remove_file(&path).await {
warn!("Failed to remove state file {}: {}", path.display(), e);
} else {
cleaned_count += 1;
debug!("Cleaned up state file: {}", path.display());
self.remove_directory_marker(&state.directory_path)
.await
.ok();
}
}
Ok(Some(_)) => {
debug!("Keeping active state file: {}", path.display());
}
Ok(None) => {}
Err(e) => {
warn!("Failed to parse state file {}: {}", path.display(), e);
if let Err(rm_err) = fs::remove_file(&path).await {
error!(
"Failed to remove corrupted state file {}: {}",
path.display(),
rm_err
);
} else {
cleaned_count += 1;
info!("Removed corrupted state file: {}", path.display());
}
}
}
}
}
else if extension == Some("marker")
&& let Ok(instance_hash) = fs::read_to_string(&path).await
{
let instance_hash = instance_hash.trim();
match self.load_state(instance_hash).await {
Ok(None) => {
if fs::remove_file(&path).await.is_ok() {
cleaned_count += 1;
debug!("Cleaned up orphaned marker: {}", path.display());
}
}
Ok(Some(state)) if state.is_complete() && !state.should_display_completed() => {
if fs::remove_file(&path).await.is_ok() {
cleaned_count += 1;
debug!("Cleaned up expired marker: {}", path.display());
}
}
_ => {} }
}
}
if cleaned_count > 0 {
info!(
"Cleaned up {} state/marker files from directory",
cleaned_count
);
}
Ok(cleaned_count)
}
pub async fn cleanup_orphaned_states(&self, max_age: chrono::Duration) -> Result<usize> {
let cutoff = Utc::now() - max_age;
let mut cleaned_count = 0;
for state in self.list_active_states().await? {
if state.status == ExecutionStatus::Running && state.started_at < cutoff {
warn!(
"Found orphaned running state for {} (started {}), removing",
state.directory_path.display(),
state.started_at
);
self.remove_state(&state.instance_hash).await?;
self.remove_directory_marker(&state.directory_path)
.await
.ok();
cleaned_count += 1;
}
}
if cleaned_count > 0 {
info!("Cleaned up {} orphaned state files", cleaned_count);
}
Ok(cleaned_count)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HookExecutionState {
pub instance_hash: String,
pub directory_path: PathBuf,
pub config_hash: String,
pub status: ExecutionStatus,
pub total_hooks: usize,
pub completed_hooks: usize,
pub current_hook_index: Option<usize>,
#[serde(default)]
pub hooks: Vec<Hook>,
pub hook_results: HashMap<usize, HookResult>,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub current_hook_started_at: Option<DateTime<Utc>>,
pub completed_display_until: Option<DateTime<Utc>>,
pub error_message: Option<String>,
pub environment_vars: HashMap<String, String>,
pub previous_env: Option<HashMap<String, String>>,
}
impl HookExecutionState {
#[must_use]
pub fn new(
directory_path: PathBuf,
instance_hash: String,
config_hash: String,
hooks: Vec<Hook>,
) -> Self {
let total_hooks = hooks.len();
Self {
instance_hash,
directory_path,
config_hash,
status: ExecutionStatus::Running,
total_hooks,
completed_hooks: 0,
current_hook_index: None,
hooks,
hook_results: HashMap::new(),
started_at: Utc::now(),
finished_at: None,
current_hook_started_at: None,
completed_display_until: None,
error_message: None,
environment_vars: HashMap::new(),
previous_env: None,
}
}
pub fn mark_hook_running(&mut self, hook_index: usize) {
self.current_hook_index = Some(hook_index);
self.current_hook_started_at = Some(Utc::now());
info!(
"Started executing hook {} of {}",
hook_index + 1,
self.total_hooks
);
}
#[expect(
clippy::needless_pass_by_value,
reason = "Takes ownership for API clarity, cloning is intentional"
)]
pub fn record_hook_result(&mut self, hook_index: usize, result: HookResult) {
self.hook_results.insert(hook_index, result.clone());
self.completed_hooks += 1;
self.current_hook_index = None;
self.current_hook_started_at = None;
if result.success {
info!(
"Hook {} of {} completed successfully",
hook_index + 1,
self.total_hooks
);
} else {
error!(
"Hook {} of {} failed: {:?}",
hook_index + 1,
self.total_hooks,
result.error
);
self.status = ExecutionStatus::Failed;
self.error_message.clone_from(&result.error);
self.finished_at = Some(Utc::now());
self.completed_display_until = Some(Utc::now() + chrono::Duration::seconds(2));
return;
}
if self.completed_hooks == self.total_hooks {
self.status = ExecutionStatus::Completed;
let now = Utc::now();
self.finished_at = Some(now);
self.completed_display_until = Some(now + chrono::Duration::seconds(2));
info!("All {} hooks completed successfully", self.total_hooks);
}
}
pub fn mark_cancelled(&mut self, reason: Option<String>) {
self.status = ExecutionStatus::Cancelled;
self.finished_at = Some(Utc::now());
self.error_message = reason;
self.current_hook_index = None;
}
#[must_use]
pub fn is_complete(&self) -> bool {
matches!(
self.status,
ExecutionStatus::Completed | ExecutionStatus::Failed | ExecutionStatus::Cancelled
)
}
#[must_use]
pub fn progress_display(&self) -> String {
match &self.status {
ExecutionStatus::Running => {
if let Some(current) = self.current_hook_index {
format!(
"Executing hook {} of {} ({})",
current + 1,
self.total_hooks,
self.status
)
} else {
format!(
"{} of {} hooks completed",
self.completed_hooks, self.total_hooks
)
}
}
ExecutionStatus::Completed => "All hooks completed successfully".to_string(),
ExecutionStatus::Failed => {
if let Some(error) = &self.error_message {
format!("Hook execution failed: {}", error)
} else {
"Hook execution failed".to_string()
}
}
ExecutionStatus::Cancelled => {
if let Some(reason) = &self.error_message {
format!("Hook execution cancelled: {}", reason)
} else {
"Hook execution cancelled".to_string()
}
}
}
}
pub fn duration(&self) -> chrono::Duration {
let end = self.finished_at.unwrap_or_else(Utc::now);
end - self.started_at
}
#[must_use]
pub fn current_hook_duration(&self) -> Option<chrono::Duration> {
self.current_hook_started_at
.map(|started| Utc::now() - started)
}
#[must_use]
pub fn current_hook(&self) -> Option<&Hook> {
self.current_hook_index.and_then(|idx| self.hooks.get(idx))
}
#[must_use]
pub fn format_duration(duration: chrono::Duration) -> String {
let total_secs = duration.num_seconds();
if total_secs < 60 {
let millis = duration.num_milliseconds();
#[expect(
clippy::cast_precision_loss,
reason = "Display formatting, precision loss is acceptable"
)]
let secs = millis as f64 / 1000.0;
format!("{secs:.1}s")
} else if total_secs < 3600 {
let mins = total_secs / 60;
let secs = total_secs % 60;
if secs == 0 {
format!("{}m", mins)
} else {
format!("{}m {}s", mins, secs)
}
} else {
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
if mins == 0 {
format!("{}h", hours)
} else {
format!("{}h {}m", hours, mins)
}
}
}
#[must_use]
pub fn current_hook_display(&self) -> Option<String> {
let hook = if let Some(hook) = self.current_hook() {
Some(hook)
} else if self.status == ExecutionStatus::Running && self.completed_hooks < self.total_hooks
{
self.hooks.get(self.completed_hooks)
} else {
None
};
hook.map(|h| {
let cmd_name = h.command.split('/').next_back().unwrap_or(&h.command);
format!("`{}`", cmd_name)
})
}
#[must_use]
pub fn should_display_completed(&self) -> bool {
if let Some(display_until) = self.completed_display_until {
Utc::now() < display_until
} else {
false
}
}
}
#[must_use]
pub fn compute_instance_hash(path: &Path, config_hash: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(b":");
hasher.update(config_hash.as_bytes());
hasher.update(b":");
hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
pub fn compute_execution_hash(hooks: &[Hook], base_dir: &Path) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
if let Ok(hooks_json) = serde_json::to_string(hooks) {
hasher.update(hooks_json.as_bytes());
}
for hook in hooks {
let hook_dir = hook
.dir
.as_ref()
.map_or_else(|| base_dir.to_path_buf(), PathBuf::from);
for input in &hook.inputs {
let input_path = hook_dir.join(input);
if let Ok(content) = std::fs::read(&input_path) {
hasher.update(b"file:");
hasher.update(input.as_bytes());
hasher.update(b":");
hasher.update(&content);
}
}
}
hasher.update(b":version:");
hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Hook, HookResult};
use std::collections::HashMap;
use std::os::unix::process::ExitStatusExt;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn test_compute_instance_hash() {
let path = Path::new("/test/path");
let config_hash = "test_config";
let hash = compute_instance_hash(path, config_hash);
assert_eq!(hash.len(), 16);
let hash2 = compute_instance_hash(path, config_hash);
assert_eq!(hash, hash2);
let different_path = Path::new("/other/path");
let different_hash = compute_instance_hash(different_path, config_hash);
assert_ne!(hash, different_hash);
let different_config_hash = compute_instance_hash(path, "different_config");
assert_ne!(hash, different_config_hash);
}
#[tokio::test]
async fn test_state_manager_operations() {
let temp_dir = TempDir::new().unwrap();
let state_manager = StateManager::new(temp_dir.path().to_path_buf());
let directory_path = PathBuf::from("/test/dir");
let config_hash = "test_config_hash".to_string();
let instance_hash = compute_instance_hash(&directory_path, &config_hash);
let hooks = vec![
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test1".to_string()],
dir: None,
inputs: vec![],
source: None,
},
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test2".to_string()],
dir: None,
inputs: vec![],
source: None,
},
];
let mut state =
HookExecutionState::new(directory_path, instance_hash.clone(), config_hash, hooks);
state_manager.save_state(&state).await.unwrap();
let loaded_state = state_manager
.load_state(&instance_hash)
.await
.unwrap()
.unwrap();
assert_eq!(loaded_state.instance_hash, state.instance_hash);
assert_eq!(loaded_state.total_hooks, 2);
assert_eq!(loaded_state.status, ExecutionStatus::Running);
let hook = Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test".to_string()],
dir: None,
inputs: Vec::new(),
source: Some(false),
};
let result = HookResult::success(
hook,
std::process::ExitStatus::from_raw(0),
"test\n".to_string(),
String::new(),
100,
);
state.record_hook_result(0, result);
state_manager.save_state(&state).await.unwrap();
let updated_state = state_manager
.load_state(&instance_hash)
.await
.unwrap()
.unwrap();
assert_eq!(updated_state.completed_hooks, 1);
assert_eq!(updated_state.hook_results.len(), 1);
state_manager.remove_state(&instance_hash).await.unwrap();
let removed_state = state_manager.load_state(&instance_hash).await.unwrap();
assert!(removed_state.is_none());
}
#[test]
fn test_hook_execution_state() {
let directory_path = PathBuf::from("/test/dir");
let instance_hash = "test_hash".to_string();
let config_hash = "config_hash".to_string();
let hooks = vec![
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test1".to_string()],
dir: None,
inputs: vec![],
source: None,
},
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test2".to_string()],
dir: None,
inputs: vec![],
source: None,
},
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test3".to_string()],
dir: None,
inputs: vec![],
source: None,
},
];
let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
assert_eq!(state.status, ExecutionStatus::Running);
assert_eq!(state.total_hooks, 3);
assert_eq!(state.completed_hooks, 0);
assert!(!state.is_complete());
state.mark_hook_running(0);
assert_eq!(state.current_hook_index, Some(0));
let hook = Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec![],
dir: None,
inputs: Vec::new(),
source: Some(false),
};
let result = HookResult::success(
hook.clone(),
std::process::ExitStatus::from_raw(0),
String::new(),
String::new(),
100,
);
state.record_hook_result(0, result);
assert_eq!(state.completed_hooks, 1);
assert_eq!(state.current_hook_index, None);
assert_eq!(state.status, ExecutionStatus::Running);
assert!(!state.is_complete());
let failed_result = HookResult::failure(
hook,
Some(std::process::ExitStatus::from_raw(256)),
String::new(),
"error".to_string(),
50,
"Command failed".to_string(),
);
state.record_hook_result(1, failed_result);
assert_eq!(state.completed_hooks, 2);
assert_eq!(state.status, ExecutionStatus::Failed);
assert!(state.is_complete());
assert!(state.error_message.is_some());
let mut cancelled_state = HookExecutionState::new(
PathBuf::from("/test"),
"hash".to_string(),
"config".to_string(),
vec![Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec![],
dir: None,
inputs: vec![],
source: None,
}],
);
cancelled_state.mark_cancelled(Some("User cancelled".to_string()));
assert_eq!(cancelled_state.status, ExecutionStatus::Cancelled);
assert!(cancelled_state.is_complete());
}
#[test]
fn test_progress_display() {
let directory_path = PathBuf::from("/test/dir");
let instance_hash = "test_hash".to_string();
let config_hash = "config_hash".to_string();
let hooks = vec![
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test1".to_string()],
dir: None,
inputs: vec![],
source: None,
},
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test2".to_string()],
dir: None,
inputs: vec![],
source: None,
},
];
let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
let display = state.progress_display();
assert!(display.contains("0 of 2"));
state.mark_hook_running(0);
let display = state.progress_display();
assert!(display.contains("Executing hook 1 of 2"));
state.status = ExecutionStatus::Completed;
state.current_hook_index = None;
let display = state.progress_display();
assert_eq!(display, "All hooks completed successfully");
state.status = ExecutionStatus::Failed;
state.error_message = Some("Test error".to_string());
let display = state.progress_display();
assert!(display.contains("Hook execution failed: Test error"));
}
#[tokio::test]
async fn test_state_directory_cleanup() {
let temp_dir = TempDir::new().unwrap();
let state_manager = StateManager::new(temp_dir.path().to_path_buf());
let completed_state = HookExecutionState {
instance_hash: "completed_hash".to_string(),
directory_path: PathBuf::from("/completed"),
config_hash: "config1".to_string(),
status: ExecutionStatus::Completed,
total_hooks: 1,
completed_hooks: 1,
current_hook_index: None,
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now() - chrono::Duration::hours(1),
finished_at: Some(Utc::now() - chrono::Duration::minutes(30)),
current_hook_started_at: None,
completed_display_until: None,
error_message: None,
previous_env: None,
};
let running_state = HookExecutionState {
instance_hash: "running_hash".to_string(),
directory_path: PathBuf::from("/running"),
config_hash: "config2".to_string(),
status: ExecutionStatus::Running,
total_hooks: 2,
completed_hooks: 1,
current_hook_index: Some(1),
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now() - chrono::Duration::minutes(5),
finished_at: None,
current_hook_started_at: None,
completed_display_until: None,
error_message: None,
previous_env: None,
};
let failed_state = HookExecutionState {
instance_hash: "failed_hash".to_string(),
directory_path: PathBuf::from("/failed"),
config_hash: "config3".to_string(),
status: ExecutionStatus::Failed,
total_hooks: 1,
completed_hooks: 0,
current_hook_index: None,
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now() - chrono::Duration::hours(2),
finished_at: Some(Utc::now() - chrono::Duration::hours(1)),
current_hook_started_at: None,
completed_display_until: None,
error_message: Some("Test failure".to_string()),
previous_env: None,
};
state_manager.save_state(&completed_state).await.unwrap();
state_manager.save_state(&running_state).await.unwrap();
state_manager.save_state(&failed_state).await.unwrap();
let states = state_manager.list_active_states().await.unwrap();
assert_eq!(states.len(), 3);
let cleaned = state_manager.cleanup_state_directory().await.unwrap();
assert_eq!(cleaned, 2);
let remaining_states = state_manager.list_active_states().await.unwrap();
assert_eq!(remaining_states.len(), 1);
assert_eq!(remaining_states[0].instance_hash, "running_hash");
}
#[tokio::test]
async fn test_cleanup_orphaned_states() {
let temp_dir = TempDir::new().unwrap();
let state_manager = StateManager::new(temp_dir.path().to_path_buf());
let orphaned_state = HookExecutionState {
instance_hash: "orphaned_hash".to_string(),
directory_path: PathBuf::from("/orphaned"),
config_hash: "config".to_string(),
status: ExecutionStatus::Running,
total_hooks: 1,
completed_hooks: 0,
current_hook_index: Some(0),
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now() - chrono::Duration::hours(3),
finished_at: None,
current_hook_started_at: None,
completed_display_until: None,
error_message: None,
previous_env: None,
};
let recent_state = HookExecutionState {
instance_hash: "recent_hash".to_string(),
directory_path: PathBuf::from("/recent"),
config_hash: "config".to_string(),
status: ExecutionStatus::Running,
total_hooks: 1,
completed_hooks: 0,
current_hook_index: Some(0),
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now() - chrono::Duration::minutes(5),
finished_at: None,
current_hook_started_at: None,
completed_display_until: None,
error_message: None,
previous_env: None,
};
state_manager.save_state(&orphaned_state).await.unwrap();
state_manager.save_state(&recent_state).await.unwrap();
let cleaned = state_manager
.cleanup_orphaned_states(chrono::Duration::hours(1))
.await
.unwrap();
assert_eq!(cleaned, 1);
let remaining_states = state_manager.list_active_states().await.unwrap();
assert_eq!(remaining_states.len(), 1);
assert_eq!(remaining_states[0].instance_hash, "recent_hash");
}
#[tokio::test]
async fn test_corrupted_state_file_handling() {
let temp_dir = TempDir::new().unwrap();
let state_dir = temp_dir.path().join("state");
let state_manager = StateManager::new(state_dir.clone());
state_manager.ensure_state_dir().await.unwrap();
let corrupted_file = state_dir.join("corrupted.json");
tokio::fs::write(&corrupted_file, "{invalid json}")
.await
.unwrap();
let states = state_manager.list_active_states().await.unwrap();
assert_eq!(states.len(), 0);
let cleaned = state_manager.cleanup_state_directory().await.unwrap();
assert_eq!(cleaned, 1);
assert!(!corrupted_file.exists());
}
#[tokio::test]
async fn test_concurrent_state_modifications() {
use tokio::task;
let temp_dir = TempDir::new().unwrap();
let state_manager = Arc::new(StateManager::new(temp_dir.path().to_path_buf()));
let initial_state = HookExecutionState {
instance_hash: "concurrent_hash".to_string(),
directory_path: PathBuf::from("/concurrent"),
config_hash: "config".to_string(),
status: ExecutionStatus::Running,
total_hooks: 10,
completed_hooks: 0,
current_hook_index: Some(0),
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now(),
finished_at: None,
current_hook_started_at: None,
completed_display_until: None,
error_message: None,
previous_env: None,
};
state_manager.save_state(&initial_state).await.unwrap();
let mut handles = vec![];
for i in 0..5 {
let sm = state_manager.clone();
let path = initial_state.directory_path.clone();
let handle = task::spawn(async move {
let instance_hash = compute_instance_hash(&path, "concurrent_config");
tokio::time::sleep(Duration::from_millis(10)).await;
if let Ok(Some(mut state)) = sm.load_state(&instance_hash).await {
state.completed_hooks += 1;
state.current_hook_index = Some(i + 1);
let _ = sm.save_state(&state).await;
}
});
handles.push(handle);
}
for handle in handles {
handle.await.unwrap();
}
let final_state = state_manager
.load_state(&initial_state.instance_hash)
.await
.unwrap();
if let Some(state) = final_state {
assert_eq!(state.instance_hash, "concurrent_hash");
}
}
#[tokio::test]
async fn test_state_with_unicode_and_special_chars() {
let temp_dir = TempDir::new().unwrap();
let state_manager = StateManager::new(temp_dir.path().to_path_buf());
let mut unicode_state = HookExecutionState {
instance_hash: "unicode_hash".to_string(),
directory_path: PathBuf::from("/測試/目錄/🚀"),
config_hash: "config_ñ_é_ü".to_string(),
status: ExecutionStatus::Failed,
total_hooks: 1,
completed_hooks: 1,
current_hook_index: None,
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now(),
finished_at: Some(Utc::now()),
current_hook_started_at: None,
completed_display_until: None,
error_message: Some("Error: 錯誤信息 with émojis 🔥💥".to_string()),
previous_env: None,
};
let unicode_hook = Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec![],
dir: None,
inputs: vec![],
source: None,
};
let unicode_result = HookResult {
hook: unicode_hook,
success: false,
exit_status: Some(1),
stdout: "輸出: Hello 世界! 🌍".to_string(),
stderr: "錯誤: ñoño error ⚠️".to_string(),
duration_ms: 100,
error: Some("失敗了 😢".to_string()),
};
unicode_state.hook_results.insert(0, unicode_result);
state_manager.save_state(&unicode_state).await.unwrap();
let loaded = state_manager
.load_state(&unicode_state.instance_hash)
.await
.unwrap()
.unwrap();
assert_eq!(loaded.config_hash, "config_ñ_é_ü");
assert_eq!(
loaded.error_message,
Some("Error: 錯誤信息 with émojis 🔥💥".to_string())
);
let hook_result = loaded.hook_results.get(&0).unwrap();
assert_eq!(hook_result.stdout, "輸出: Hello 世界! 🌍");
assert_eq!(hook_result.stderr, "錯誤: ñoño error ⚠️");
assert_eq!(hook_result.error, Some("失敗了 😢".to_string()));
}
#[tokio::test]
async fn test_state_directory_with_many_states() {
let temp_dir = TempDir::new().unwrap();
let state_manager = StateManager::new(temp_dir.path().to_path_buf());
for i in 0..50 {
let state = HookExecutionState {
instance_hash: format!("hash_{}", i),
directory_path: PathBuf::from(format!("/dir/{}", i)),
config_hash: format!("config_{}", i),
status: if i % 3 == 0 {
ExecutionStatus::Completed
} else if i % 3 == 1 {
ExecutionStatus::Running
} else {
ExecutionStatus::Failed
},
total_hooks: 1,
completed_hooks: usize::from(i % 3 == 0),
current_hook_index: if i % 3 == 1 { Some(0) } else { None },
hooks: vec![],
hook_results: HashMap::new(),
environment_vars: HashMap::new(),
started_at: Utc::now() - chrono::Duration::hours(i64::from(i)),
finished_at: if i % 3 == 1 {
None
} else {
Some(Utc::now() - chrono::Duration::hours(i64::from(i) - 1))
},
current_hook_started_at: None,
completed_display_until: None,
error_message: if i % 3 == 2 {
Some(format!("Error {}", i))
} else {
None
},
previous_env: None,
};
state_manager.save_state(&state).await.unwrap();
}
let listed = state_manager.list_active_states().await.unwrap();
assert_eq!(listed.len(), 50);
let cleaned = state_manager
.cleanup_orphaned_states(chrono::Duration::hours(24))
.await
.unwrap();
assert!(cleaned > 0);
}
}