1use crate::hooks::types::{ExecutionStatus, HookResult};
4use crate::{Error, Result};
5use chrono::{DateTime, Utc};
6use fs4::tokio::AsyncFileExt;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tokio::fs;
11use tokio::fs::OpenOptions;
12use tokio::io::{AsyncReadExt, AsyncWriteExt};
13use tracing::{debug, error, info, warn};
14
15#[derive(Debug, Clone)]
17pub struct StateManager {
18 state_dir: PathBuf,
19}
20
21impl StateManager {
22 pub fn new(state_dir: PathBuf) -> Self {
24 Self { state_dir }
25 }
26
27 pub fn default_state_dir() -> Result<PathBuf> {
29 if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
31 return Ok(PathBuf::from(state_dir));
32 }
33
34 let home = dirs::home_dir()
35 .ok_or_else(|| Error::configuration("Could not determine home directory"))?;
36 Ok(home.join(".cuenv").join("state"))
37 }
38
39 pub fn with_default_dir() -> Result<Self> {
41 Ok(Self::new(Self::default_state_dir()?))
42 }
43
44 pub fn get_state_dir(&self) -> &Path {
46 &self.state_dir
47 }
48
49 pub async fn ensure_state_dir(&self) -> Result<()> {
51 if !self.state_dir.exists() {
52 fs::create_dir_all(&self.state_dir)
53 .await
54 .map_err(|e| Error::Io {
55 source: e,
56 path: Some(self.state_dir.clone().into_boxed_path()),
57 operation: "create_dir_all".to_string(),
58 })?;
59 debug!("Created state directory: {}", self.state_dir.display());
60 }
61 Ok(())
62 }
63
64 fn state_file_path(&self, instance_hash: &str) -> PathBuf {
66 self.state_dir.join(format!("{}.json", instance_hash))
67 }
68
69 pub fn get_state_file_path(&self, instance_hash: &str) -> PathBuf {
71 self.state_dir.join(format!("{}.json", instance_hash))
72 }
73
74 pub async fn save_state(&self, state: &HookExecutionState) -> Result<()> {
76 self.ensure_state_dir().await?;
77
78 let state_file = self.state_file_path(&state.instance_hash);
79 let json = serde_json::to_string_pretty(state)
80 .map_err(|e| Error::configuration(format!("Failed to serialize state: {e}")))?;
81
82 let temp_path = state_file.with_extension("tmp");
84
85 let mut file = OpenOptions::new()
87 .write(true)
88 .create(true)
89 .truncate(true)
90 .open(&temp_path)
91 .await
92 .map_err(|e| Error::Io {
93 source: e,
94 path: Some(temp_path.clone().into_boxed_path()),
95 operation: "open".to_string(),
96 })?;
97
98 file.lock_exclusive().map_err(|e| {
100 Error::configuration(format!(
101 "Failed to acquire exclusive lock on state temp file: {}",
102 e
103 ))
104 })?;
105
106 file.write_all(json.as_bytes())
107 .await
108 .map_err(|e| Error::Io {
109 source: e,
110 path: Some(temp_path.clone().into_boxed_path()),
111 operation: "write_all".to_string(),
112 })?;
113
114 file.sync_all().await.map_err(|e| Error::Io {
115 source: e,
116 path: Some(temp_path.clone().into_boxed_path()),
117 operation: "sync_all".to_string(),
118 })?;
119
120 drop(file);
122
123 fs::rename(&temp_path, &state_file)
125 .await
126 .map_err(|e| Error::Io {
127 source: e,
128 path: Some(state_file.clone().into_boxed_path()),
129 operation: "rename".to_string(),
130 })?;
131
132 debug!(
133 "Saved execution state for directory hash: {}",
134 state.instance_hash
135 );
136 Ok(())
137 }
138
139 pub async fn load_state(&self, instance_hash: &str) -> Result<Option<HookExecutionState>> {
141 let state_file = self.state_file_path(instance_hash);
142
143 if !state_file.exists() {
144 return Ok(None);
145 }
146
147 let mut file = match OpenOptions::new().read(true).open(&state_file).await {
149 Ok(f) => f,
150 Err(e) => {
151 if e.kind() == std::io::ErrorKind::NotFound {
153 return Ok(None);
154 }
155 return Err(Error::Io {
156 source: e,
157 path: Some(state_file.clone().into_boxed_path()),
158 operation: "open".to_string(),
159 });
160 }
161 };
162
163 file.lock_shared().map_err(|e| {
165 Error::configuration(format!(
166 "Failed to acquire shared lock on state file: {}",
167 e
168 ))
169 })?;
170
171 let mut contents = String::new();
172 file.read_to_string(&mut contents)
173 .await
174 .map_err(|e| Error::Io {
175 source: e,
176 path: Some(state_file.clone().into_boxed_path()),
177 operation: "read_to_string".to_string(),
178 })?;
179
180 drop(file);
182
183 let state: HookExecutionState = serde_json::from_str(&contents)
184 .map_err(|e| Error::configuration(format!("Failed to deserialize state: {e}")))?;
185
186 debug!(
187 "Loaded execution state for directory hash: {}",
188 instance_hash
189 );
190 Ok(Some(state))
191 }
192
193 pub async fn remove_state(&self, instance_hash: &str) -> Result<()> {
195 let state_file = self.state_file_path(instance_hash);
196
197 if state_file.exists() {
198 fs::remove_file(&state_file).await.map_err(|e| Error::Io {
199 source: e,
200 path: Some(state_file.into_boxed_path()),
201 operation: "remove_file".to_string(),
202 })?;
203 debug!(
204 "Removed execution state for directory hash: {}",
205 instance_hash
206 );
207 }
208
209 Ok(())
210 }
211
212 pub async fn list_active_states(&self) -> Result<Vec<HookExecutionState>> {
214 if !self.state_dir.exists() {
215 return Ok(Vec::new());
216 }
217
218 let mut states = Vec::new();
219 let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
220 source: e,
221 path: Some(self.state_dir.clone().into_boxed_path()),
222 operation: "read_dir".to_string(),
223 })?;
224
225 while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
226 source: e,
227 path: Some(self.state_dir.clone().into_boxed_path()),
228 operation: "next_entry".to_string(),
229 })? {
230 let path = entry.path();
231 if path.extension().and_then(|s| s.to_str()) == Some("json")
232 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
233 && let Ok(Some(state)) = self.load_state(stem).await
234 {
235 states.push(state);
236 }
237 }
238
239 Ok(states)
240 }
241
242 pub async fn cleanup_state_directory(&self) -> Result<usize> {
244 if !self.state_dir.exists() {
245 return Ok(0);
246 }
247
248 let mut cleaned_count = 0;
249 let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
250 source: e,
251 path: Some(self.state_dir.clone().into_boxed_path()),
252 operation: "read_dir".to_string(),
253 })?;
254
255 while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
256 source: e,
257 path: Some(self.state_dir.clone().into_boxed_path()),
258 operation: "next_entry".to_string(),
259 })? {
260 let path = entry.path();
261
262 if path.extension().and_then(|s| s.to_str()) == Some("json") {
264 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
266 match self.load_state(stem).await {
267 Ok(Some(state)) if state.is_complete() => {
268 if let Err(e) = fs::remove_file(&path).await {
270 warn!("Failed to remove state file {}: {}", path.display(), e);
271 } else {
272 cleaned_count += 1;
273 debug!("Cleaned up state file: {}", path.display());
274 }
275 }
276 Ok(Some(_)) => {
277 debug!("Keeping active state file: {}", path.display());
279 }
280 Ok(None) => {}
281 Err(e) => {
282 warn!("Failed to parse state file {}: {}", path.display(), e);
284 if let Err(rm_err) = fs::remove_file(&path).await {
285 error!(
286 "Failed to remove corrupted state file {}: {}",
287 path.display(),
288 rm_err
289 );
290 } else {
291 cleaned_count += 1;
292 info!("Removed corrupted state file: {}", path.display());
293 }
294 }
295 }
296 }
297 }
298 }
299
300 if cleaned_count > 0 {
301 info!("Cleaned up {} state files from directory", cleaned_count);
302 }
303
304 Ok(cleaned_count)
305 }
306
307 pub async fn cleanup_orphaned_states(&self, max_age: chrono::Duration) -> Result<usize> {
309 let cutoff = Utc::now() - max_age;
310 let mut cleaned_count = 0;
311
312 for state in self.list_active_states().await? {
313 if state.status == ExecutionStatus::Running && state.started_at < cutoff {
315 warn!(
316 "Found orphaned running state for {} (started {}), removing",
317 state.directory_path.display(),
318 state.started_at
319 );
320 self.remove_state(&state.instance_hash).await?;
321 cleaned_count += 1;
322 }
323 }
324
325 if cleaned_count > 0 {
326 info!("Cleaned up {} orphaned state files", cleaned_count);
327 }
328
329 Ok(cleaned_count)
330 }
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
335pub struct HookExecutionState {
336 pub instance_hash: String,
338 pub directory_path: PathBuf,
340 pub config_hash: String,
342 pub status: ExecutionStatus,
344 pub total_hooks: usize,
346 pub completed_hooks: usize,
348 pub current_hook_index: Option<usize>,
350 #[serde(default)]
352 pub hooks: Vec<crate::hooks::types::Hook>,
353 pub hook_results: HashMap<usize, HookResult>,
355 pub started_at: DateTime<Utc>,
357 pub finished_at: Option<DateTime<Utc>>,
359 pub current_hook_started_at: Option<DateTime<Utc>>,
361 pub completed_display_until: Option<DateTime<Utc>>,
363 pub error_message: Option<String>,
365 pub environment_vars: HashMap<String, String>,
367 pub previous_env: Option<HashMap<String, String>>,
369}
370
371impl HookExecutionState {
372 pub fn new(
374 directory_path: PathBuf,
375 instance_hash: String,
376 config_hash: String,
377 hooks: Vec<crate::hooks::types::Hook>,
378 ) -> Self {
379 let total_hooks = hooks.len();
380 Self {
381 instance_hash,
382 directory_path,
383 config_hash,
384 status: ExecutionStatus::Running,
385 total_hooks,
386 completed_hooks: 0,
387 current_hook_index: None,
388 hooks,
389 hook_results: HashMap::new(),
390 started_at: Utc::now(),
391 finished_at: None,
392 current_hook_started_at: None,
393 completed_display_until: None,
394 error_message: None,
395 environment_vars: HashMap::new(),
396 previous_env: None,
397 }
398 }
399
400 pub fn mark_hook_running(&mut self, hook_index: usize) {
402 self.current_hook_index = Some(hook_index);
403 self.current_hook_started_at = Some(Utc::now());
404 info!(
405 "Started executing hook {} of {}",
406 hook_index + 1,
407 self.total_hooks
408 );
409 }
410
411 pub fn record_hook_result(&mut self, hook_index: usize, result: HookResult) {
413 self.hook_results.insert(hook_index, result.clone());
414 self.completed_hooks += 1;
415 self.current_hook_index = None;
416 self.current_hook_started_at = None;
417
418 if result.success {
419 info!(
420 "Hook {} of {} completed successfully",
421 hook_index + 1,
422 self.total_hooks
423 );
424 } else {
425 error!(
426 "Hook {} of {} failed: {:?}",
427 hook_index + 1,
428 self.total_hooks,
429 result.error
430 );
431 self.status = ExecutionStatus::Failed;
432 self.error_message = result.error.clone();
433 self.finished_at = Some(Utc::now());
434 self.completed_display_until = Some(Utc::now() + chrono::Duration::seconds(2));
436 return;
437 }
438
439 if self.completed_hooks == self.total_hooks {
441 self.status = ExecutionStatus::Completed;
442 let now = Utc::now();
443 self.finished_at = Some(now);
444 self.completed_display_until = Some(now + chrono::Duration::seconds(2));
446 info!("All {} hooks completed successfully", self.total_hooks);
447 }
448 }
449
450 pub fn mark_cancelled(&mut self, reason: Option<String>) {
452 self.status = ExecutionStatus::Cancelled;
453 self.finished_at = Some(Utc::now());
454 self.error_message = reason;
455 self.current_hook_index = None;
456 }
457
458 pub fn is_complete(&self) -> bool {
460 matches!(
461 self.status,
462 ExecutionStatus::Completed | ExecutionStatus::Failed | ExecutionStatus::Cancelled
463 )
464 }
465
466 pub fn progress_display(&self) -> String {
468 match &self.status {
469 ExecutionStatus::Running => {
470 if let Some(current) = self.current_hook_index {
471 format!(
472 "Executing hook {} of {} ({})",
473 current + 1,
474 self.total_hooks,
475 self.status
476 )
477 } else {
478 format!(
479 "{} of {} hooks completed",
480 self.completed_hooks, self.total_hooks
481 )
482 }
483 }
484 ExecutionStatus::Completed => "All hooks completed successfully".to_string(),
485 ExecutionStatus::Failed => {
486 if let Some(error) = &self.error_message {
487 format!("Hook execution failed: {}", error)
488 } else {
489 "Hook execution failed".to_string()
490 }
491 }
492 ExecutionStatus::Cancelled => {
493 if let Some(reason) = &self.error_message {
494 format!("Hook execution cancelled: {}", reason)
495 } else {
496 "Hook execution cancelled".to_string()
497 }
498 }
499 }
500 }
501
502 pub fn duration(&self) -> chrono::Duration {
504 let end = self.finished_at.unwrap_or_else(Utc::now);
505 end - self.started_at
506 }
507
508 pub fn current_hook_duration(&self) -> Option<chrono::Duration> {
510 self.current_hook_started_at
511 .map(|started| Utc::now() - started)
512 }
513
514 pub fn current_hook(&self) -> Option<&crate::hooks::types::Hook> {
516 self.current_hook_index.and_then(|idx| self.hooks.get(idx))
517 }
518
519 pub fn format_duration(duration: chrono::Duration) -> String {
521 let total_secs = duration.num_seconds();
522
523 if total_secs < 60 {
524 let millis = duration.num_milliseconds();
526 format!("{:.1}s", millis as f64 / 1000.0)
527 } else if total_secs < 3600 {
528 let mins = total_secs / 60;
530 let secs = total_secs % 60;
531 if secs == 0 {
532 format!("{}m", mins)
533 } else {
534 format!("{}m {}s", mins, secs)
535 }
536 } else {
537 let hours = total_secs / 3600;
539 let mins = (total_secs % 3600) / 60;
540 if mins == 0 {
541 format!("{}h", hours)
542 } else {
543 format!("{}h {}m", hours, mins)
544 }
545 }
546 }
547
548 pub fn current_hook_display(&self) -> Option<String> {
550 let hook = if let Some(hook) = self.current_hook() {
552 Some(hook)
553 } else if self.status == ExecutionStatus::Running && self.completed_hooks < self.total_hooks
554 {
555 self.hooks.get(self.completed_hooks)
557 } else {
558 None
559 };
560
561 hook.map(|h| {
562 let cmd_name = h.command.split('/').next_back().unwrap_or(&h.command);
564
565 format!("`{}`", cmd_name)
567 })
568 }
569
570 pub fn should_display_completed(&self) -> bool {
572 if let Some(display_until) = self.completed_display_until {
573 Utc::now() < display_until
574 } else {
575 false
576 }
577 }
578}
579
580pub fn compute_instance_hash(path: &Path, config_hash: &str) -> String {
582 use sha2::{Digest, Sha256};
583 let mut hasher = Sha256::new();
584 hasher.update(path.to_string_lossy().as_bytes());
585 hasher.update(b":");
586 hasher.update(config_hash.as_bytes());
587 hasher.update(b":");
590 hasher.update(crate::VERSION.as_bytes());
591 format!("{:x}", hasher.finalize())[..16].to_string()
592}
593
594pub fn compute_execution_hash(hooks: &[crate::hooks::types::Hook], base_dir: &Path) -> String {
598 use sha2::{Digest, Sha256};
599 let mut hasher = Sha256::new();
600
601 if let Ok(hooks_json) = serde_json::to_string(hooks) {
603 hasher.update(hooks_json.as_bytes());
604 }
605
606 for hook in hooks {
608 let hook_dir = hook
610 .dir
611 .as_ref()
612 .map(PathBuf::from)
613 .unwrap_or_else(|| base_dir.to_path_buf());
614
615 for input in &hook.inputs {
616 let input_path = hook_dir.join(input);
617 if let Ok(content) = std::fs::read(&input_path) {
618 hasher.update(b"file:");
619 hasher.update(input.as_bytes());
620 hasher.update(b":");
621 hasher.update(&content);
622 }
623 }
624 }
625
626 hasher.update(b":version:");
628 hasher.update(crate::VERSION.as_bytes());
629
630 format!("{:x}", hasher.finalize())[..16].to_string()
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use crate::hooks::types::{Hook, HookResult};
637 use std::collections::HashMap;
638 use std::os::unix::process::ExitStatusExt;
639 use std::sync::Arc;
640 use std::time::Duration;
641 use tempfile::TempDir;
642
643 #[test]
644 fn test_compute_instance_hash() {
645 let path = Path::new("/test/path");
646 let config_hash = "test_config";
647 let hash = compute_instance_hash(path, config_hash);
648 assert_eq!(hash.len(), 16);
649
650 let hash2 = compute_instance_hash(path, config_hash);
652 assert_eq!(hash, hash2);
653
654 let different_path = Path::new("/other/path");
656 let different_hash = compute_instance_hash(different_path, config_hash);
657 assert_ne!(hash, different_hash);
658
659 let different_config_hash = compute_instance_hash(path, "different_config");
661 assert_ne!(hash, different_config_hash);
662 }
663
664 #[tokio::test]
665 async fn test_state_manager_operations() {
666 let temp_dir = TempDir::new().unwrap();
667 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
668
669 let directory_path = PathBuf::from("/test/dir");
670 let config_hash = "test_config_hash".to_string();
671 let instance_hash = compute_instance_hash(&directory_path, &config_hash);
672
673 let hooks = vec![
674 Hook {
675 order: 100,
676 propagate: false,
677 command: "echo".to_string(),
678 args: vec!["test1".to_string()],
679 dir: None,
680 inputs: vec![],
681 source: None,
682 },
683 Hook {
684 order: 100,
685 propagate: false,
686 command: "echo".to_string(),
687 args: vec!["test2".to_string()],
688 dir: None,
689 inputs: vec![],
690 source: None,
691 },
692 ];
693
694 let mut state =
695 HookExecutionState::new(directory_path, instance_hash.clone(), config_hash, hooks);
696
697 state_manager.save_state(&state).await.unwrap();
699
700 let loaded_state = state_manager
702 .load_state(&instance_hash)
703 .await
704 .unwrap()
705 .unwrap();
706 assert_eq!(loaded_state.instance_hash, state.instance_hash);
707 assert_eq!(loaded_state.total_hooks, 2);
708 assert_eq!(loaded_state.status, ExecutionStatus::Running);
709
710 let hook = Hook {
712 order: 100,
713 propagate: false,
714 command: "echo".to_string(),
715 args: vec!["test".to_string()],
716 dir: None,
717 inputs: Vec::new(),
718 source: Some(false),
719 };
720
721 let result = HookResult::success(
722 hook,
723 std::process::ExitStatus::from_raw(0),
724 "test\n".to_string(),
725 "".to_string(),
726 100,
727 );
728
729 state.record_hook_result(0, result);
730 state_manager.save_state(&state).await.unwrap();
731
732 let updated_state = state_manager
734 .load_state(&instance_hash)
735 .await
736 .unwrap()
737 .unwrap();
738 assert_eq!(updated_state.completed_hooks, 1);
739 assert_eq!(updated_state.hook_results.len(), 1);
740
741 state_manager.remove_state(&instance_hash).await.unwrap();
743 let removed_state = state_manager.load_state(&instance_hash).await.unwrap();
744 assert!(removed_state.is_none());
745 }
746
747 #[test]
748 fn test_hook_execution_state() {
749 let directory_path = PathBuf::from("/test/dir");
750 let instance_hash = "test_hash".to_string();
751 let config_hash = "config_hash".to_string();
752 let hooks = vec![
753 Hook {
754 order: 100,
755 propagate: false,
756 command: "echo".to_string(),
757 args: vec!["test1".to_string()],
758 dir: None,
759 inputs: vec![],
760 source: None,
761 },
762 Hook {
763 order: 100,
764 propagate: false,
765 command: "echo".to_string(),
766 args: vec!["test2".to_string()],
767 dir: None,
768 inputs: vec![],
769 source: None,
770 },
771 Hook {
772 order: 100,
773 propagate: false,
774 command: "echo".to_string(),
775 args: vec!["test3".to_string()],
776 dir: None,
777 inputs: vec![],
778 source: None,
779 },
780 ];
781 let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
782
783 assert_eq!(state.status, ExecutionStatus::Running);
785 assert_eq!(state.total_hooks, 3);
786 assert_eq!(state.completed_hooks, 0);
787 assert!(!state.is_complete());
788
789 state.mark_hook_running(0);
791 assert_eq!(state.current_hook_index, Some(0));
792
793 let hook = Hook {
795 order: 100,
796 propagate: false,
797 command: "echo".to_string(),
798 args: vec![],
799 dir: None,
800 inputs: Vec::new(),
801 source: Some(false),
802 };
803
804 let result = HookResult::success(
805 hook.clone(),
806 std::process::ExitStatus::from_raw(0),
807 "".to_string(),
808 "".to_string(),
809 100,
810 );
811
812 state.record_hook_result(0, result);
813 assert_eq!(state.completed_hooks, 1);
814 assert_eq!(state.current_hook_index, None);
815 assert_eq!(state.status, ExecutionStatus::Running);
816 assert!(!state.is_complete());
817
818 let failed_result = HookResult::failure(
820 hook,
821 Some(std::process::ExitStatus::from_raw(256)),
822 "".to_string(),
823 "error".to_string(),
824 50,
825 "Command failed".to_string(),
826 );
827
828 state.record_hook_result(1, failed_result);
829 assert_eq!(state.completed_hooks, 2);
830 assert_eq!(state.status, ExecutionStatus::Failed);
831 assert!(state.is_complete());
832 assert!(state.error_message.is_some());
833
834 let mut cancelled_state = HookExecutionState::new(
836 PathBuf::from("/test"),
837 "hash".to_string(),
838 "config".to_string(),
839 vec![Hook {
840 order: 100,
841 propagate: false,
842 command: "echo".to_string(),
843 args: vec![],
844 dir: None,
845 inputs: vec![],
846 source: None,
847 }],
848 );
849 cancelled_state.mark_cancelled(Some("User cancelled".to_string()));
850 assert_eq!(cancelled_state.status, ExecutionStatus::Cancelled);
851 assert!(cancelled_state.is_complete());
852 }
853
854 #[test]
855 fn test_progress_display() {
856 let directory_path = PathBuf::from("/test/dir");
857 let instance_hash = "test_hash".to_string();
858 let config_hash = "config_hash".to_string();
859 let hooks = vec![
860 Hook {
861 order: 100,
862 propagate: false,
863 command: "echo".to_string(),
864 args: vec!["test1".to_string()],
865 dir: None,
866 inputs: vec![],
867 source: None,
868 },
869 Hook {
870 order: 100,
871 propagate: false,
872 command: "echo".to_string(),
873 args: vec!["test2".to_string()],
874 dir: None,
875 inputs: vec![],
876 source: None,
877 },
878 ];
879 let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
880
881 let display = state.progress_display();
883 assert!(display.contains("0 of 2"));
884
885 state.mark_hook_running(0);
887 let display = state.progress_display();
888 assert!(display.contains("Executing hook 1 of 2"));
889
890 state.status = ExecutionStatus::Completed;
892 state.current_hook_index = None;
893 let display = state.progress_display();
894 assert_eq!(display, "All hooks completed successfully");
895
896 state.status = ExecutionStatus::Failed;
898 state.error_message = Some("Test error".to_string());
899 let display = state.progress_display();
900 assert!(display.contains("Hook execution failed: Test error"));
901 }
902
903 #[tokio::test]
904 async fn test_state_directory_cleanup() {
905 let temp_dir = TempDir::new().unwrap();
906 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
907
908 let completed_state = HookExecutionState {
910 instance_hash: "completed_hash".to_string(),
911 directory_path: PathBuf::from("/completed"),
912 config_hash: "config1".to_string(),
913 status: ExecutionStatus::Completed,
914 total_hooks: 1,
915 completed_hooks: 1,
916 current_hook_index: None,
917 hooks: vec![],
918 hook_results: HashMap::new(),
919 environment_vars: HashMap::new(),
920 started_at: Utc::now() - chrono::Duration::hours(1),
921 finished_at: Some(Utc::now() - chrono::Duration::minutes(30)),
922 current_hook_started_at: None,
923 completed_display_until: None,
924 error_message: None,
925 previous_env: None,
926 };
927
928 let running_state = HookExecutionState {
929 instance_hash: "running_hash".to_string(),
930 directory_path: PathBuf::from("/running"),
931 config_hash: "config2".to_string(),
932 status: ExecutionStatus::Running,
933 total_hooks: 2,
934 completed_hooks: 1,
935 current_hook_index: Some(1),
936 hooks: vec![],
937 hook_results: HashMap::new(),
938 environment_vars: HashMap::new(),
939 started_at: Utc::now() - chrono::Duration::minutes(5),
940 finished_at: None,
941 current_hook_started_at: None,
942 completed_display_until: None,
943 error_message: None,
944 previous_env: None,
945 };
946
947 let failed_state = HookExecutionState {
948 instance_hash: "failed_hash".to_string(),
949 directory_path: PathBuf::from("/failed"),
950 config_hash: "config3".to_string(),
951 status: ExecutionStatus::Failed,
952 total_hooks: 1,
953 completed_hooks: 0,
954 current_hook_index: None,
955 hooks: vec![],
956 hook_results: HashMap::new(),
957 environment_vars: HashMap::new(),
958 started_at: Utc::now() - chrono::Duration::hours(2),
959 finished_at: Some(Utc::now() - chrono::Duration::hours(1)),
960 current_hook_started_at: None,
961 completed_display_until: None,
962 error_message: Some("Test failure".to_string()),
963 previous_env: None,
964 };
965
966 state_manager.save_state(&completed_state).await.unwrap();
968 state_manager.save_state(&running_state).await.unwrap();
969 state_manager.save_state(&failed_state).await.unwrap();
970
971 let states = state_manager.list_active_states().await.unwrap();
973 assert_eq!(states.len(), 3);
974
975 let cleaned = state_manager.cleanup_state_directory().await.unwrap();
977 assert_eq!(cleaned, 2); let remaining_states = state_manager.list_active_states().await.unwrap();
981 assert_eq!(remaining_states.len(), 1);
982 assert_eq!(remaining_states[0].instance_hash, "running_hash");
983 }
984
985 #[tokio::test]
986 async fn test_cleanup_orphaned_states() {
987 let temp_dir = TempDir::new().unwrap();
988 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
989
990 let orphaned_state = HookExecutionState {
992 instance_hash: "orphaned_hash".to_string(),
993 directory_path: PathBuf::from("/orphaned"),
994 config_hash: "config".to_string(),
995 status: ExecutionStatus::Running,
996 total_hooks: 1,
997 completed_hooks: 0,
998 current_hook_index: Some(0),
999 hooks: vec![],
1000 hook_results: HashMap::new(),
1001 environment_vars: HashMap::new(),
1002 started_at: Utc::now() - chrono::Duration::hours(3),
1003 finished_at: None,
1004 current_hook_started_at: None,
1005 completed_display_until: None,
1006 error_message: None,
1007 previous_env: None,
1008 };
1009
1010 let recent_state = HookExecutionState {
1012 instance_hash: "recent_hash".to_string(),
1013 directory_path: PathBuf::from("/recent"),
1014 config_hash: "config".to_string(),
1015 status: ExecutionStatus::Running,
1016 total_hooks: 1,
1017 completed_hooks: 0,
1018 current_hook_index: Some(0),
1019 hooks: vec![],
1020 hook_results: HashMap::new(),
1021 environment_vars: HashMap::new(),
1022 started_at: Utc::now() - chrono::Duration::minutes(5),
1023 finished_at: None,
1024 current_hook_started_at: None,
1025 completed_display_until: None,
1026 error_message: None,
1027 previous_env: None,
1028 };
1029
1030 state_manager.save_state(&orphaned_state).await.unwrap();
1032 state_manager.save_state(&recent_state).await.unwrap();
1033
1034 let cleaned = state_manager
1036 .cleanup_orphaned_states(chrono::Duration::hours(1))
1037 .await
1038 .unwrap();
1039 assert_eq!(cleaned, 1); let remaining_states = state_manager.list_active_states().await.unwrap();
1043 assert_eq!(remaining_states.len(), 1);
1044 assert_eq!(remaining_states[0].instance_hash, "recent_hash");
1045 }
1046
1047 #[tokio::test]
1048 async fn test_corrupted_state_file_handling() {
1049 let temp_dir = TempDir::new().unwrap();
1050 let state_dir = temp_dir.path().join("state");
1051 let state_manager = StateManager::new(state_dir.clone());
1052
1053 state_manager.ensure_state_dir().await.unwrap();
1055
1056 let corrupted_file = state_dir.join("corrupted.json");
1058 tokio::fs::write(&corrupted_file, "{invalid json}")
1059 .await
1060 .unwrap();
1061
1062 let states = state_manager.list_active_states().await.unwrap();
1064 assert_eq!(states.len(), 0); let cleaned = state_manager.cleanup_state_directory().await.unwrap();
1068 assert_eq!(cleaned, 1);
1069
1070 assert!(!corrupted_file.exists());
1072 }
1073
1074 #[tokio::test]
1075 async fn test_concurrent_state_modifications() {
1076 use tokio::task;
1077
1078 let temp_dir = TempDir::new().unwrap();
1079 let state_manager = Arc::new(StateManager::new(temp_dir.path().to_path_buf()));
1080
1081 let initial_state = HookExecutionState {
1083 instance_hash: "concurrent_hash".to_string(),
1084 directory_path: PathBuf::from("/concurrent"),
1085 config_hash: "config".to_string(),
1086 status: ExecutionStatus::Running,
1087 total_hooks: 10,
1088 completed_hooks: 0,
1089 current_hook_index: Some(0),
1090 hooks: vec![],
1091 hook_results: HashMap::new(),
1092 environment_vars: HashMap::new(),
1093 started_at: Utc::now(),
1094 finished_at: None,
1095 current_hook_started_at: None,
1096 completed_display_until: None,
1097 error_message: None,
1098 previous_env: None,
1099 };
1100
1101 state_manager.save_state(&initial_state).await.unwrap();
1102
1103 let mut handles = vec![];
1105
1106 for i in 0..5 {
1107 let sm = state_manager.clone();
1108 let path = initial_state.directory_path.clone();
1109
1110 let handle = task::spawn(async move {
1111 let instance_hash = compute_instance_hash(&path, "concurrent_config");
1113
1114 tokio::time::sleep(Duration::from_millis(10)).await;
1116
1117 if let Ok(Some(mut state)) = sm.load_state(&instance_hash).await {
1119 state.completed_hooks += 1;
1120 state.current_hook_index = Some(i + 1);
1121
1122 let _ = sm.save_state(&state).await;
1124 }
1125 });
1126
1127 handles.push(handle);
1128 }
1129
1130 for handle in handles {
1132 handle.await.unwrap();
1133 }
1134
1135 let final_state = state_manager
1138 .load_state(&initial_state.instance_hash)
1139 .await
1140 .unwrap();
1141
1142 if let Some(state) = final_state {
1144 assert_eq!(state.instance_hash, "concurrent_hash");
1145 }
1147 }
1148
1149 #[tokio::test]
1150 async fn test_state_with_unicode_and_special_chars() {
1151 let temp_dir = TempDir::new().unwrap();
1152 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1153
1154 let mut unicode_state = HookExecutionState {
1156 instance_hash: "unicode_hash".to_string(),
1157 directory_path: PathBuf::from("/测试/目录/🚀"),
1158 config_hash: "config_ñ_é_ü".to_string(),
1159 status: ExecutionStatus::Failed,
1160 total_hooks: 1,
1161 completed_hooks: 1,
1162 current_hook_index: None,
1163 hooks: vec![],
1164 hook_results: HashMap::new(),
1165 environment_vars: HashMap::new(),
1166 started_at: Utc::now(),
1167 finished_at: Some(Utc::now()),
1168 current_hook_started_at: None,
1169 completed_display_until: None,
1170 error_message: Some("Error: 错误信息 with émojis 🔥💥".to_string()),
1171 previous_env: None,
1172 };
1173
1174 let unicode_hook = Hook {
1176 order: 100,
1177 propagate: false,
1178 command: "echo".to_string(),
1179 args: vec![],
1180 dir: None,
1181 inputs: vec![],
1182 source: None,
1183 };
1184 let unicode_result = HookResult {
1185 hook: unicode_hook,
1186 success: false,
1187 exit_status: Some(1),
1188 stdout: "输出: Hello 世界! 🌍".to_string(),
1189 stderr: "错误: ñoño error ⚠️".to_string(),
1190 duration_ms: 100,
1191 error: Some("失败了 😢".to_string()),
1192 };
1193 unicode_state.hook_results.insert(0, unicode_result);
1194
1195 state_manager.save_state(&unicode_state).await.unwrap();
1197
1198 let loaded = state_manager
1199 .load_state(&unicode_state.instance_hash)
1200 .await
1201 .unwrap()
1202 .unwrap();
1203
1204 assert_eq!(loaded.config_hash, "config_ñ_é_ü");
1206 assert_eq!(
1207 loaded.error_message,
1208 Some("Error: 错误信息 with émojis 🔥💥".to_string())
1209 );
1210
1211 let hook_result = loaded.hook_results.get(&0).unwrap();
1212 assert_eq!(hook_result.stdout, "输出: Hello 世界! 🌍");
1213 assert_eq!(hook_result.stderr, "错误: ñoño error ⚠️");
1214 assert_eq!(hook_result.error, Some("失败了 😢".to_string()));
1215 }
1216
1217 #[tokio::test]
1218 async fn test_state_directory_with_many_states() {
1219 let temp_dir = TempDir::new().unwrap();
1220 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1221
1222 for i in 0..50 {
1224 let state = HookExecutionState {
1225 instance_hash: format!("hash_{}", i),
1226 directory_path: PathBuf::from(format!("/dir/{}", i)),
1227 config_hash: format!("config_{}", i),
1228 status: if i % 3 == 0 {
1229 ExecutionStatus::Completed
1230 } else if i % 3 == 1 {
1231 ExecutionStatus::Running
1232 } else {
1233 ExecutionStatus::Failed
1234 },
1235 total_hooks: 1,
1236 completed_hooks: if i % 3 == 0 { 1 } else { 0 },
1237 current_hook_index: if i % 3 == 1 { Some(0) } else { None },
1238 hooks: vec![],
1239 hook_results: HashMap::new(),
1240 environment_vars: HashMap::new(),
1241 started_at: Utc::now() - chrono::Duration::hours(i as i64),
1242 finished_at: if i % 3 != 1 {
1243 Some(Utc::now() - chrono::Duration::hours(i as i64 - 1))
1244 } else {
1245 None
1246 },
1247 current_hook_started_at: None,
1248 completed_display_until: None,
1249 error_message: if i % 3 == 2 {
1250 Some(format!("Error {}", i))
1251 } else {
1252 None
1253 },
1254 previous_env: None,
1255 };
1256 state_manager.save_state(&state).await.unwrap();
1257 }
1258
1259 let listed = state_manager.list_active_states().await.unwrap();
1261 assert_eq!(listed.len(), 50);
1262
1263 let cleaned = state_manager
1265 .cleanup_orphaned_states(chrono::Duration::hours(24))
1266 .await
1267 .unwrap();
1268
1269 assert!(cleaned > 0);
1271 }
1272}