cuenv_core/hooks/
state.rs

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