1use crate::hooks::types::{ExecutionStatus, HookResult};
4use crate::{Error, Result};
5use chrono::{DateTime, Utc};
6#[allow(unused_imports)] use fs4::fs_std::FileExt as SyncFileExt;
8use fs4::tokio::AsyncFileExt;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use tokio::fs;
14use tokio::fs::OpenOptions;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tracing::{debug, error, info, warn};
17
18#[derive(Debug, Clone)]
20pub struct StateManager {
21 state_dir: PathBuf,
22}
23
24impl StateManager {
25 pub fn new(state_dir: PathBuf) -> Self {
27 Self { state_dir }
28 }
29
30 pub fn default_state_dir() -> Result<PathBuf> {
37 crate::paths::hook_state_dir()
38 }
39
40 pub fn with_default_dir() -> Result<Self> {
42 Ok(Self::new(Self::default_state_dir()?))
43 }
44
45 pub fn get_state_dir(&self) -> &Path {
47 &self.state_dir
48 }
49
50 pub async fn ensure_state_dir(&self) -> Result<()> {
52 if !self.state_dir.exists() {
53 fs::create_dir_all(&self.state_dir)
54 .await
55 .map_err(|e| Error::Io {
56 source: e,
57 path: Some(self.state_dir.clone().into_boxed_path()),
58 operation: "create_dir_all".to_string(),
59 })?;
60 debug!("Created state directory: {}", self.state_dir.display());
61 }
62 Ok(())
63 }
64
65 fn state_file_path(&self, instance_hash: &str) -> PathBuf {
67 self.state_dir.join(format!("{}.json", instance_hash))
68 }
69
70 pub fn get_state_file_path(&self, instance_hash: &str) -> PathBuf {
72 self.state_dir.join(format!("{}.json", instance_hash))
73 }
74
75 pub async fn save_state(&self, state: &HookExecutionState) -> Result<()> {
77 self.ensure_state_dir().await?;
78
79 let state_file = self.state_file_path(&state.instance_hash);
80 let json = serde_json::to_string_pretty(state)
81 .map_err(|e| Error::configuration(format!("Failed to serialize state: {e}")))?;
82
83 let temp_path = state_file.with_extension("tmp");
85
86 let mut file = OpenOptions::new()
88 .write(true)
89 .create(true)
90 .truncate(true)
91 .open(&temp_path)
92 .await
93 .map_err(|e| Error::Io {
94 source: e,
95 path: Some(temp_path.clone().into_boxed_path()),
96 operation: "open".to_string(),
97 })?;
98
99 file.lock_exclusive().map_err(|e| {
101 Error::configuration(format!(
102 "Failed to acquire exclusive lock on state temp file: {}",
103 e
104 ))
105 })?;
106
107 file.write_all(json.as_bytes())
108 .await
109 .map_err(|e| Error::Io {
110 source: e,
111 path: Some(temp_path.clone().into_boxed_path()),
112 operation: "write_all".to_string(),
113 })?;
114
115 file.sync_all().await.map_err(|e| Error::Io {
116 source: e,
117 path: Some(temp_path.clone().into_boxed_path()),
118 operation: "sync_all".to_string(),
119 })?;
120
121 drop(file);
123
124 fs::rename(&temp_path, &state_file)
126 .await
127 .map_err(|e| Error::Io {
128 source: e,
129 path: Some(state_file.clone().into_boxed_path()),
130 operation: "rename".to_string(),
131 })?;
132
133 debug!(
134 "Saved execution state for directory hash: {}",
135 state.instance_hash
136 );
137 Ok(())
138 }
139
140 pub async fn load_state(&self, instance_hash: &str) -> Result<Option<HookExecutionState>> {
142 let state_file = self.state_file_path(instance_hash);
143
144 if !state_file.exists() {
145 return Ok(None);
146 }
147
148 let mut file = match OpenOptions::new().read(true).open(&state_file).await {
150 Ok(f) => f,
151 Err(e) => {
152 if e.kind() == std::io::ErrorKind::NotFound {
154 return Ok(None);
155 }
156 return Err(Error::Io {
157 source: e,
158 path: Some(state_file.clone().into_boxed_path()),
159 operation: "open".to_string(),
160 });
161 }
162 };
163
164 file.lock_shared().map_err(|e| {
166 Error::configuration(format!(
167 "Failed to acquire shared lock on state file: {}",
168 e
169 ))
170 })?;
171
172 let mut contents = String::new();
173 file.read_to_string(&mut contents)
174 .await
175 .map_err(|e| Error::Io {
176 source: e,
177 path: Some(state_file.clone().into_boxed_path()),
178 operation: "read_to_string".to_string(),
179 })?;
180
181 drop(file);
183
184 let state: HookExecutionState = serde_json::from_str(&contents)
185 .map_err(|e| Error::configuration(format!("Failed to deserialize state: {e}")))?;
186
187 debug!(
188 "Loaded execution state for directory hash: {}",
189 instance_hash
190 );
191 Ok(Some(state))
192 }
193
194 pub async fn remove_state(&self, instance_hash: &str) -> Result<()> {
196 let state_file = self.state_file_path(instance_hash);
197
198 if state_file.exists() {
199 fs::remove_file(&state_file).await.map_err(|e| Error::Io {
200 source: e,
201 path: Some(state_file.into_boxed_path()),
202 operation: "remove_file".to_string(),
203 })?;
204 debug!(
205 "Removed execution state for directory hash: {}",
206 instance_hash
207 );
208 }
209
210 Ok(())
211 }
212
213 pub async fn list_active_states(&self) -> Result<Vec<HookExecutionState>> {
215 if !self.state_dir.exists() {
216 return Ok(Vec::new());
217 }
218
219 let mut states = Vec::new();
220 let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
221 source: e,
222 path: Some(self.state_dir.clone().into_boxed_path()),
223 operation: "read_dir".to_string(),
224 })?;
225
226 while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
227 source: e,
228 path: Some(self.state_dir.clone().into_boxed_path()),
229 operation: "next_entry".to_string(),
230 })? {
231 let path = entry.path();
232 if path.extension().and_then(|s| s.to_str()) == Some("json")
233 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
234 && let Ok(Some(state)) = self.load_state(stem).await
235 {
236 states.push(state);
237 }
238 }
239
240 Ok(states)
241 }
242
243 pub fn compute_directory_key(path: &Path) -> String {
250 use sha2::{Digest, Sha256};
251 let mut hasher = Sha256::new();
252 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
253 hasher.update(canonical.to_string_lossy().as_bytes());
254 format!("{:x}", hasher.finalize())[..16].to_string()
255 }
256
257 fn directory_marker_path(&self, directory_key: &str) -> PathBuf {
259 self.state_dir.join(format!("{}.marker", directory_key))
260 }
261
262 pub async fn create_directory_marker(
265 &self,
266 directory_path: &Path,
267 instance_hash: &str,
268 ) -> Result<()> {
269 self.ensure_state_dir().await?;
270 let dir_key = Self::compute_directory_key(directory_path);
271 let marker_path = self.directory_marker_path(&dir_key);
272
273 fs::write(&marker_path, instance_hash)
274 .await
275 .map_err(|e| Error::Io {
276 source: e,
277 path: Some(marker_path.into_boxed_path()),
278 operation: "write_marker".to_string(),
279 })?;
280
281 debug!(
282 "Created directory marker for {} -> {}",
283 directory_path.display(),
284 instance_hash
285 );
286 Ok(())
287 }
288
289 pub async fn remove_directory_marker(&self, directory_path: &Path) -> Result<()> {
292 let dir_key = Self::compute_directory_key(directory_path);
293 let marker_path = self.directory_marker_path(&dir_key);
294
295 if marker_path.exists() {
296 fs::remove_file(&marker_path).await.ok(); debug!("Removed directory marker for {}", directory_path.display());
298 }
299 Ok(())
300 }
301
302 pub fn has_active_marker(&self, directory_path: &Path) -> bool {
305 let dir_key = Self::compute_directory_key(directory_path);
306 self.directory_marker_path(&dir_key).exists()
307 }
308
309 pub async fn get_marker_instance_hash(&self, directory_path: &Path) -> Option<String> {
311 let dir_key = Self::compute_directory_key(directory_path);
312 let marker_path = self.directory_marker_path(&dir_key);
313 fs::read_to_string(&marker_path)
314 .await
315 .ok()
316 .map(|s| s.trim().to_string())
317 }
318
319 pub fn get_marker_instance_hash_sync(&self, directory_path: &Path) -> Option<String> {
326 let dir_key = Self::compute_directory_key(directory_path);
327 let marker_path = self.directory_marker_path(&dir_key);
328 std::fs::read_to_string(&marker_path)
329 .ok()
330 .map(|s| s.trim().to_string())
331 }
332
333 pub fn load_state_sync(&self, instance_hash: &str) -> Result<Option<HookExecutionState>> {
336 let state_file = self.state_file_path(instance_hash);
337
338 if !state_file.exists() {
339 return Ok(None);
340 }
341
342 let mut file = match std::fs::File::open(&state_file) {
344 Ok(f) => f,
345 Err(e) => {
346 if e.kind() == std::io::ErrorKind::NotFound {
348 return Ok(None);
349 }
350 return Err(Error::Io {
351 source: e,
352 path: Some(state_file.clone().into_boxed_path()),
353 operation: "open".to_string(),
354 });
355 }
356 };
357
358 file.lock_shared().map_err(|e| {
360 Error::configuration(format!(
361 "Failed to acquire shared lock on state file: {}",
362 e
363 ))
364 })?;
365
366 let mut contents = String::new();
367 file.read_to_string(&mut contents).map_err(|e| Error::Io {
368 source: e,
369 path: Some(state_file.clone().into_boxed_path()),
370 operation: "read_to_string".to_string(),
371 })?;
372
373 drop(file);
375
376 let state: HookExecutionState = serde_json::from_str(&contents)
377 .map_err(|e| Error::configuration(format!("Failed to deserialize state: {e}")))?;
378
379 Ok(Some(state))
380 }
381
382 pub async fn cleanup_state_directory(&self) -> Result<usize> {
388 if !self.state_dir.exists() {
389 return Ok(0);
390 }
391
392 let mut cleaned_count = 0;
393 let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
394 source: e,
395 path: Some(self.state_dir.clone().into_boxed_path()),
396 operation: "read_dir".to_string(),
397 })?;
398
399 while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
400 source: e,
401 path: Some(self.state_dir.clone().into_boxed_path()),
402 operation: "next_entry".to_string(),
403 })? {
404 let path = entry.path();
405
406 let extension = path.extension().and_then(|s| s.to_str());
407
408 if extension == Some("json") {
410 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
411 match self.load_state(stem).await {
412 Ok(Some(state)) if state.is_complete() => {
413 if let Err(e) = fs::remove_file(&path).await {
415 warn!("Failed to remove state file {}: {}", path.display(), e);
416 } else {
417 cleaned_count += 1;
418 debug!("Cleaned up state file: {}", path.display());
419 self.remove_directory_marker(&state.directory_path)
421 .await
422 .ok();
423 }
424 }
425 Ok(Some(_)) => {
426 debug!("Keeping active state file: {}", path.display());
428 }
429 Ok(None) => {}
430 Err(e) => {
431 warn!("Failed to parse state file {}: {}", path.display(), e);
433 if let Err(rm_err) = fs::remove_file(&path).await {
434 error!(
435 "Failed to remove corrupted state file {}: {}",
436 path.display(),
437 rm_err
438 );
439 } else {
440 cleaned_count += 1;
441 info!("Removed corrupted state file: {}", path.display());
442 }
443 }
444 }
445 }
446 }
447 else if extension == Some("marker")
449 && let Ok(instance_hash) = fs::read_to_string(&path).await
450 {
451 let instance_hash = instance_hash.trim();
452 match self.load_state(instance_hash).await {
454 Ok(None) => {
455 if fs::remove_file(&path).await.is_ok() {
457 cleaned_count += 1;
458 debug!("Cleaned up orphaned marker: {}", path.display());
459 }
460 }
461 Ok(Some(state)) if state.is_complete() && !state.should_display_completed() => {
462 if fs::remove_file(&path).await.is_ok() {
464 cleaned_count += 1;
465 debug!("Cleaned up expired marker: {}", path.display());
466 }
467 }
468 _ => {} }
470 }
471 }
472
473 if cleaned_count > 0 {
474 info!(
475 "Cleaned up {} state/marker files from directory",
476 cleaned_count
477 );
478 }
479
480 Ok(cleaned_count)
481 }
482
483 pub async fn cleanup_orphaned_states(&self, max_age: chrono::Duration) -> Result<usize> {
485 let cutoff = Utc::now() - max_age;
486 let mut cleaned_count = 0;
487
488 for state in self.list_active_states().await? {
489 if state.status == ExecutionStatus::Running && state.started_at < cutoff {
491 warn!(
492 "Found orphaned running state for {} (started {}), removing",
493 state.directory_path.display(),
494 state.started_at
495 );
496 self.remove_state(&state.instance_hash).await?;
497 self.remove_directory_marker(&state.directory_path)
499 .await
500 .ok();
501 cleaned_count += 1;
502 }
503 }
504
505 if cleaned_count > 0 {
506 info!("Cleaned up {} orphaned state files", cleaned_count);
507 }
508
509 Ok(cleaned_count)
510 }
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
515pub struct HookExecutionState {
516 pub instance_hash: String,
518 pub directory_path: PathBuf,
520 pub config_hash: String,
522 pub status: ExecutionStatus,
524 pub total_hooks: usize,
526 pub completed_hooks: usize,
528 pub current_hook_index: Option<usize>,
530 #[serde(default)]
532 pub hooks: Vec<crate::hooks::types::Hook>,
533 pub hook_results: HashMap<usize, HookResult>,
535 pub started_at: DateTime<Utc>,
537 pub finished_at: Option<DateTime<Utc>>,
539 pub current_hook_started_at: Option<DateTime<Utc>>,
541 pub completed_display_until: Option<DateTime<Utc>>,
543 pub error_message: Option<String>,
545 pub environment_vars: HashMap<String, String>,
547 pub previous_env: Option<HashMap<String, String>>,
549}
550
551impl HookExecutionState {
552 pub fn new(
554 directory_path: PathBuf,
555 instance_hash: String,
556 config_hash: String,
557 hooks: Vec<crate::hooks::types::Hook>,
558 ) -> Self {
559 let total_hooks = hooks.len();
560 Self {
561 instance_hash,
562 directory_path,
563 config_hash,
564 status: ExecutionStatus::Running,
565 total_hooks,
566 completed_hooks: 0,
567 current_hook_index: None,
568 hooks,
569 hook_results: HashMap::new(),
570 started_at: Utc::now(),
571 finished_at: None,
572 current_hook_started_at: None,
573 completed_display_until: None,
574 error_message: None,
575 environment_vars: HashMap::new(),
576 previous_env: None,
577 }
578 }
579
580 pub fn mark_hook_running(&mut self, hook_index: usize) {
582 self.current_hook_index = Some(hook_index);
583 self.current_hook_started_at = Some(Utc::now());
584 info!(
585 "Started executing hook {} of {}",
586 hook_index + 1,
587 self.total_hooks
588 );
589 }
590
591 pub fn record_hook_result(&mut self, hook_index: usize, result: HookResult) {
593 self.hook_results.insert(hook_index, result.clone());
594 self.completed_hooks += 1;
595 self.current_hook_index = None;
596 self.current_hook_started_at = None;
597
598 if result.success {
599 info!(
600 "Hook {} of {} completed successfully",
601 hook_index + 1,
602 self.total_hooks
603 );
604 } else {
605 error!(
606 "Hook {} of {} failed: {:?}",
607 hook_index + 1,
608 self.total_hooks,
609 result.error
610 );
611 self.status = ExecutionStatus::Failed;
612 self.error_message = result.error.clone();
613 self.finished_at = Some(Utc::now());
614 self.completed_display_until = Some(Utc::now() + chrono::Duration::seconds(2));
616 return;
617 }
618
619 if self.completed_hooks == self.total_hooks {
621 self.status = ExecutionStatus::Completed;
622 let now = Utc::now();
623 self.finished_at = Some(now);
624 self.completed_display_until = Some(now + chrono::Duration::seconds(2));
626 info!("All {} hooks completed successfully", self.total_hooks);
627 }
628 }
629
630 pub fn mark_cancelled(&mut self, reason: Option<String>) {
632 self.status = ExecutionStatus::Cancelled;
633 self.finished_at = Some(Utc::now());
634 self.error_message = reason;
635 self.current_hook_index = None;
636 }
637
638 pub fn is_complete(&self) -> bool {
640 matches!(
641 self.status,
642 ExecutionStatus::Completed | ExecutionStatus::Failed | ExecutionStatus::Cancelled
643 )
644 }
645
646 pub fn progress_display(&self) -> String {
648 match &self.status {
649 ExecutionStatus::Running => {
650 if let Some(current) = self.current_hook_index {
651 format!(
652 "Executing hook {} of {} ({})",
653 current + 1,
654 self.total_hooks,
655 self.status
656 )
657 } else {
658 format!(
659 "{} of {} hooks completed",
660 self.completed_hooks, self.total_hooks
661 )
662 }
663 }
664 ExecutionStatus::Completed => "All hooks completed successfully".to_string(),
665 ExecutionStatus::Failed => {
666 if let Some(error) = &self.error_message {
667 format!("Hook execution failed: {}", error)
668 } else {
669 "Hook execution failed".to_string()
670 }
671 }
672 ExecutionStatus::Cancelled => {
673 if let Some(reason) = &self.error_message {
674 format!("Hook execution cancelled: {}", reason)
675 } else {
676 "Hook execution cancelled".to_string()
677 }
678 }
679 }
680 }
681
682 pub fn duration(&self) -> chrono::Duration {
684 let end = self.finished_at.unwrap_or_else(Utc::now);
685 end - self.started_at
686 }
687
688 pub fn current_hook_duration(&self) -> Option<chrono::Duration> {
690 self.current_hook_started_at
691 .map(|started| Utc::now() - started)
692 }
693
694 pub fn current_hook(&self) -> Option<&crate::hooks::types::Hook> {
696 self.current_hook_index.and_then(|idx| self.hooks.get(idx))
697 }
698
699 pub fn format_duration(duration: chrono::Duration) -> String {
701 let total_secs = duration.num_seconds();
702
703 if total_secs < 60 {
704 let millis = duration.num_milliseconds();
706 format!("{:.1}s", millis as f64 / 1000.0)
707 } else if total_secs < 3600 {
708 let mins = total_secs / 60;
710 let secs = total_secs % 60;
711 if secs == 0 {
712 format!("{}m", mins)
713 } else {
714 format!("{}m {}s", mins, secs)
715 }
716 } else {
717 let hours = total_secs / 3600;
719 let mins = (total_secs % 3600) / 60;
720 if mins == 0 {
721 format!("{}h", hours)
722 } else {
723 format!("{}h {}m", hours, mins)
724 }
725 }
726 }
727
728 pub fn current_hook_display(&self) -> Option<String> {
730 let hook = if let Some(hook) = self.current_hook() {
732 Some(hook)
733 } else if self.status == ExecutionStatus::Running && self.completed_hooks < self.total_hooks
734 {
735 self.hooks.get(self.completed_hooks)
737 } else {
738 None
739 };
740
741 hook.map(|h| {
742 let cmd_name = h.command.split('/').next_back().unwrap_or(&h.command);
744
745 format!("`{}`", cmd_name)
747 })
748 }
749
750 pub fn should_display_completed(&self) -> bool {
752 if let Some(display_until) = self.completed_display_until {
753 Utc::now() < display_until
754 } else {
755 false
756 }
757 }
758}
759
760pub fn compute_instance_hash(path: &Path, config_hash: &str) -> String {
762 use sha2::{Digest, Sha256};
763 let mut hasher = Sha256::new();
764 hasher.update(path.to_string_lossy().as_bytes());
765 hasher.update(b":");
766 hasher.update(config_hash.as_bytes());
767 hasher.update(b":");
770 hasher.update(crate::VERSION.as_bytes());
771 format!("{:x}", hasher.finalize())[..16].to_string()
772}
773
774pub fn compute_execution_hash(hooks: &[crate::hooks::types::Hook], base_dir: &Path) -> String {
778 use sha2::{Digest, Sha256};
779 let mut hasher = Sha256::new();
780
781 if let Ok(hooks_json) = serde_json::to_string(hooks) {
783 hasher.update(hooks_json.as_bytes());
784 }
785
786 for hook in hooks {
788 let hook_dir = hook
790 .dir
791 .as_ref()
792 .map(PathBuf::from)
793 .unwrap_or_else(|| base_dir.to_path_buf());
794
795 for input in &hook.inputs {
796 let input_path = hook_dir.join(input);
797 if let Ok(content) = std::fs::read(&input_path) {
798 hasher.update(b"file:");
799 hasher.update(input.as_bytes());
800 hasher.update(b":");
801 hasher.update(&content);
802 }
803 }
804 }
805
806 hasher.update(b":version:");
808 hasher.update(crate::VERSION.as_bytes());
809
810 format!("{:x}", hasher.finalize())[..16].to_string()
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use crate::hooks::types::{Hook, HookResult};
817 use std::collections::HashMap;
818 use std::os::unix::process::ExitStatusExt;
819 use std::sync::Arc;
820 use std::time::Duration;
821 use tempfile::TempDir;
822
823 #[test]
824 fn test_compute_instance_hash() {
825 let path = Path::new("/test/path");
826 let config_hash = "test_config";
827 let hash = compute_instance_hash(path, config_hash);
828 assert_eq!(hash.len(), 16);
829
830 let hash2 = compute_instance_hash(path, config_hash);
832 assert_eq!(hash, hash2);
833
834 let different_path = Path::new("/other/path");
836 let different_hash = compute_instance_hash(different_path, config_hash);
837 assert_ne!(hash, different_hash);
838
839 let different_config_hash = compute_instance_hash(path, "different_config");
841 assert_ne!(hash, different_config_hash);
842 }
843
844 #[tokio::test]
845 async fn test_state_manager_operations() {
846 let temp_dir = TempDir::new().unwrap();
847 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
848
849 let directory_path = PathBuf::from("/test/dir");
850 let config_hash = "test_config_hash".to_string();
851 let instance_hash = compute_instance_hash(&directory_path, &config_hash);
852
853 let hooks = vec![
854 Hook {
855 order: 100,
856 propagate: false,
857 command: "echo".to_string(),
858 args: vec!["test1".to_string()],
859 dir: None,
860 inputs: vec![],
861 source: None,
862 },
863 Hook {
864 order: 100,
865 propagate: false,
866 command: "echo".to_string(),
867 args: vec!["test2".to_string()],
868 dir: None,
869 inputs: vec![],
870 source: None,
871 },
872 ];
873
874 let mut state =
875 HookExecutionState::new(directory_path, instance_hash.clone(), config_hash, hooks);
876
877 state_manager.save_state(&state).await.unwrap();
879
880 let loaded_state = state_manager
882 .load_state(&instance_hash)
883 .await
884 .unwrap()
885 .unwrap();
886 assert_eq!(loaded_state.instance_hash, state.instance_hash);
887 assert_eq!(loaded_state.total_hooks, 2);
888 assert_eq!(loaded_state.status, ExecutionStatus::Running);
889
890 let hook = Hook {
892 order: 100,
893 propagate: false,
894 command: "echo".to_string(),
895 args: vec!["test".to_string()],
896 dir: None,
897 inputs: Vec::new(),
898 source: Some(false),
899 };
900
901 let result = HookResult::success(
902 hook,
903 std::process::ExitStatus::from_raw(0),
904 "test\n".to_string(),
905 "".to_string(),
906 100,
907 );
908
909 state.record_hook_result(0, result);
910 state_manager.save_state(&state).await.unwrap();
911
912 let updated_state = state_manager
914 .load_state(&instance_hash)
915 .await
916 .unwrap()
917 .unwrap();
918 assert_eq!(updated_state.completed_hooks, 1);
919 assert_eq!(updated_state.hook_results.len(), 1);
920
921 state_manager.remove_state(&instance_hash).await.unwrap();
923 let removed_state = state_manager.load_state(&instance_hash).await.unwrap();
924 assert!(removed_state.is_none());
925 }
926
927 #[test]
928 fn test_hook_execution_state() {
929 let directory_path = PathBuf::from("/test/dir");
930 let instance_hash = "test_hash".to_string();
931 let config_hash = "config_hash".to_string();
932 let hooks = vec![
933 Hook {
934 order: 100,
935 propagate: false,
936 command: "echo".to_string(),
937 args: vec!["test1".to_string()],
938 dir: None,
939 inputs: vec![],
940 source: None,
941 },
942 Hook {
943 order: 100,
944 propagate: false,
945 command: "echo".to_string(),
946 args: vec!["test2".to_string()],
947 dir: None,
948 inputs: vec![],
949 source: None,
950 },
951 Hook {
952 order: 100,
953 propagate: false,
954 command: "echo".to_string(),
955 args: vec!["test3".to_string()],
956 dir: None,
957 inputs: vec![],
958 source: None,
959 },
960 ];
961 let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
962
963 assert_eq!(state.status, ExecutionStatus::Running);
965 assert_eq!(state.total_hooks, 3);
966 assert_eq!(state.completed_hooks, 0);
967 assert!(!state.is_complete());
968
969 state.mark_hook_running(0);
971 assert_eq!(state.current_hook_index, Some(0));
972
973 let hook = Hook {
975 order: 100,
976 propagate: false,
977 command: "echo".to_string(),
978 args: vec![],
979 dir: None,
980 inputs: Vec::new(),
981 source: Some(false),
982 };
983
984 let result = HookResult::success(
985 hook.clone(),
986 std::process::ExitStatus::from_raw(0),
987 "".to_string(),
988 "".to_string(),
989 100,
990 );
991
992 state.record_hook_result(0, result);
993 assert_eq!(state.completed_hooks, 1);
994 assert_eq!(state.current_hook_index, None);
995 assert_eq!(state.status, ExecutionStatus::Running);
996 assert!(!state.is_complete());
997
998 let failed_result = HookResult::failure(
1000 hook,
1001 Some(std::process::ExitStatus::from_raw(256)),
1002 "".to_string(),
1003 "error".to_string(),
1004 50,
1005 "Command failed".to_string(),
1006 );
1007
1008 state.record_hook_result(1, failed_result);
1009 assert_eq!(state.completed_hooks, 2);
1010 assert_eq!(state.status, ExecutionStatus::Failed);
1011 assert!(state.is_complete());
1012 assert!(state.error_message.is_some());
1013
1014 let mut cancelled_state = HookExecutionState::new(
1016 PathBuf::from("/test"),
1017 "hash".to_string(),
1018 "config".to_string(),
1019 vec![Hook {
1020 order: 100,
1021 propagate: false,
1022 command: "echo".to_string(),
1023 args: vec![],
1024 dir: None,
1025 inputs: vec![],
1026 source: None,
1027 }],
1028 );
1029 cancelled_state.mark_cancelled(Some("User cancelled".to_string()));
1030 assert_eq!(cancelled_state.status, ExecutionStatus::Cancelled);
1031 assert!(cancelled_state.is_complete());
1032 }
1033
1034 #[test]
1035 fn test_progress_display() {
1036 let directory_path = PathBuf::from("/test/dir");
1037 let instance_hash = "test_hash".to_string();
1038 let config_hash = "config_hash".to_string();
1039 let hooks = vec![
1040 Hook {
1041 order: 100,
1042 propagate: false,
1043 command: "echo".to_string(),
1044 args: vec!["test1".to_string()],
1045 dir: None,
1046 inputs: vec![],
1047 source: None,
1048 },
1049 Hook {
1050 order: 100,
1051 propagate: false,
1052 command: "echo".to_string(),
1053 args: vec!["test2".to_string()],
1054 dir: None,
1055 inputs: vec![],
1056 source: None,
1057 },
1058 ];
1059 let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
1060
1061 let display = state.progress_display();
1063 assert!(display.contains("0 of 2"));
1064
1065 state.mark_hook_running(0);
1067 let display = state.progress_display();
1068 assert!(display.contains("Executing hook 1 of 2"));
1069
1070 state.status = ExecutionStatus::Completed;
1072 state.current_hook_index = None;
1073 let display = state.progress_display();
1074 assert_eq!(display, "All hooks completed successfully");
1075
1076 state.status = ExecutionStatus::Failed;
1078 state.error_message = Some("Test error".to_string());
1079 let display = state.progress_display();
1080 assert!(display.contains("Hook execution failed: Test error"));
1081 }
1082
1083 #[tokio::test]
1084 async fn test_state_directory_cleanup() {
1085 let temp_dir = TempDir::new().unwrap();
1086 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1087
1088 let completed_state = HookExecutionState {
1090 instance_hash: "completed_hash".to_string(),
1091 directory_path: PathBuf::from("/completed"),
1092 config_hash: "config1".to_string(),
1093 status: ExecutionStatus::Completed,
1094 total_hooks: 1,
1095 completed_hooks: 1,
1096 current_hook_index: None,
1097 hooks: vec![],
1098 hook_results: HashMap::new(),
1099 environment_vars: HashMap::new(),
1100 started_at: Utc::now() - chrono::Duration::hours(1),
1101 finished_at: Some(Utc::now() - chrono::Duration::minutes(30)),
1102 current_hook_started_at: None,
1103 completed_display_until: None,
1104 error_message: None,
1105 previous_env: None,
1106 };
1107
1108 let running_state = HookExecutionState {
1109 instance_hash: "running_hash".to_string(),
1110 directory_path: PathBuf::from("/running"),
1111 config_hash: "config2".to_string(),
1112 status: ExecutionStatus::Running,
1113 total_hooks: 2,
1114 completed_hooks: 1,
1115 current_hook_index: Some(1),
1116 hooks: vec![],
1117 hook_results: HashMap::new(),
1118 environment_vars: HashMap::new(),
1119 started_at: Utc::now() - chrono::Duration::minutes(5),
1120 finished_at: None,
1121 current_hook_started_at: None,
1122 completed_display_until: None,
1123 error_message: None,
1124 previous_env: None,
1125 };
1126
1127 let failed_state = HookExecutionState {
1128 instance_hash: "failed_hash".to_string(),
1129 directory_path: PathBuf::from("/failed"),
1130 config_hash: "config3".to_string(),
1131 status: ExecutionStatus::Failed,
1132 total_hooks: 1,
1133 completed_hooks: 0,
1134 current_hook_index: None,
1135 hooks: vec![],
1136 hook_results: HashMap::new(),
1137 environment_vars: HashMap::new(),
1138 started_at: Utc::now() - chrono::Duration::hours(2),
1139 finished_at: Some(Utc::now() - chrono::Duration::hours(1)),
1140 current_hook_started_at: None,
1141 completed_display_until: None,
1142 error_message: Some("Test failure".to_string()),
1143 previous_env: None,
1144 };
1145
1146 state_manager.save_state(&completed_state).await.unwrap();
1148 state_manager.save_state(&running_state).await.unwrap();
1149 state_manager.save_state(&failed_state).await.unwrap();
1150
1151 let states = state_manager.list_active_states().await.unwrap();
1153 assert_eq!(states.len(), 3);
1154
1155 let cleaned = state_manager.cleanup_state_directory().await.unwrap();
1157 assert_eq!(cleaned, 2); let remaining_states = state_manager.list_active_states().await.unwrap();
1161 assert_eq!(remaining_states.len(), 1);
1162 assert_eq!(remaining_states[0].instance_hash, "running_hash");
1163 }
1164
1165 #[tokio::test]
1166 async fn test_cleanup_orphaned_states() {
1167 let temp_dir = TempDir::new().unwrap();
1168 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1169
1170 let orphaned_state = HookExecutionState {
1172 instance_hash: "orphaned_hash".to_string(),
1173 directory_path: PathBuf::from("/orphaned"),
1174 config_hash: "config".to_string(),
1175 status: ExecutionStatus::Running,
1176 total_hooks: 1,
1177 completed_hooks: 0,
1178 current_hook_index: Some(0),
1179 hooks: vec![],
1180 hook_results: HashMap::new(),
1181 environment_vars: HashMap::new(),
1182 started_at: Utc::now() - chrono::Duration::hours(3),
1183 finished_at: None,
1184 current_hook_started_at: None,
1185 completed_display_until: None,
1186 error_message: None,
1187 previous_env: None,
1188 };
1189
1190 let recent_state = HookExecutionState {
1192 instance_hash: "recent_hash".to_string(),
1193 directory_path: PathBuf::from("/recent"),
1194 config_hash: "config".to_string(),
1195 status: ExecutionStatus::Running,
1196 total_hooks: 1,
1197 completed_hooks: 0,
1198 current_hook_index: Some(0),
1199 hooks: vec![],
1200 hook_results: HashMap::new(),
1201 environment_vars: HashMap::new(),
1202 started_at: Utc::now() - chrono::Duration::minutes(5),
1203 finished_at: None,
1204 current_hook_started_at: None,
1205 completed_display_until: None,
1206 error_message: None,
1207 previous_env: None,
1208 };
1209
1210 state_manager.save_state(&orphaned_state).await.unwrap();
1212 state_manager.save_state(&recent_state).await.unwrap();
1213
1214 let cleaned = state_manager
1216 .cleanup_orphaned_states(chrono::Duration::hours(1))
1217 .await
1218 .unwrap();
1219 assert_eq!(cleaned, 1); let remaining_states = state_manager.list_active_states().await.unwrap();
1223 assert_eq!(remaining_states.len(), 1);
1224 assert_eq!(remaining_states[0].instance_hash, "recent_hash");
1225 }
1226
1227 #[tokio::test]
1228 async fn test_corrupted_state_file_handling() {
1229 let temp_dir = TempDir::new().unwrap();
1230 let state_dir = temp_dir.path().join("state");
1231 let state_manager = StateManager::new(state_dir.clone());
1232
1233 state_manager.ensure_state_dir().await.unwrap();
1235
1236 let corrupted_file = state_dir.join("corrupted.json");
1238 tokio::fs::write(&corrupted_file, "{invalid json}")
1239 .await
1240 .unwrap();
1241
1242 let states = state_manager.list_active_states().await.unwrap();
1244 assert_eq!(states.len(), 0); let cleaned = state_manager.cleanup_state_directory().await.unwrap();
1248 assert_eq!(cleaned, 1);
1249
1250 assert!(!corrupted_file.exists());
1252 }
1253
1254 #[tokio::test]
1255 async fn test_concurrent_state_modifications() {
1256 use tokio::task;
1257
1258 let temp_dir = TempDir::new().unwrap();
1259 let state_manager = Arc::new(StateManager::new(temp_dir.path().to_path_buf()));
1260
1261 let initial_state = HookExecutionState {
1263 instance_hash: "concurrent_hash".to_string(),
1264 directory_path: PathBuf::from("/concurrent"),
1265 config_hash: "config".to_string(),
1266 status: ExecutionStatus::Running,
1267 total_hooks: 10,
1268 completed_hooks: 0,
1269 current_hook_index: Some(0),
1270 hooks: vec![],
1271 hook_results: HashMap::new(),
1272 environment_vars: HashMap::new(),
1273 started_at: Utc::now(),
1274 finished_at: None,
1275 current_hook_started_at: None,
1276 completed_display_until: None,
1277 error_message: None,
1278 previous_env: None,
1279 };
1280
1281 state_manager.save_state(&initial_state).await.unwrap();
1282
1283 let mut handles = vec![];
1285
1286 for i in 0..5 {
1287 let sm = state_manager.clone();
1288 let path = initial_state.directory_path.clone();
1289
1290 let handle = task::spawn(async move {
1291 let instance_hash = compute_instance_hash(&path, "concurrent_config");
1293
1294 tokio::time::sleep(Duration::from_millis(10)).await;
1296
1297 if let Ok(Some(mut state)) = sm.load_state(&instance_hash).await {
1299 state.completed_hooks += 1;
1300 state.current_hook_index = Some(i + 1);
1301
1302 let _ = sm.save_state(&state).await;
1304 }
1305 });
1306
1307 handles.push(handle);
1308 }
1309
1310 for handle in handles {
1312 handle.await.unwrap();
1313 }
1314
1315 let final_state = state_manager
1318 .load_state(&initial_state.instance_hash)
1319 .await
1320 .unwrap();
1321
1322 if let Some(state) = final_state {
1324 assert_eq!(state.instance_hash, "concurrent_hash");
1325 }
1327 }
1328
1329 #[tokio::test]
1330 async fn test_state_with_unicode_and_special_chars() {
1331 let temp_dir = TempDir::new().unwrap();
1332 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1333
1334 let mut unicode_state = HookExecutionState {
1336 instance_hash: "unicode_hash".to_string(),
1337 directory_path: PathBuf::from("/测试/目录/🚀"),
1338 config_hash: "config_ñ_é_ü".to_string(),
1339 status: ExecutionStatus::Failed,
1340 total_hooks: 1,
1341 completed_hooks: 1,
1342 current_hook_index: None,
1343 hooks: vec![],
1344 hook_results: HashMap::new(),
1345 environment_vars: HashMap::new(),
1346 started_at: Utc::now(),
1347 finished_at: Some(Utc::now()),
1348 current_hook_started_at: None,
1349 completed_display_until: None,
1350 error_message: Some("Error: 错误信息 with émojis 🔥💥".to_string()),
1351 previous_env: None,
1352 };
1353
1354 let unicode_hook = Hook {
1356 order: 100,
1357 propagate: false,
1358 command: "echo".to_string(),
1359 args: vec![],
1360 dir: None,
1361 inputs: vec![],
1362 source: None,
1363 };
1364 let unicode_result = HookResult {
1365 hook: unicode_hook,
1366 success: false,
1367 exit_status: Some(1),
1368 stdout: "输出: Hello 世界! 🌍".to_string(),
1369 stderr: "错误: ñoño error ⚠️".to_string(),
1370 duration_ms: 100,
1371 error: Some("失败了 😢".to_string()),
1372 };
1373 unicode_state.hook_results.insert(0, unicode_result);
1374
1375 state_manager.save_state(&unicode_state).await.unwrap();
1377
1378 let loaded = state_manager
1379 .load_state(&unicode_state.instance_hash)
1380 .await
1381 .unwrap()
1382 .unwrap();
1383
1384 assert_eq!(loaded.config_hash, "config_ñ_é_ü");
1386 assert_eq!(
1387 loaded.error_message,
1388 Some("Error: 错误信息 with émojis 🔥💥".to_string())
1389 );
1390
1391 let hook_result = loaded.hook_results.get(&0).unwrap();
1392 assert_eq!(hook_result.stdout, "输出: Hello 世界! 🌍");
1393 assert_eq!(hook_result.stderr, "错误: ñoño error ⚠️");
1394 assert_eq!(hook_result.error, Some("失败了 😢".to_string()));
1395 }
1396
1397 #[tokio::test]
1398 async fn test_state_directory_with_many_states() {
1399 let temp_dir = TempDir::new().unwrap();
1400 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1401
1402 for i in 0..50 {
1404 let state = HookExecutionState {
1405 instance_hash: format!("hash_{}", i),
1406 directory_path: PathBuf::from(format!("/dir/{}", i)),
1407 config_hash: format!("config_{}", i),
1408 status: if i % 3 == 0 {
1409 ExecutionStatus::Completed
1410 } else if i % 3 == 1 {
1411 ExecutionStatus::Running
1412 } else {
1413 ExecutionStatus::Failed
1414 },
1415 total_hooks: 1,
1416 completed_hooks: if i % 3 == 0 { 1 } else { 0 },
1417 current_hook_index: if i % 3 == 1 { Some(0) } else { None },
1418 hooks: vec![],
1419 hook_results: HashMap::new(),
1420 environment_vars: HashMap::new(),
1421 started_at: Utc::now() - chrono::Duration::hours(i as i64),
1422 finished_at: if i % 3 != 1 {
1423 Some(Utc::now() - chrono::Duration::hours(i as i64 - 1))
1424 } else {
1425 None
1426 },
1427 current_hook_started_at: None,
1428 completed_display_until: None,
1429 error_message: if i % 3 == 2 {
1430 Some(format!("Error {}", i))
1431 } else {
1432 None
1433 },
1434 previous_env: None,
1435 };
1436 state_manager.save_state(&state).await.unwrap();
1437 }
1438
1439 let listed = state_manager.list_active_states().await.unwrap();
1441 assert_eq!(listed.len(), 50);
1442
1443 let cleaned = state_manager
1445 .cleanup_orphaned_states(chrono::Duration::hours(24))
1446 .await
1447 .unwrap();
1448
1449 assert!(cleaned > 0);
1451 }
1452}