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