Skip to main content

cuenv_hooks/
state.rs

1//! State management for hook execution tracking
2
3use crate::types::{ExecutionStatus, Hook, HookResult};
4use crate::{Error, Result};
5use chrono::{DateTime, Utc};
6#[allow(unused_imports)] // Used by load_state_sync for file locking
7use 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/// Manages persistent state for hook execution sessions
19#[derive(Debug, Clone)]
20pub struct StateManager {
21    state_dir: PathBuf,
22}
23
24impl StateManager {
25    /// Create a new state manager with the specified state directory
26    #[must_use]
27    pub fn new(state_dir: PathBuf) -> Self {
28        Self { state_dir }
29    }
30
31    /// Get the default state directory.
32    ///
33    /// Uses platform-appropriate paths:
34    /// - Linux: `~/.local/state/cuenv/hooks`
35    /// - macOS: `~/Library/Application Support/cuenv/hooks`
36    /// - Windows: `%APPDATA%\cuenv\hooks`
37    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    /// Create a state manager using the default state directory
45    pub fn with_default_dir() -> Result<Self> {
46        Ok(Self::new(Self::default_state_dir()?))
47    }
48
49    /// Get the state directory path
50    #[must_use]
51    pub fn get_state_dir(&self) -> &Path {
52        &self.state_dir
53    }
54
55    /// Ensure the state directory exists
56    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    /// Generate a state file path for a given directory hash
71    fn state_file_path(&self, instance_hash: &str) -> PathBuf {
72        self.state_dir.join(format!("{}.json", instance_hash))
73    }
74
75    /// Get the state file path for a given directory hash (public for PID files)
76    #[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    /// Save execution state to disk with atomic write and locking
82    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        // Write to a temporary file first, then rename atomically
90        let temp_path = state_file.with_extension("tmp");
91
92        // Open temp file with exclusive lock for writing
93        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        // Acquire exclusive lock (only one writer allowed)
106        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        // Unlock happens automatically when file is dropped
128        drop(file);
129
130        // Atomically rename temp file to final location
131        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    /// Load execution state from disk with shared locking
147    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        // Open file with shared lock for reading
155        let mut file = match OpenOptions::new().read(true).open(&state_file).await {
156            Ok(f) => f,
157            Err(e) => {
158                // File might have been deleted between exists check and open
159                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        // Acquire shared lock (multiple readers allowed)
171        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        // Unlock happens automatically when file is dropped
188        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    /// Remove state file for a directory
201    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    /// List all active execution states
220    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    // ========================================================================
250    // Directory Marker System - Fast status lookups without config hash
251    // ========================================================================
252
253    /// Compute a key for directory-only lookups (used for fast status checks).
254    /// This hashes just the canonicalized directory path, without config hash.
255    #[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    /// Get the path for a directory marker file
265    fn directory_marker_path(&self, directory_key: &str) -> PathBuf {
266        self.state_dir.join(format!("{}.marker", directory_key))
267    }
268
269    /// Create a marker file linking directory to instance hash.
270    /// Called when hooks start to enable fast status lookups.
271    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    /// Remove marker file for a directory.
297    /// Called when hooks complete/fail and display timeout expires.
298    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(); // Ignore errors
304            debug!("Removed directory marker for {}", directory_path.display());
305        }
306        Ok(())
307    }
308
309    /// Fast synchronous check: does a marker exist for this directory?
310    /// This is the hot path for Starship - just a single stat() syscall.
311    #[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    /// Read the instance hash from a marker file (if it exists).
318    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    // ========================================================================
328    // Synchronous Methods (for fast path - no tokio runtime required)
329    // ========================================================================
330
331    /// Read the instance hash from a marker file synchronously.
332    /// This is the sync equivalent of `get_marker_instance_hash` for the fast path.
333    #[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    /// Load execution state from disk synchronously with shared locking.
343    /// This is the sync equivalent of `load_state` for the fast path.
344    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        // Open file with shared lock for reading
352        let mut file = match std::fs::File::open(&state_file) {
353            Ok(f) => f,
354            Err(e) => {
355                // File might have been deleted between exists check and open
356                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        // Acquire shared lock (multiple readers allowed)
368        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        // Unlock happens automatically when file is dropped
383        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    // ========================================================================
392    // Cleanup Methods
393    // ========================================================================
394
395    /// Clean up the entire state directory
396    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            // Clean up JSON state files
418            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                            // Remove completed states and their markers
423                            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                                // Also remove the directory marker
429                                self.remove_directory_marker(&state.directory_path)
430                                    .await
431                                    .ok();
432                            }
433                        }
434                        Ok(Some(_)) => {
435                            // Keep running states
436                            debug!("Keeping active state file: {}", path.display());
437                        }
438                        Ok(None) => {}
439                        Err(e) => {
440                            // If we can't parse it, it might be corrupted - remove it
441                            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            // Clean up orphaned marker files (markers without corresponding state)
457            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                // Check if corresponding state exists
462                match self.load_state(instance_hash).await {
463                    Ok(None) => {
464                        // State doesn't exist, remove orphaned marker
465                        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                        // State is complete and expired, remove marker
472                        if fs::remove_file(&path).await.is_ok() {
473                            cleaned_count += 1;
474                            debug!("Cleaned up expired marker: {}", path.display());
475                        }
476                    }
477                    _ => {} // Keep marker
478                }
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    /// Clean up orphaned state files (states without corresponding processes)
493    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            // Remove states that are stuck in running but are too old
499            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                // Also remove the directory marker
507                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/// Represents the state of hook execution for a specific directory
523#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
524pub struct HookExecutionState {
525    /// Hash combining directory path and config (instance identifier)
526    pub instance_hash: String,
527    /// Path to the directory being processed
528    pub directory_path: PathBuf,
529    /// Hash of the configuration that was approved
530    pub config_hash: String,
531    /// Current status of execution
532    pub status: ExecutionStatus,
533    /// Total number of hooks to execute
534    pub total_hooks: usize,
535    /// Number of hooks completed so far
536    pub completed_hooks: usize,
537    /// Index of currently executing hook (if any)
538    pub current_hook_index: Option<usize>,
539    /// The list of hooks being executed (for display purposes)
540    #[serde(default)]
541    pub hooks: Vec<Hook>,
542    /// Results of completed hooks
543    pub hook_results: HashMap<usize, HookResult>,
544    /// Timestamp when execution started
545    pub started_at: DateTime<Utc>,
546    /// Timestamp when execution finished (if completed)
547    pub finished_at: Option<DateTime<Utc>>,
548    /// Timestamp when the current hook started (if running)
549    pub current_hook_started_at: Option<DateTime<Utc>>,
550    /// Timestamp until which completed state should be displayed
551    pub completed_display_until: Option<DateTime<Utc>>,
552    /// Error message if execution failed
553    pub error_message: Option<String>,
554    /// Environment variables captured from source hooks
555    pub environment_vars: HashMap<String, String>,
556    /// Previous environment variables (for diff/unset support)
557    pub previous_env: Option<HashMap<String, String>>,
558}
559
560impl HookExecutionState {
561    /// Create a new execution state
562    #[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    /// Mark a hook as currently executing
591    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    /// Record the result of a hook execution
602    #[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            // Keep failed state visible for 2 seconds (enough for at least one starship poll)
629            self.completed_display_until = Some(Utc::now() + chrono::Duration::seconds(2));
630            return;
631        }
632
633        // Check if all hooks are complete
634        if self.completed_hooks == self.total_hooks {
635            self.status = ExecutionStatus::Completed;
636            let now = Utc::now();
637            self.finished_at = Some(now);
638            // Keep completed state visible for 2 seconds (enough for at least one starship poll)
639            self.completed_display_until = Some(now + chrono::Duration::seconds(2));
640            info!("All {} hooks completed successfully", self.total_hooks);
641        }
642    }
643
644    /// Mark execution as cancelled
645    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    /// Check if execution is complete (success, failure, or cancelled)
653    #[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    /// Get a human-readable progress display
662    #[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    /// Get execution duration
699    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    /// Get current hook duration (if a hook is currently running)
705    #[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    /// Get the currently executing hook
712    #[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    /// Format duration in human-readable format (e.g., "2.3s", "1m 15s", "2h 5m")
718    #[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            // Less than 1 minute: show as decimal seconds
724            let millis = duration.num_milliseconds();
725            // Precision loss is acceptable for display purposes
726            #[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            // Less than 1 hour: show minutes and seconds
734            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            // 1 hour or more: show hours and minutes
743            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    /// Get a short description of the current or next hook for display
754    #[must_use]
755    pub fn current_hook_display(&self) -> Option<String> {
756        // If there's a current hook index, use that
757        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            // If we're running but no current hook index yet, show the next hook to execute
762            self.hooks.get(self.completed_hooks)
763        } else {
764            None
765        };
766
767        hook.map(|h| {
768            // Extract just the command name (first part before any path separators)
769            let cmd_name = h.command.split('/').next_back().unwrap_or(&h.command);
770
771            // Format: just the command name (no args, to keep it concise)
772            format!("`{}`", cmd_name)
773        })
774    }
775
776    /// Check if the completed state should still be displayed
777    #[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/// Compute a hash for a unique execution instance (directory + config)
788#[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    // Include cuenv version in hash to invalidate cache on upgrades
796    // This is important when internal logic (like environment capturing) changes
797    hasher.update(b":");
798    hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
799    format!("{:x}", hasher.finalize())[..16].to_string()
800}
801
802/// Compute a hash for hook execution that includes input file contents.
803///
804/// This is separate from the approval hash - approval only cares about the hook
805/// definition, but execution cache needs to invalidate when input files change.
806pub fn compute_execution_hash(hooks: &[Hook], base_dir: &Path) -> String {
807    use sha2::{Digest, Sha256};
808    let mut hasher = Sha256::new();
809
810    // Hash the hook definitions
811    if let Ok(hooks_json) = serde_json::to_string(hooks) {
812        hasher.update(hooks_json.as_bytes());
813    }
814
815    // Hash the contents of input files from each hook
816    for hook in hooks {
817        // Determine the working directory for this hook
818        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    // Include cuenv version
835    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        // Same path and config should produce same hash
859        let hash2 = compute_instance_hash(path, config_hash);
860        assert_eq!(hash, hash2);
861
862        // Different path should produce different hash
863        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        // Same path but different config should produce different hash
868        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        // Save initial state
906        state_manager.save_state(&state).await.unwrap();
907
908        // Load state back
909        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        // Update state with hook result
919        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        // Load updated state
941        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        // Remove state
950        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        // Initial state
992        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        // Mark hook as running
998        state.mark_hook_running(0);
999        assert_eq!(state.current_hook_index, Some(0));
1000
1001        // Record successful hook result
1002        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        // Record failed hook result
1027        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        // Test cancellation
1043        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        // Running state
1090        let display = state.progress_display();
1091        assert!(display.contains("0 of 2"));
1092
1093        // Running with current hook
1094        state.mark_hook_running(0);
1095        let display = state.progress_display();
1096        assert!(display.contains("Executing hook 1 of 2"));
1097
1098        // Completed state
1099        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        // Failed state
1105        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        // Create multiple states with different statuses
1117        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        // Save all states
1175        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        // Verify all states exist
1180        let states = state_manager.list_active_states().await.unwrap();
1181        assert_eq!(states.len(), 3);
1182
1183        // Clean up completed states
1184        let cleaned = state_manager.cleanup_state_directory().await.unwrap();
1185        assert_eq!(cleaned, 2); // Should clean up completed and failed states
1186
1187        // Verify only running state remains
1188        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        // Create an old running state (orphaned)
1199        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        // Create a recent running state (not orphaned)
1219        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        // Save both states
1239        state_manager.save_state(&orphaned_state).await.unwrap();
1240        state_manager.save_state(&recent_state).await.unwrap();
1241
1242        // Clean up orphaned states older than 1 hour
1243        let cleaned = state_manager
1244            .cleanup_orphaned_states(chrono::Duration::hours(1))
1245            .await
1246            .unwrap();
1247        assert_eq!(cleaned, 1); // Should clean up only the orphaned state
1248
1249        // Verify only recent state remains
1250        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        // Ensure state directory exists
1262        state_manager.ensure_state_dir().await.unwrap();
1263
1264        // Write corrupted JSON to a state file
1265        let corrupted_file = state_dir.join("corrupted.json");
1266        tokio::fs::write(&corrupted_file, "{invalid json}")
1267            .await
1268            .unwrap();
1269
1270        // List active states should handle the corrupted file gracefully
1271        let states = state_manager.list_active_states().await.unwrap();
1272        assert_eq!(states.len(), 0); // Corrupted file should be skipped
1273
1274        // Cleanup should remove the corrupted file
1275        let cleaned = state_manager.cleanup_state_directory().await.unwrap();
1276        assert_eq!(cleaned, 1);
1277
1278        // Verify the corrupted file is gone
1279        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        // Create initial state
1290        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        // Spawn multiple tasks that concurrently modify the state
1312        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                // Load state - it might have been modified by another task
1320                let instance_hash = compute_instance_hash(&path, "concurrent_config");
1321
1322                // Simulate some work
1323                tokio::time::sleep(Duration::from_millis(10)).await;
1324
1325                // Load state, modify, and save (handle potential concurrent modifications)
1326                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                    // Save state - ignore errors from concurrent saves
1331                    let _ = sm.save_state(&state).await;
1332                }
1333            });
1334
1335            handles.push(handle);
1336        }
1337
1338        // Wait for all tasks to complete
1339        for handle in handles {
1340            handle.await.unwrap();
1341        }
1342
1343        // Verify final state - due to concurrent writes, the exact values may vary
1344        // but the state should be loadable and valid
1345        let final_state = state_manager
1346            .load_state(&initial_state.instance_hash)
1347            .await
1348            .unwrap();
1349
1350        // The state might exist or not depending on timing of concurrent operations
1351        if let Some(state) = final_state {
1352            assert_eq!(state.instance_hash, "concurrent_hash");
1353            // Completed hooks will be 0 if all concurrent writes failed, or > 0 if some succeeded
1354        }
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        // Create state with unicode and special characters
1363        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        // Add hook result with unicode output
1383        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        // Save and load the state
1404        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        // Verify all unicode content is preserved
1413        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        // Create many states to test scalability
1431        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        // List all states
1468        let listed = state_manager.list_active_states().await.unwrap();
1469        assert_eq!(listed.len(), 50);
1470
1471        // Clean up old completed states (older than 24 hours)
1472        let cleaned = state_manager
1473            .cleanup_orphaned_states(chrono::Duration::hours(24))
1474            .await
1475            .unwrap();
1476
1477        // Should clean up states older than 24 hours
1478        assert!(cleaned > 0);
1479    }
1480}