1use crate::types::{ExecutionStatus, Hook, 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 #[must_use]
27 pub fn new(state_dir: PathBuf) -> Self {
28 Self { state_dir }
29 }
30
31 pub fn default_state_dir() -> Result<PathBuf> {
38 let base = dirs::state_dir()
39 .or_else(dirs::data_dir)
40 .ok_or_else(|| Error::configuration("Could not determine state directory"))?;
41 Ok(base.join("cuenv").join("hooks"))
42 }
43
44 pub fn with_default_dir() -> Result<Self> {
46 Ok(Self::new(Self::default_state_dir()?))
47 }
48
49 #[must_use]
51 pub fn get_state_dir(&self) -> &Path {
52 &self.state_dir
53 }
54
55 pub async fn ensure_state_dir(&self) -> Result<()> {
57 if !self.state_dir.exists() {
58 fs::create_dir_all(&self.state_dir)
59 .await
60 .map_err(|e| Error::Io {
61 source: e,
62 path: Some(self.state_dir.clone().into_boxed_path()),
63 operation: "create_dir_all".to_string(),
64 })?;
65 debug!("Created state directory: {}", self.state_dir.display());
66 }
67 Ok(())
68 }
69
70 fn state_file_path(&self, instance_hash: &str) -> PathBuf {
72 self.state_dir.join(format!("{}.json", instance_hash))
73 }
74
75 #[must_use]
77 pub fn get_state_file_path(&self, instance_hash: &str) -> PathBuf {
78 self.state_dir.join(format!("{}.json", instance_hash))
79 }
80
81 pub async fn save_state(&self, state: &HookExecutionState) -> Result<()> {
83 self.ensure_state_dir().await?;
84
85 let state_file = self.state_file_path(&state.instance_hash);
86 let json = serde_json::to_string_pretty(state)
87 .map_err(|e| Error::serialization(format!("Failed to serialize state: {e}")))?;
88
89 let temp_path = state_file.with_extension("tmp");
91
92 let mut file = OpenOptions::new()
94 .write(true)
95 .create(true)
96 .truncate(true)
97 .open(&temp_path)
98 .await
99 .map_err(|e| Error::Io {
100 source: e,
101 path: Some(temp_path.clone().into_boxed_path()),
102 operation: "open".to_string(),
103 })?;
104
105 file.lock_exclusive().map_err(|e| {
107 Error::configuration(format!(
108 "Failed to acquire exclusive lock on state temp file: {}",
109 e
110 ))
111 })?;
112
113 file.write_all(json.as_bytes())
114 .await
115 .map_err(|e| Error::Io {
116 source: e,
117 path: Some(temp_path.clone().into_boxed_path()),
118 operation: "write_all".to_string(),
119 })?;
120
121 file.sync_all().await.map_err(|e| Error::Io {
122 source: e,
123 path: Some(temp_path.clone().into_boxed_path()),
124 operation: "sync_all".to_string(),
125 })?;
126
127 drop(file);
129
130 fs::rename(&temp_path, &state_file)
132 .await
133 .map_err(|e| Error::Io {
134 source: e,
135 path: Some(state_file.clone().into_boxed_path()),
136 operation: "rename".to_string(),
137 })?;
138
139 debug!(
140 "Saved execution state for directory hash: {}",
141 state.instance_hash
142 );
143 Ok(())
144 }
145
146 pub async fn load_state(&self, instance_hash: &str) -> Result<Option<HookExecutionState>> {
148 let state_file = self.state_file_path(instance_hash);
149
150 if !state_file.exists() {
151 return Ok(None);
152 }
153
154 let mut file = match OpenOptions::new().read(true).open(&state_file).await {
156 Ok(f) => f,
157 Err(e) => {
158 if e.kind() == std::io::ErrorKind::NotFound {
160 return Ok(None);
161 }
162 return Err(Error::Io {
163 source: e,
164 path: Some(state_file.clone().into_boxed_path()),
165 operation: "open".to_string(),
166 });
167 }
168 };
169
170 file.lock_shared().map_err(|e| {
172 Error::configuration(format!(
173 "Failed to acquire shared lock on state file: {}",
174 e
175 ))
176 })?;
177
178 let mut contents = String::new();
179 file.read_to_string(&mut contents)
180 .await
181 .map_err(|e| Error::Io {
182 source: e,
183 path: Some(state_file.clone().into_boxed_path()),
184 operation: "read_to_string".to_string(),
185 })?;
186
187 drop(file);
189
190 let state: HookExecutionState = serde_json::from_str(&contents)
191 .map_err(|e| Error::serialization(format!("Failed to deserialize state: {e}")))?;
192
193 debug!(
194 "Loaded execution state for directory hash: {}",
195 instance_hash
196 );
197 Ok(Some(state))
198 }
199
200 pub async fn remove_state(&self, instance_hash: &str) -> Result<()> {
202 let state_file = self.state_file_path(instance_hash);
203
204 if state_file.exists() {
205 fs::remove_file(&state_file).await.map_err(|e| Error::Io {
206 source: e,
207 path: Some(state_file.into_boxed_path()),
208 operation: "remove_file".to_string(),
209 })?;
210 debug!(
211 "Removed execution state for directory hash: {}",
212 instance_hash
213 );
214 }
215
216 Ok(())
217 }
218
219 pub async fn list_active_states(&self) -> Result<Vec<HookExecutionState>> {
221 if !self.state_dir.exists() {
222 return Ok(Vec::new());
223 }
224
225 let mut states = Vec::new();
226 let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
227 source: e,
228 path: Some(self.state_dir.clone().into_boxed_path()),
229 operation: "read_dir".to_string(),
230 })?;
231
232 while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
233 source: e,
234 path: Some(self.state_dir.clone().into_boxed_path()),
235 operation: "next_entry".to_string(),
236 })? {
237 let path = entry.path();
238 if path.extension().and_then(|s| s.to_str()) == Some("json")
239 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
240 && let Ok(Some(state)) = self.load_state(stem).await
241 {
242 states.push(state);
243 }
244 }
245
246 Ok(states)
247 }
248
249 #[must_use]
256 pub fn compute_directory_key(path: &Path) -> String {
257 use sha2::{Digest, Sha256};
258 let mut hasher = Sha256::new();
259 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
260 hasher.update(canonical.to_string_lossy().as_bytes());
261 format!("{:x}", hasher.finalize())[..16].to_string()
262 }
263
264 fn directory_marker_path(&self, directory_key: &str) -> PathBuf {
266 self.state_dir.join(format!("{}.marker", directory_key))
267 }
268
269 pub async fn create_directory_marker(
272 &self,
273 directory_path: &Path,
274 instance_hash: &str,
275 ) -> Result<()> {
276 self.ensure_state_dir().await?;
277 let dir_key = Self::compute_directory_key(directory_path);
278 let marker_path = self.directory_marker_path(&dir_key);
279
280 fs::write(&marker_path, instance_hash)
281 .await
282 .map_err(|e| Error::Io {
283 source: e,
284 path: Some(marker_path.into_boxed_path()),
285 operation: "write_marker".to_string(),
286 })?;
287
288 debug!(
289 "Created directory marker for {} -> {}",
290 directory_path.display(),
291 instance_hash
292 );
293 Ok(())
294 }
295
296 pub async fn remove_directory_marker(&self, directory_path: &Path) -> Result<()> {
299 let dir_key = Self::compute_directory_key(directory_path);
300 let marker_path = self.directory_marker_path(&dir_key);
301
302 if marker_path.exists() {
303 fs::remove_file(&marker_path).await.ok(); debug!("Removed directory marker for {}", directory_path.display());
305 }
306 Ok(())
307 }
308
309 #[must_use]
312 pub fn has_active_marker(&self, directory_path: &Path) -> bool {
313 let dir_key = Self::compute_directory_key(directory_path);
314 self.directory_marker_path(&dir_key).exists()
315 }
316
317 pub async fn get_marker_instance_hash(&self, directory_path: &Path) -> Option<String> {
319 let dir_key = Self::compute_directory_key(directory_path);
320 let marker_path = self.directory_marker_path(&dir_key);
321 fs::read_to_string(&marker_path)
322 .await
323 .ok()
324 .map(|s| s.trim().to_string())
325 }
326
327 #[must_use]
334 pub fn get_marker_instance_hash_sync(&self, directory_path: &Path) -> Option<String> {
335 let dir_key = Self::compute_directory_key(directory_path);
336 let marker_path = self.directory_marker_path(&dir_key);
337 std::fs::read_to_string(&marker_path)
338 .ok()
339 .map(|s| s.trim().to_string())
340 }
341
342 pub fn load_state_sync(&self, instance_hash: &str) -> Result<Option<HookExecutionState>> {
345 let state_file = self.state_file_path(instance_hash);
346
347 if !state_file.exists() {
348 return Ok(None);
349 }
350
351 let mut file = match std::fs::File::open(&state_file) {
353 Ok(f) => f,
354 Err(e) => {
355 if e.kind() == std::io::ErrorKind::NotFound {
357 return Ok(None);
358 }
359 return Err(Error::Io {
360 source: e,
361 path: Some(state_file.clone().into_boxed_path()),
362 operation: "open".to_string(),
363 });
364 }
365 };
366
367 file.lock_shared().map_err(|e| {
369 Error::configuration(format!(
370 "Failed to acquire shared lock on state file: {}",
371 e
372 ))
373 })?;
374
375 let mut contents = String::new();
376 file.read_to_string(&mut contents).map_err(|e| Error::Io {
377 source: e,
378 path: Some(state_file.clone().into_boxed_path()),
379 operation: "read_to_string".to_string(),
380 })?;
381
382 drop(file);
384
385 let state: HookExecutionState = serde_json::from_str(&contents)
386 .map_err(|e| Error::serialization(format!("Failed to deserialize state: {e}")))?;
387
388 Ok(Some(state))
389 }
390
391 pub async fn cleanup_state_directory(&self) -> Result<usize> {
397 if !self.state_dir.exists() {
398 return Ok(0);
399 }
400
401 let mut cleaned_count = 0;
402 let mut dir = fs::read_dir(&self.state_dir).await.map_err(|e| Error::Io {
403 source: e,
404 path: Some(self.state_dir.clone().into_boxed_path()),
405 operation: "read_dir".to_string(),
406 })?;
407
408 while let Some(entry) = dir.next_entry().await.map_err(|e| Error::Io {
409 source: e,
410 path: Some(self.state_dir.clone().into_boxed_path()),
411 operation: "next_entry".to_string(),
412 })? {
413 let path = entry.path();
414
415 let extension = path.extension().and_then(|s| s.to_str());
416
417 if extension == Some("json") {
419 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
420 match self.load_state(stem).await {
421 Ok(Some(state)) if state.is_complete() => {
422 if let Err(e) = fs::remove_file(&path).await {
424 warn!("Failed to remove state file {}: {}", path.display(), e);
425 } else {
426 cleaned_count += 1;
427 debug!("Cleaned up state file: {}", path.display());
428 self.remove_directory_marker(&state.directory_path)
430 .await
431 .ok();
432 }
433 }
434 Ok(Some(_)) => {
435 debug!("Keeping active state file: {}", path.display());
437 }
438 Ok(None) => {}
439 Err(e) => {
440 warn!("Failed to parse state file {}: {}", path.display(), e);
442 if let Err(rm_err) = fs::remove_file(&path).await {
443 error!(
444 "Failed to remove corrupted state file {}: {}",
445 path.display(),
446 rm_err
447 );
448 } else {
449 cleaned_count += 1;
450 info!("Removed corrupted state file: {}", path.display());
451 }
452 }
453 }
454 }
455 }
456 else if extension == Some("marker")
458 && let Ok(instance_hash) = fs::read_to_string(&path).await
459 {
460 let instance_hash = instance_hash.trim();
461 match self.load_state(instance_hash).await {
463 Ok(None) => {
464 if fs::remove_file(&path).await.is_ok() {
466 cleaned_count += 1;
467 debug!("Cleaned up orphaned marker: {}", path.display());
468 }
469 }
470 Ok(Some(state)) if state.is_complete() && !state.should_display_completed() => {
471 if fs::remove_file(&path).await.is_ok() {
473 cleaned_count += 1;
474 debug!("Cleaned up expired marker: {}", path.display());
475 }
476 }
477 _ => {} }
479 }
480 }
481
482 if cleaned_count > 0 {
483 info!(
484 "Cleaned up {} state/marker files from directory",
485 cleaned_count
486 );
487 }
488
489 Ok(cleaned_count)
490 }
491
492 pub async fn cleanup_orphaned_states(&self, max_age: chrono::Duration) -> Result<usize> {
494 let cutoff = Utc::now() - max_age;
495 let mut cleaned_count = 0;
496
497 for state in self.list_active_states().await? {
498 if state.status == ExecutionStatus::Running && state.started_at < cutoff {
500 warn!(
501 "Found orphaned running state for {} (started {}), removing",
502 state.directory_path.display(),
503 state.started_at
504 );
505 self.remove_state(&state.instance_hash).await?;
506 self.remove_directory_marker(&state.directory_path)
508 .await
509 .ok();
510 cleaned_count += 1;
511 }
512 }
513
514 if cleaned_count > 0 {
515 info!("Cleaned up {} orphaned state files", cleaned_count);
516 }
517
518 Ok(cleaned_count)
519 }
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
524pub struct HookExecutionState {
525 pub instance_hash: String,
527 pub directory_path: PathBuf,
529 pub config_hash: String,
531 pub status: ExecutionStatus,
533 pub total_hooks: usize,
535 pub completed_hooks: usize,
537 pub current_hook_index: Option<usize>,
539 #[serde(default)]
541 pub hooks: Vec<Hook>,
542 pub hook_results: HashMap<usize, HookResult>,
544 pub started_at: DateTime<Utc>,
546 pub finished_at: Option<DateTime<Utc>>,
548 pub current_hook_started_at: Option<DateTime<Utc>>,
550 pub completed_display_until: Option<DateTime<Utc>>,
552 pub error_message: Option<String>,
554 pub environment_vars: HashMap<String, String>,
556 pub previous_env: Option<HashMap<String, String>>,
558}
559
560impl HookExecutionState {
561 #[must_use]
563 pub fn new(
564 directory_path: PathBuf,
565 instance_hash: String,
566 config_hash: String,
567 hooks: Vec<Hook>,
568 ) -> Self {
569 let total_hooks = hooks.len();
570 Self {
571 instance_hash,
572 directory_path,
573 config_hash,
574 status: ExecutionStatus::Running,
575 total_hooks,
576 completed_hooks: 0,
577 current_hook_index: None,
578 hooks,
579 hook_results: HashMap::new(),
580 started_at: Utc::now(),
581 finished_at: None,
582 current_hook_started_at: None,
583 completed_display_until: None,
584 error_message: None,
585 environment_vars: HashMap::new(),
586 previous_env: None,
587 }
588 }
589
590 pub fn mark_hook_running(&mut self, hook_index: usize) {
592 self.current_hook_index = Some(hook_index);
593 self.current_hook_started_at = Some(Utc::now());
594 info!(
595 "Started executing hook {} of {}",
596 hook_index + 1,
597 self.total_hooks
598 );
599 }
600
601 #[expect(
603 clippy::needless_pass_by_value,
604 reason = "Takes ownership for API clarity, cloning is intentional"
605 )]
606 pub fn record_hook_result(&mut self, hook_index: usize, result: HookResult) {
607 self.hook_results.insert(hook_index, result.clone());
608 self.completed_hooks += 1;
609 self.current_hook_index = None;
610 self.current_hook_started_at = None;
611
612 if result.success {
613 info!(
614 "Hook {} of {} completed successfully",
615 hook_index + 1,
616 self.total_hooks
617 );
618 } else {
619 error!(
620 "Hook {} of {} failed: {:?}",
621 hook_index + 1,
622 self.total_hooks,
623 result.error
624 );
625 self.status = ExecutionStatus::Failed;
626 self.error_message.clone_from(&result.error);
627 self.finished_at = Some(Utc::now());
628 self.completed_display_until = Some(Utc::now() + chrono::Duration::seconds(2));
630 return;
631 }
632
633 if self.completed_hooks == self.total_hooks {
635 self.status = ExecutionStatus::Completed;
636 let now = Utc::now();
637 self.finished_at = Some(now);
638 self.completed_display_until = Some(now + chrono::Duration::seconds(2));
640 info!("All {} hooks completed successfully", self.total_hooks);
641 }
642 }
643
644 pub fn mark_cancelled(&mut self, reason: Option<String>) {
646 self.status = ExecutionStatus::Cancelled;
647 self.finished_at = Some(Utc::now());
648 self.error_message = reason;
649 self.current_hook_index = None;
650 }
651
652 #[must_use]
654 pub fn is_complete(&self) -> bool {
655 matches!(
656 self.status,
657 ExecutionStatus::Completed | ExecutionStatus::Failed | ExecutionStatus::Cancelled
658 )
659 }
660
661 #[must_use]
663 pub fn progress_display(&self) -> String {
664 match &self.status {
665 ExecutionStatus::Running => {
666 if let Some(current) = self.current_hook_index {
667 format!(
668 "Executing hook {} of {} ({})",
669 current + 1,
670 self.total_hooks,
671 self.status
672 )
673 } else {
674 format!(
675 "{} of {} hooks completed",
676 self.completed_hooks, self.total_hooks
677 )
678 }
679 }
680 ExecutionStatus::Completed => "All hooks completed successfully".to_string(),
681 ExecutionStatus::Failed => {
682 if let Some(error) = &self.error_message {
683 format!("Hook execution failed: {}", error)
684 } else {
685 "Hook execution failed".to_string()
686 }
687 }
688 ExecutionStatus::Cancelled => {
689 if let Some(reason) = &self.error_message {
690 format!("Hook execution cancelled: {}", reason)
691 } else {
692 "Hook execution cancelled".to_string()
693 }
694 }
695 }
696 }
697
698 pub fn duration(&self) -> chrono::Duration {
700 let end = self.finished_at.unwrap_or_else(Utc::now);
701 end - self.started_at
702 }
703
704 #[must_use]
706 pub fn current_hook_duration(&self) -> Option<chrono::Duration> {
707 self.current_hook_started_at
708 .map(|started| Utc::now() - started)
709 }
710
711 #[must_use]
713 pub fn current_hook(&self) -> Option<&Hook> {
714 self.current_hook_index.and_then(|idx| self.hooks.get(idx))
715 }
716
717 #[must_use]
719 pub fn format_duration(duration: chrono::Duration) -> String {
720 let total_secs = duration.num_seconds();
721
722 if total_secs < 60 {
723 let millis = duration.num_milliseconds();
725 #[expect(
727 clippy::cast_precision_loss,
728 reason = "Display formatting, precision loss is acceptable"
729 )]
730 let secs = millis as f64 / 1000.0;
731 format!("{secs:.1}s")
732 } else if total_secs < 3600 {
733 let mins = total_secs / 60;
735 let secs = total_secs % 60;
736 if secs == 0 {
737 format!("{}m", mins)
738 } else {
739 format!("{}m {}s", mins, secs)
740 }
741 } else {
742 let hours = total_secs / 3600;
744 let mins = (total_secs % 3600) / 60;
745 if mins == 0 {
746 format!("{}h", hours)
747 } else {
748 format!("{}h {}m", hours, mins)
749 }
750 }
751 }
752
753 #[must_use]
755 pub fn current_hook_display(&self) -> Option<String> {
756 let hook = if let Some(hook) = self.current_hook() {
758 Some(hook)
759 } else if self.status == ExecutionStatus::Running && self.completed_hooks < self.total_hooks
760 {
761 self.hooks.get(self.completed_hooks)
763 } else {
764 None
765 };
766
767 hook.map(|h| {
768 let cmd_name = h.command.split('/').next_back().unwrap_or(&h.command);
770
771 format!("`{}`", cmd_name)
773 })
774 }
775
776 #[must_use]
778 pub fn should_display_completed(&self) -> bool {
779 if let Some(display_until) = self.completed_display_until {
780 Utc::now() < display_until
781 } else {
782 false
783 }
784 }
785}
786
787#[must_use]
789pub fn compute_instance_hash(path: &Path, config_hash: &str) -> String {
790 use sha2::{Digest, Sha256};
791 let mut hasher = Sha256::new();
792 hasher.update(path.to_string_lossy().as_bytes());
793 hasher.update(b":");
794 hasher.update(config_hash.as_bytes());
795 hasher.update(b":");
798 hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
799 format!("{:x}", hasher.finalize())[..16].to_string()
800}
801
802pub fn compute_execution_hash(hooks: &[Hook], base_dir: &Path) -> String {
807 use sha2::{Digest, Sha256};
808 let mut hasher = Sha256::new();
809
810 if let Ok(hooks_json) = serde_json::to_string(hooks) {
812 hasher.update(hooks_json.as_bytes());
813 }
814
815 for hook in hooks {
817 let hook_dir = hook
819 .dir
820 .as_ref()
821 .map_or_else(|| base_dir.to_path_buf(), PathBuf::from);
822
823 for input in &hook.inputs {
824 let input_path = hook_dir.join(input);
825 if let Ok(content) = std::fs::read(&input_path) {
826 hasher.update(b"file:");
827 hasher.update(input.as_bytes());
828 hasher.update(b":");
829 hasher.update(&content);
830 }
831 }
832 }
833
834 hasher.update(b":version:");
836 hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
837
838 format!("{:x}", hasher.finalize())[..16].to_string()
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use crate::types::{Hook, HookResult};
845 use std::collections::HashMap;
846 use std::os::unix::process::ExitStatusExt;
847 use std::sync::Arc;
848 use std::time::Duration;
849 use tempfile::TempDir;
850
851 #[test]
852 fn test_compute_instance_hash() {
853 let path = Path::new("/test/path");
854 let config_hash = "test_config";
855 let hash = compute_instance_hash(path, config_hash);
856 assert_eq!(hash.len(), 16);
857
858 let hash2 = compute_instance_hash(path, config_hash);
860 assert_eq!(hash, hash2);
861
862 let different_path = Path::new("/other/path");
864 let different_hash = compute_instance_hash(different_path, config_hash);
865 assert_ne!(hash, different_hash);
866
867 let different_config_hash = compute_instance_hash(path, "different_config");
869 assert_ne!(hash, different_config_hash);
870 }
871
872 #[tokio::test]
873 async fn test_state_manager_operations() {
874 let temp_dir = TempDir::new().unwrap();
875 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
876
877 let directory_path = PathBuf::from("/test/dir");
878 let config_hash = "test_config_hash".to_string();
879 let instance_hash = compute_instance_hash(&directory_path, &config_hash);
880
881 let hooks = vec![
882 Hook {
883 order: 100,
884 propagate: false,
885 command: "echo".to_string(),
886 args: vec!["test1".to_string()],
887 dir: None,
888 inputs: vec![],
889 source: None,
890 },
891 Hook {
892 order: 100,
893 propagate: false,
894 command: "echo".to_string(),
895 args: vec!["test2".to_string()],
896 dir: None,
897 inputs: vec![],
898 source: None,
899 },
900 ];
901
902 let mut state =
903 HookExecutionState::new(directory_path, instance_hash.clone(), config_hash, hooks);
904
905 state_manager.save_state(&state).await.unwrap();
907
908 let loaded_state = state_manager
910 .load_state(&instance_hash)
911 .await
912 .unwrap()
913 .unwrap();
914 assert_eq!(loaded_state.instance_hash, state.instance_hash);
915 assert_eq!(loaded_state.total_hooks, 2);
916 assert_eq!(loaded_state.status, ExecutionStatus::Running);
917
918 let hook = Hook {
920 order: 100,
921 propagate: false,
922 command: "echo".to_string(),
923 args: vec!["test".to_string()],
924 dir: None,
925 inputs: Vec::new(),
926 source: Some(false),
927 };
928
929 let result = HookResult::success(
930 hook,
931 std::process::ExitStatus::from_raw(0),
932 "test\n".to_string(),
933 String::new(),
934 100,
935 );
936
937 state.record_hook_result(0, result);
938 state_manager.save_state(&state).await.unwrap();
939
940 let updated_state = state_manager
942 .load_state(&instance_hash)
943 .await
944 .unwrap()
945 .unwrap();
946 assert_eq!(updated_state.completed_hooks, 1);
947 assert_eq!(updated_state.hook_results.len(), 1);
948
949 state_manager.remove_state(&instance_hash).await.unwrap();
951 let removed_state = state_manager.load_state(&instance_hash).await.unwrap();
952 assert!(removed_state.is_none());
953 }
954
955 #[test]
956 fn test_hook_execution_state() {
957 let directory_path = PathBuf::from("/test/dir");
958 let instance_hash = "test_hash".to_string();
959 let config_hash = "config_hash".to_string();
960 let hooks = vec![
961 Hook {
962 order: 100,
963 propagate: false,
964 command: "echo".to_string(),
965 args: vec!["test1".to_string()],
966 dir: None,
967 inputs: vec![],
968 source: None,
969 },
970 Hook {
971 order: 100,
972 propagate: false,
973 command: "echo".to_string(),
974 args: vec!["test2".to_string()],
975 dir: None,
976 inputs: vec![],
977 source: None,
978 },
979 Hook {
980 order: 100,
981 propagate: false,
982 command: "echo".to_string(),
983 args: vec!["test3".to_string()],
984 dir: None,
985 inputs: vec![],
986 source: None,
987 },
988 ];
989 let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
990
991 assert_eq!(state.status, ExecutionStatus::Running);
993 assert_eq!(state.total_hooks, 3);
994 assert_eq!(state.completed_hooks, 0);
995 assert!(!state.is_complete());
996
997 state.mark_hook_running(0);
999 assert_eq!(state.current_hook_index, Some(0));
1000
1001 let hook = Hook {
1003 order: 100,
1004 propagate: false,
1005 command: "echo".to_string(),
1006 args: vec![],
1007 dir: None,
1008 inputs: Vec::new(),
1009 source: Some(false),
1010 };
1011
1012 let result = HookResult::success(
1013 hook.clone(),
1014 std::process::ExitStatus::from_raw(0),
1015 String::new(),
1016 String::new(),
1017 100,
1018 );
1019
1020 state.record_hook_result(0, result);
1021 assert_eq!(state.completed_hooks, 1);
1022 assert_eq!(state.current_hook_index, None);
1023 assert_eq!(state.status, ExecutionStatus::Running);
1024 assert!(!state.is_complete());
1025
1026 let failed_result = HookResult::failure(
1028 hook,
1029 Some(std::process::ExitStatus::from_raw(256)),
1030 String::new(),
1031 "error".to_string(),
1032 50,
1033 "Command failed".to_string(),
1034 );
1035
1036 state.record_hook_result(1, failed_result);
1037 assert_eq!(state.completed_hooks, 2);
1038 assert_eq!(state.status, ExecutionStatus::Failed);
1039 assert!(state.is_complete());
1040 assert!(state.error_message.is_some());
1041
1042 let mut cancelled_state = HookExecutionState::new(
1044 PathBuf::from("/test"),
1045 "hash".to_string(),
1046 "config".to_string(),
1047 vec![Hook {
1048 order: 100,
1049 propagate: false,
1050 command: "echo".to_string(),
1051 args: vec![],
1052 dir: None,
1053 inputs: vec![],
1054 source: None,
1055 }],
1056 );
1057 cancelled_state.mark_cancelled(Some("User cancelled".to_string()));
1058 assert_eq!(cancelled_state.status, ExecutionStatus::Cancelled);
1059 assert!(cancelled_state.is_complete());
1060 }
1061
1062 #[test]
1063 fn test_progress_display() {
1064 let directory_path = PathBuf::from("/test/dir");
1065 let instance_hash = "test_hash".to_string();
1066 let config_hash = "config_hash".to_string();
1067 let hooks = vec![
1068 Hook {
1069 order: 100,
1070 propagate: false,
1071 command: "echo".to_string(),
1072 args: vec!["test1".to_string()],
1073 dir: None,
1074 inputs: vec![],
1075 source: None,
1076 },
1077 Hook {
1078 order: 100,
1079 propagate: false,
1080 command: "echo".to_string(),
1081 args: vec!["test2".to_string()],
1082 dir: None,
1083 inputs: vec![],
1084 source: None,
1085 },
1086 ];
1087 let mut state = HookExecutionState::new(directory_path, instance_hash, config_hash, hooks);
1088
1089 let display = state.progress_display();
1091 assert!(display.contains("0 of 2"));
1092
1093 state.mark_hook_running(0);
1095 let display = state.progress_display();
1096 assert!(display.contains("Executing hook 1 of 2"));
1097
1098 state.status = ExecutionStatus::Completed;
1100 state.current_hook_index = None;
1101 let display = state.progress_display();
1102 assert_eq!(display, "All hooks completed successfully");
1103
1104 state.status = ExecutionStatus::Failed;
1106 state.error_message = Some("Test error".to_string());
1107 let display = state.progress_display();
1108 assert!(display.contains("Hook execution failed: Test error"));
1109 }
1110
1111 #[tokio::test]
1112 async fn test_state_directory_cleanup() {
1113 let temp_dir = TempDir::new().unwrap();
1114 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1115
1116 let completed_state = HookExecutionState {
1118 instance_hash: "completed_hash".to_string(),
1119 directory_path: PathBuf::from("/completed"),
1120 config_hash: "config1".to_string(),
1121 status: ExecutionStatus::Completed,
1122 total_hooks: 1,
1123 completed_hooks: 1,
1124 current_hook_index: None,
1125 hooks: vec![],
1126 hook_results: HashMap::new(),
1127 environment_vars: HashMap::new(),
1128 started_at: Utc::now() - chrono::Duration::hours(1),
1129 finished_at: Some(Utc::now() - chrono::Duration::minutes(30)),
1130 current_hook_started_at: None,
1131 completed_display_until: None,
1132 error_message: None,
1133 previous_env: None,
1134 };
1135
1136 let running_state = HookExecutionState {
1137 instance_hash: "running_hash".to_string(),
1138 directory_path: PathBuf::from("/running"),
1139 config_hash: "config2".to_string(),
1140 status: ExecutionStatus::Running,
1141 total_hooks: 2,
1142 completed_hooks: 1,
1143 current_hook_index: Some(1),
1144 hooks: vec![],
1145 hook_results: HashMap::new(),
1146 environment_vars: HashMap::new(),
1147 started_at: Utc::now() - chrono::Duration::minutes(5),
1148 finished_at: None,
1149 current_hook_started_at: None,
1150 completed_display_until: None,
1151 error_message: None,
1152 previous_env: None,
1153 };
1154
1155 let failed_state = HookExecutionState {
1156 instance_hash: "failed_hash".to_string(),
1157 directory_path: PathBuf::from("/failed"),
1158 config_hash: "config3".to_string(),
1159 status: ExecutionStatus::Failed,
1160 total_hooks: 1,
1161 completed_hooks: 0,
1162 current_hook_index: None,
1163 hooks: vec![],
1164 hook_results: HashMap::new(),
1165 environment_vars: HashMap::new(),
1166 started_at: Utc::now() - chrono::Duration::hours(2),
1167 finished_at: Some(Utc::now() - chrono::Duration::hours(1)),
1168 current_hook_started_at: None,
1169 completed_display_until: None,
1170 error_message: Some("Test failure".to_string()),
1171 previous_env: None,
1172 };
1173
1174 state_manager.save_state(&completed_state).await.unwrap();
1176 state_manager.save_state(&running_state).await.unwrap();
1177 state_manager.save_state(&failed_state).await.unwrap();
1178
1179 let states = state_manager.list_active_states().await.unwrap();
1181 assert_eq!(states.len(), 3);
1182
1183 let cleaned = state_manager.cleanup_state_directory().await.unwrap();
1185 assert_eq!(cleaned, 2); let remaining_states = state_manager.list_active_states().await.unwrap();
1189 assert_eq!(remaining_states.len(), 1);
1190 assert_eq!(remaining_states[0].instance_hash, "running_hash");
1191 }
1192
1193 #[tokio::test]
1194 async fn test_cleanup_orphaned_states() {
1195 let temp_dir = TempDir::new().unwrap();
1196 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1197
1198 let orphaned_state = HookExecutionState {
1200 instance_hash: "orphaned_hash".to_string(),
1201 directory_path: PathBuf::from("/orphaned"),
1202 config_hash: "config".to_string(),
1203 status: ExecutionStatus::Running,
1204 total_hooks: 1,
1205 completed_hooks: 0,
1206 current_hook_index: Some(0),
1207 hooks: vec![],
1208 hook_results: HashMap::new(),
1209 environment_vars: HashMap::new(),
1210 started_at: Utc::now() - chrono::Duration::hours(3),
1211 finished_at: None,
1212 current_hook_started_at: None,
1213 completed_display_until: None,
1214 error_message: None,
1215 previous_env: None,
1216 };
1217
1218 let recent_state = HookExecutionState {
1220 instance_hash: "recent_hash".to_string(),
1221 directory_path: PathBuf::from("/recent"),
1222 config_hash: "config".to_string(),
1223 status: ExecutionStatus::Running,
1224 total_hooks: 1,
1225 completed_hooks: 0,
1226 current_hook_index: Some(0),
1227 hooks: vec![],
1228 hook_results: HashMap::new(),
1229 environment_vars: HashMap::new(),
1230 started_at: Utc::now() - chrono::Duration::minutes(5),
1231 finished_at: None,
1232 current_hook_started_at: None,
1233 completed_display_until: None,
1234 error_message: None,
1235 previous_env: None,
1236 };
1237
1238 state_manager.save_state(&orphaned_state).await.unwrap();
1240 state_manager.save_state(&recent_state).await.unwrap();
1241
1242 let cleaned = state_manager
1244 .cleanup_orphaned_states(chrono::Duration::hours(1))
1245 .await
1246 .unwrap();
1247 assert_eq!(cleaned, 1); let remaining_states = state_manager.list_active_states().await.unwrap();
1251 assert_eq!(remaining_states.len(), 1);
1252 assert_eq!(remaining_states[0].instance_hash, "recent_hash");
1253 }
1254
1255 #[tokio::test]
1256 async fn test_corrupted_state_file_handling() {
1257 let temp_dir = TempDir::new().unwrap();
1258 let state_dir = temp_dir.path().join("state");
1259 let state_manager = StateManager::new(state_dir.clone());
1260
1261 state_manager.ensure_state_dir().await.unwrap();
1263
1264 let corrupted_file = state_dir.join("corrupted.json");
1266 tokio::fs::write(&corrupted_file, "{invalid json}")
1267 .await
1268 .unwrap();
1269
1270 let states = state_manager.list_active_states().await.unwrap();
1272 assert_eq!(states.len(), 0); let cleaned = state_manager.cleanup_state_directory().await.unwrap();
1276 assert_eq!(cleaned, 1);
1277
1278 assert!(!corrupted_file.exists());
1280 }
1281
1282 #[tokio::test]
1283 async fn test_concurrent_state_modifications() {
1284 use tokio::task;
1285
1286 let temp_dir = TempDir::new().unwrap();
1287 let state_manager = Arc::new(StateManager::new(temp_dir.path().to_path_buf()));
1288
1289 let initial_state = HookExecutionState {
1291 instance_hash: "concurrent_hash".to_string(),
1292 directory_path: PathBuf::from("/concurrent"),
1293 config_hash: "config".to_string(),
1294 status: ExecutionStatus::Running,
1295 total_hooks: 10,
1296 completed_hooks: 0,
1297 current_hook_index: Some(0),
1298 hooks: vec![],
1299 hook_results: HashMap::new(),
1300 environment_vars: HashMap::new(),
1301 started_at: Utc::now(),
1302 finished_at: None,
1303 current_hook_started_at: None,
1304 completed_display_until: None,
1305 error_message: None,
1306 previous_env: None,
1307 };
1308
1309 state_manager.save_state(&initial_state).await.unwrap();
1310
1311 let mut handles = vec![];
1313
1314 for i in 0..5 {
1315 let sm = state_manager.clone();
1316 let path = initial_state.directory_path.clone();
1317
1318 let handle = task::spawn(async move {
1319 let instance_hash = compute_instance_hash(&path, "concurrent_config");
1321
1322 tokio::time::sleep(Duration::from_millis(10)).await;
1324
1325 if let Ok(Some(mut state)) = sm.load_state(&instance_hash).await {
1327 state.completed_hooks += 1;
1328 state.current_hook_index = Some(i + 1);
1329
1330 let _ = sm.save_state(&state).await;
1332 }
1333 });
1334
1335 handles.push(handle);
1336 }
1337
1338 for handle in handles {
1340 handle.await.unwrap();
1341 }
1342
1343 let final_state = state_manager
1346 .load_state(&initial_state.instance_hash)
1347 .await
1348 .unwrap();
1349
1350 if let Some(state) = final_state {
1352 assert_eq!(state.instance_hash, "concurrent_hash");
1353 }
1355 }
1356
1357 #[tokio::test]
1358 async fn test_state_with_unicode_and_special_chars() {
1359 let temp_dir = TempDir::new().unwrap();
1360 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1361
1362 let mut unicode_state = HookExecutionState {
1364 instance_hash: "unicode_hash".to_string(),
1365 directory_path: PathBuf::from("/測試/目錄/🚀"),
1366 config_hash: "config_ñ_é_ü".to_string(),
1367 status: ExecutionStatus::Failed,
1368 total_hooks: 1,
1369 completed_hooks: 1,
1370 current_hook_index: None,
1371 hooks: vec![],
1372 hook_results: HashMap::new(),
1373 environment_vars: HashMap::new(),
1374 started_at: Utc::now(),
1375 finished_at: Some(Utc::now()),
1376 current_hook_started_at: None,
1377 completed_display_until: None,
1378 error_message: Some("Error: 錯誤信息 with émojis 🔥💥".to_string()),
1379 previous_env: None,
1380 };
1381
1382 let unicode_hook = Hook {
1384 order: 100,
1385 propagate: false,
1386 command: "echo".to_string(),
1387 args: vec![],
1388 dir: None,
1389 inputs: vec![],
1390 source: None,
1391 };
1392 let unicode_result = HookResult {
1393 hook: unicode_hook,
1394 success: false,
1395 exit_status: Some(1),
1396 stdout: "輸出: Hello 世界! 🌍".to_string(),
1397 stderr: "錯誤: ñoño error ⚠️".to_string(),
1398 duration_ms: 100,
1399 error: Some("失敗了 😢".to_string()),
1400 };
1401 unicode_state.hook_results.insert(0, unicode_result);
1402
1403 state_manager.save_state(&unicode_state).await.unwrap();
1405
1406 let loaded = state_manager
1407 .load_state(&unicode_state.instance_hash)
1408 .await
1409 .unwrap()
1410 .unwrap();
1411
1412 assert_eq!(loaded.config_hash, "config_ñ_é_ü");
1414 assert_eq!(
1415 loaded.error_message,
1416 Some("Error: 錯誤信息 with émojis 🔥💥".to_string())
1417 );
1418
1419 let hook_result = loaded.hook_results.get(&0).unwrap();
1420 assert_eq!(hook_result.stdout, "輸出: Hello 世界! 🌍");
1421 assert_eq!(hook_result.stderr, "錯誤: ñoño error ⚠️");
1422 assert_eq!(hook_result.error, Some("失敗了 😢".to_string()));
1423 }
1424
1425 #[tokio::test]
1426 async fn test_state_directory_with_many_states() {
1427 let temp_dir = TempDir::new().unwrap();
1428 let state_manager = StateManager::new(temp_dir.path().to_path_buf());
1429
1430 for i in 0..50 {
1432 let state = HookExecutionState {
1433 instance_hash: format!("hash_{}", i),
1434 directory_path: PathBuf::from(format!("/dir/{}", i)),
1435 config_hash: format!("config_{}", i),
1436 status: if i % 3 == 0 {
1437 ExecutionStatus::Completed
1438 } else if i % 3 == 1 {
1439 ExecutionStatus::Running
1440 } else {
1441 ExecutionStatus::Failed
1442 },
1443 total_hooks: 1,
1444 completed_hooks: usize::from(i % 3 == 0),
1445 current_hook_index: if i % 3 == 1 { Some(0) } else { None },
1446 hooks: vec![],
1447 hook_results: HashMap::new(),
1448 environment_vars: HashMap::new(),
1449 started_at: Utc::now() - chrono::Duration::hours(i64::from(i)),
1450 finished_at: if i % 3 == 1 {
1451 None
1452 } else {
1453 Some(Utc::now() - chrono::Duration::hours(i64::from(i) - 1))
1454 },
1455 current_hook_started_at: None,
1456 completed_display_until: None,
1457 error_message: if i % 3 == 2 {
1458 Some(format!("Error {}", i))
1459 } else {
1460 None
1461 },
1462 previous_env: None,
1463 };
1464 state_manager.save_state(&state).await.unwrap();
1465 }
1466
1467 let listed = state_manager.list_active_states().await.unwrap();
1469 assert_eq!(listed.len(), 50);
1470
1471 let cleaned = state_manager
1473 .cleanup_orphaned_states(chrono::Duration::hours(24))
1474 .await
1475 .unwrap();
1476
1477 assert!(cleaned > 0);
1479 }
1480}