Skip to main content

cascade_cli/
utils.rs

1use crate::errors::{CascadeError, Result};
2use serde::Serialize;
3use std::fs;
4use std::path::Path;
5
6/// Platform-specific utilities for cross-platform compatibility
7pub mod platform;
8
9/// Terminal spinner utilities for progress indication
10pub mod spinner;
11
12/// Atomic file operations to prevent corruption during writes
13pub mod atomic_file {
14    use super::*;
15
16    /// Write JSON data to a file atomically using a temporary file + rename strategy with file locking
17    pub fn write_json<T: Serialize>(path: &Path, data: &T) -> Result<()> {
18        with_concurrent_file_lock(path, || {
19            let content = serde_json::to_string_pretty(data)
20                .map_err(|e| CascadeError::config(format!("Failed to serialize data: {e}")))?;
21
22            write_string_unlocked(path, &content)
23        })
24    }
25
26    /// Write string content to a file atomically using a temporary file + rename strategy with file locking
27    pub fn write_string(path: &Path, content: &str) -> Result<()> {
28        with_concurrent_file_lock(path, || write_string_unlocked(path, content))
29    }
30
31    /// Execute an operation with file locking optimized for concurrent access
32    fn with_concurrent_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
33    where
34        F: FnOnce() -> Result<R>,
35    {
36        // Use aggressive timeout in environments where concurrent access is expected
37        let use_aggressive =
38            std::env::var("CI").is_ok() || std::env::var("CONCURRENT_ACCESS_EXPECTED").is_ok();
39
40        let _lock = if use_aggressive {
41            crate::utils::file_locking::FileLock::acquire_aggressive(file_path)?
42        } else {
43            crate::utils::file_locking::FileLock::acquire(file_path)?
44        };
45
46        operation()
47    }
48
49    /// Internal unlocked version for use within lock contexts
50    fn write_string_unlocked(path: &Path, content: &str) -> Result<()> {
51        // Create temporary file in the same directory as the target
52        let temp_path = path.with_extension("tmp");
53
54        // Write to temporary file first and ensure it's synced to disk
55        {
56            use std::fs::File;
57            use std::io::Write;
58
59            let mut file = File::create(&temp_path).map_err(|e| {
60                CascadeError::config(format!("Failed to create temporary file: {e}"))
61            })?;
62
63            file.write_all(content.as_bytes()).map_err(|e| {
64                CascadeError::config(format!("Failed to write to temporary file: {e}"))
65            })?;
66
67            // Force data to be written to disk before rename
68            file.sync_all().map_err(|e| {
69                CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
70            })?;
71        }
72
73        // Platform-specific atomic rename
74        atomic_rename(&temp_path, path)
75    }
76
77    /// Platform-specific atomic rename operation
78    #[cfg(windows)]
79    fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
80        // Windows: More robust rename with retry on failure
81        const MAX_RETRIES: u32 = 3;
82        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
83
84        for attempt in 1..=MAX_RETRIES {
85            match fs::rename(temp_path, final_path) {
86                Ok(()) => return Ok(()),
87                Err(e) => {
88                    if attempt == MAX_RETRIES {
89                        // Clean up temp file on final failure
90                        let _ = fs::remove_file(temp_path);
91                        return Err(CascadeError::config(format!(
92                            "Failed to finalize file write after {MAX_RETRIES} attempts on Windows: {e}"
93                        )));
94                    }
95
96                    // Retry after a short delay for transient Windows file locking issues
97                    std::thread::sleep(RETRY_DELAY);
98                }
99            }
100        }
101
102        unreachable!("Loop should have returned or failed by now")
103    }
104
105    #[cfg(not(windows))]
106    fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
107        // Unix: Standard atomic rename works reliably
108        fs::rename(temp_path, final_path)
109            .map_err(|e| CascadeError::config(format!("Failed to finalize file write: {e}")))?;
110        Ok(())
111    }
112
113    /// Write binary data to a file atomically with file locking
114    pub fn write_bytes(path: &Path, data: &[u8]) -> Result<()> {
115        with_concurrent_file_lock(path, || {
116            let temp_path = path.with_extension("tmp");
117
118            // Write and sync binary data to disk
119            {
120                use std::fs::File;
121                use std::io::Write;
122
123                let mut file = File::create(&temp_path).map_err(|e| {
124                    CascadeError::config(format!("Failed to create temporary file: {e}"))
125                })?;
126
127                file.write_all(data).map_err(|e| {
128                    CascadeError::config(format!("Failed to write to temporary file: {e}"))
129                })?;
130
131                // Force data to be written to disk before rename
132                file.sync_all().map_err(|e| {
133                    CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
134                })?;
135            }
136
137            atomic_rename(&temp_path, path)
138        })
139    }
140}
141
142/// Path validation utilities to prevent path traversal attacks
143pub mod path_validation {
144    use super::*;
145    use std::path::PathBuf;
146
147    /// Validate and canonicalize a path to ensure it's within allowed boundaries
148    /// Handles both existing and non-existing paths for security validation
149    pub fn validate_config_path(path: &Path, base_dir: &Path) -> Result<PathBuf> {
150        // For non-existing paths, we need to validate without canonicalize
151        if !path.exists() {
152            // Validate the base directory exists and can be canonicalized
153            let canonical_base = base_dir.canonicalize().map_err(|e| {
154                CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
155            })?;
156
157            // For non-existing paths, check if the parent directory is within bounds
158            let mut check_path = path.to_path_buf();
159
160            // Find the first existing parent
161            while !check_path.exists() && check_path.parent().is_some() {
162                check_path = check_path.parent().unwrap().to_path_buf();
163            }
164
165            if check_path.exists() {
166                let canonical_check = check_path.canonicalize().map_err(|e| {
167                    CascadeError::config(format!("Cannot validate path security: {e}"))
168                })?;
169
170                if !canonical_check.starts_with(&canonical_base) {
171                    return Err(CascadeError::config(format!(
172                        "Path '{path:?}' would be outside allowed directory '{canonical_base:?}'"
173                    )));
174                }
175            }
176
177            // Return the original path for non-existing files
178            Ok(path.to_path_buf())
179        } else {
180            // For existing paths, use full canonicalization
181            let canonical_path = path
182                .canonicalize()
183                .map_err(|e| CascadeError::config(format!("Invalid path '{path:?}': {e}")))?;
184
185            let canonical_base = base_dir.canonicalize().map_err(|e| {
186                CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
187            })?;
188
189            if !canonical_path.starts_with(&canonical_base) {
190                return Err(CascadeError::config(format!(
191                    "Path '{canonical_path:?}' is outside allowed directory '{canonical_base:?}'"
192                )));
193            }
194
195            Ok(canonical_path)
196        }
197    }
198
199    /// Sanitize a filename to prevent issues with special characters
200    pub fn sanitize_filename(name: &str) -> String {
201        name.chars()
202            .map(|c| match c {
203                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
204                _ => '_',
205            })
206            .collect()
207    }
208}
209
210/// Async utilities to prevent blocking operations
211pub mod async_ops {
212    use super::*;
213    use tokio::task;
214
215    /// Run a potentially blocking Git operation in a background thread
216    pub async fn run_git_operation<F, R>(operation: F) -> Result<R>
217    where
218        F: FnOnce() -> Result<R> + Send + 'static,
219        R: Send + 'static,
220    {
221        task::spawn_blocking(operation)
222            .await
223            .map_err(|e| CascadeError::config(format!("Background task failed: {e}")))?
224    }
225
226    /// Run a potentially blocking file operation in a background thread
227    pub async fn run_file_operation<F, R>(operation: F) -> Result<R>
228    where
229        F: FnOnce() -> Result<R> + Send + 'static,
230        R: Send + 'static,
231    {
232        task::spawn_blocking(operation)
233            .await
234            .map_err(|e| CascadeError::config(format!("File operation failed: {e}")))?
235    }
236}
237
238/// Git lock utilities for detecting and cleaning stale locks
239pub mod git_lock {
240    use super::*;
241    use std::path::Path;
242    use std::process::Command;
243
244    /// Check if any Git processes are currently running
245    /// Returns Some(true) if Git processes detected, Some(false) if none detected,
246    /// None if detection failed (unable to check)
247    fn has_running_git_processes() -> Option<bool> {
248        // Try to detect running git processes
249        #[cfg(unix)]
250        {
251            match Command::new("pgrep").arg("-i").arg("git").output() {
252                Ok(output) => {
253                    return Some(!output.stdout.is_empty());
254                }
255                Err(e) => {
256                    tracing::debug!("Failed to run pgrep to detect Git processes: {}", e);
257                    return None;
258                }
259            }
260        }
261
262        #[cfg(windows)]
263        {
264            match Command::new("tasklist")
265                .arg("/FI")
266                .arg("IMAGENAME eq git.exe")
267                .output()
268            {
269                Ok(output) => {
270                    let stdout = String::from_utf8_lossy(&output.stdout);
271                    return Some(stdout.contains("git.exe"));
272                }
273                Err(e) => {
274                    tracing::debug!("Failed to run tasklist to detect Git processes: {}", e);
275                    return None;
276                }
277            }
278        }
279
280        #[allow(unreachable_code)]
281        {
282            // Fallback for non-unix, non-windows platforms
283            None
284        }
285    }
286
287    /// Detect and remove stale Git index lock if safe to do so
288    /// Returns true if a stale lock was removed
289    pub fn clean_stale_index_lock(repo_path: &Path) -> Result<bool> {
290        let lock_path = crate::git::resolve_git_dir(repo_path)?.join("index.lock");
291
292        if !lock_path.exists() {
293            return Ok(false);
294        }
295
296        // Check if any Git processes are running
297        match has_running_git_processes() {
298            Some(true) => {
299                // Git processes are definitely running, don't remove the lock
300                tracing::debug!(
301                    "Git index lock exists and Git processes are running - not removing"
302                );
303                Ok(false)
304            }
305            Some(false) => {
306                // No Git processes detected - safe to remove stale lock
307                tracing::debug!(
308                    "Detected stale Git index lock (no Git processes running), removing: {:?}",
309                    lock_path
310                );
311
312                fs::remove_file(&lock_path).map_err(|e| {
313                    CascadeError::config(format!(
314                        "Failed to remove stale index lock at {:?}: {}",
315                        lock_path, e
316                    ))
317                })?;
318
319                tracing::info!("Removed stale Git index lock at {:?}", lock_path);
320                Ok(true)
321            }
322            None => {
323                // Can't detect processes - fail safe by not removing the lock
324                tracing::debug!(
325                    "Cannot detect Git processes (pgrep/tasklist unavailable) - not removing lock for safety"
326                );
327                Ok(false)
328            }
329        }
330    }
331
332    /// Check if an error is a Git lock error
333    pub fn is_lock_error(error: &git2::Error) -> bool {
334        error.code() == git2::ErrorCode::Locked
335            || error.message().contains("index is locked")
336            || error.message().contains("index.lock")
337    }
338
339    /// Execute a git2 operation with exponential-backoff retry on index lock contention.
340    /// Retries up to 4 times with delays of 50ms, 100ms, 200ms before falling back
341    /// to stale lock cleanup as a last resort.
342    pub fn with_lock_retry<F, R>(repo_path: &Path, operation: F) -> Result<R>
343    where
344        F: Fn() -> std::result::Result<R, git2::Error>,
345    {
346        const MAX_ATTEMPTS: u32 = 4;
347        const BASE_DELAY_MS: u64 = 50;
348
349        let mut last_error = None;
350
351        for attempt in 0..MAX_ATTEMPTS {
352            match operation() {
353                Ok(result) => return Ok(result),
354                Err(e) if is_lock_error(&e) => {
355                    last_error = Some(e);
356                    if attempt < MAX_ATTEMPTS - 1 {
357                        let delay_ms = BASE_DELAY_MS * 2_u64.pow(attempt);
358                        tracing::debug!(
359                            "Index lock contention (attempt {}/{}), retrying in {}ms",
360                            attempt + 1,
361                            MAX_ATTEMPTS,
362                            delay_ms,
363                        );
364                        std::thread::sleep(std::time::Duration::from_millis(delay_ms));
365                    }
366                }
367                Err(e) => return Err(CascadeError::Git(e)),
368            }
369        }
370
371        // Last resort: try stale lock cleanup
372        if let Ok(true) = clean_stale_index_lock(repo_path) {
373            tracing::info!("Removed stale lock after retries exhausted, final attempt");
374            return operation().map_err(CascadeError::Git);
375        }
376
377        Err(CascadeError::Git(last_error.unwrap()))
378    }
379
380    /// Wait for the git index lock to be released before starting a destructive operation.
381    /// Polls the filesystem for the lock file to disappear. IDE locks are transient
382    /// (milliseconds for a status check), so a short timeout catches the common case.
383    /// Falls back to stale lock cleanup if the lock persists past the timeout.
384    pub fn wait_for_index_lock(repo_path: &Path, timeout: std::time::Duration) -> Result<()> {
385        let lock_path = crate::git::resolve_git_dir(repo_path)?.join("index.lock");
386
387        if !lock_path.exists() {
388            return Ok(());
389        }
390
391        tracing::debug!("Index lock detected, waiting for it to clear...");
392
393        let start = std::time::Instant::now();
394        let poll_interval = std::time::Duration::from_millis(50);
395
396        while start.elapsed() < timeout {
397            std::thread::sleep(poll_interval);
398            if !lock_path.exists() {
399                tracing::debug!("Index lock cleared after {:?}", start.elapsed());
400                return Ok(());
401            }
402        }
403
404        // Lock persisted past timeout — try stale lock cleanup as last resort
405        if let Ok(true) = clean_stale_index_lock(repo_path) {
406            tracing::info!("Removed stale index lock after timeout");
407            return Ok(());
408        }
409
410        Err(CascadeError::branch(format!(
411            "Git index is locked ({}).\n\n\
412             Another program is using Git in this repository.\n\n\
413             Common causes:\n\
414             \u{2022} An IDE with Git integration has this repo open\n\
415             \u{2022} Another terminal is running a git command\n\
416             \u{2022} A previous git operation crashed and left a stale lock\n\n\
417             To fix:\n\
418             1. Close any IDEs or Git-aware tools using this repo, then retry\n\
419             2. If no Git processes are running: rm -f {}\n\
420             3. Check for running git processes: pgrep -l git",
421            lock_path.display(),
422            lock_path.display(),
423        )))
424    }
425
426    /// Retry an operation that returns CascadeError on index lock contention.
427    /// Uses exponential backoff with the same timing as with_lock_retry.
428    pub fn retry_on_lock<F, R>(max_attempts: u32, operation: F) -> Result<R>
429    where
430        F: Fn() -> Result<R>,
431    {
432        let mut last_error = None;
433        for attempt in 0..max_attempts {
434            match operation() {
435                Ok(result) => return Ok(result),
436                Err(e) if e.is_lock_error() && attempt < max_attempts - 1 => {
437                    let delay = 50 * 2u64.pow(attempt);
438                    tracing::debug!(
439                        "Operation hit index lock (attempt {}/{}), retry in {}ms",
440                        attempt + 1,
441                        max_attempts,
442                        delay,
443                    );
444                    std::thread::sleep(std::time::Duration::from_millis(delay));
445                    last_error = Some(e);
446                }
447                Err(e) => return Err(e),
448            }
449        }
450        Err(last_error.unwrap())
451    }
452}
453
454/// File locking utilities for concurrent access protection
455pub mod file_locking {
456    use super::*;
457    use std::fs::{File, OpenOptions};
458    use std::path::Path;
459    use std::time::{Duration, Instant};
460
461    /// A file lock that prevents concurrent access to critical files
462    pub struct FileLock {
463        _file: File,
464        lock_path: std::path::PathBuf,
465    }
466
467    impl FileLock {
468        /// Platform-specific configuration for file locking
469        #[cfg(windows)]
470        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); // Longer timeout for Windows
471        #[cfg(windows)]
472        const RETRY_INTERVAL: Duration = Duration::from_millis(100); // Less aggressive polling
473
474        #[cfg(not(windows))]
475        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); // Shorter timeout for Unix
476        #[cfg(not(windows))]
477        const RETRY_INTERVAL: Duration = Duration::from_millis(50); // More frequent polling
478
479        /// Attempt to acquire a lock on a file with timeout
480        pub fn acquire_with_timeout(file_path: &Path, timeout: Duration) -> Result<Self> {
481            let lock_path = file_path.with_extension("lock");
482            let start_time = Instant::now();
483
484            loop {
485                match Self::try_acquire(&lock_path) {
486                    Ok(lock) => return Ok(lock),
487                    Err(e) => {
488                        if start_time.elapsed() >= timeout {
489                            return Err(CascadeError::config(format!(
490                                "Timeout waiting for lock on {file_path:?} after {}ms (platform: {}): {e}",
491                                timeout.as_millis(),
492                                if cfg!(windows) { "windows" } else { "unix" }
493                            )));
494                        }
495                        std::thread::sleep(Self::RETRY_INTERVAL);
496                    }
497                }
498            }
499        }
500
501        /// Try to acquire a lock immediately (non-blocking)
502        pub fn try_acquire(lock_path: &Path) -> Result<Self> {
503            // Platform-specific lock file creation
504            let file = Self::create_lock_file(lock_path)?;
505
506            Ok(Self {
507                _file: file,
508                lock_path: lock_path.to_path_buf(),
509            })
510        }
511
512        /// Platform-specific lock file creation
513        #[cfg(windows)]
514        fn create_lock_file(lock_path: &Path) -> Result<File> {
515            // Windows: More robust file creation with explicit sharing mode
516            OpenOptions::new()
517                .write(true)
518                .create_new(true)
519                .open(lock_path)
520                .map_err(|e| {
521                    // Provide more specific error information for Windows
522                    match e.kind() {
523                        std::io::ErrorKind::AlreadyExists => {
524                            CascadeError::config(format!(
525                                "Lock file {lock_path:?} already exists - another process may be accessing the file"
526                            ))
527                        }
528                        std::io::ErrorKind::PermissionDenied => {
529                            CascadeError::config(format!(
530                                "Permission denied creating lock file {lock_path:?} - check file permissions"
531                            ))
532                        }
533                        _ => CascadeError::config(format!(
534                            "Failed to acquire lock {lock_path:?} on Windows: {e}"
535                        ))
536                    }
537                })
538        }
539
540        #[cfg(not(windows))]
541        fn create_lock_file(lock_path: &Path) -> Result<File> {
542            // Unix: Standard approach works well
543            OpenOptions::new()
544                .write(true)
545                .create_new(true)
546                .open(lock_path)
547                .map_err(|e| {
548                    CascadeError::config(format!("Failed to acquire lock {lock_path:?}: {e}"))
549                })
550        }
551
552        /// Acquire a lock with platform-appropriate default timeout
553        pub fn acquire(file_path: &Path) -> Result<Self> {
554            Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
555        }
556
557        /// Acquire a lock with aggressive timeout for high-concurrency scenarios
558        pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
559            let timeout = if cfg!(windows) {
560                Duration::from_secs(15) // Even longer for Windows under load
561            } else {
562                Duration::from_secs(8) // Slightly longer for Unix under load
563            };
564            Self::acquire_with_timeout(file_path, timeout)
565        }
566    }
567
568    impl Drop for FileLock {
569        fn drop(&mut self) {
570            // Clean up lock file on drop
571            let _ = std::fs::remove_file(&self.lock_path);
572        }
573    }
574
575    /// Execute an operation with file locking protection
576    pub fn with_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
577    where
578        F: FnOnce() -> Result<R>,
579    {
580        let _lock = FileLock::acquire(file_path)?;
581        operation()
582    }
583
584    /// Execute an async operation with file locking protection
585    pub async fn with_file_lock_async<F, Fut, R>(file_path: &Path, operation: F) -> Result<R>
586    where
587        F: FnOnce() -> Fut,
588        Fut: std::future::Future<Output = Result<R>>,
589    {
590        let file_path = file_path.to_path_buf();
591        let _lock = tokio::task::spawn_blocking(move || FileLock::acquire(&file_path))
592            .await
593            .map_err(|e| CascadeError::config(format!("Lock task failed: {e}")))?;
594
595        operation().await
596    }
597}