1use crate::errors::{CascadeError, Result};
2use serde::Serialize;
3use std::fs;
4use std::path::Path;
5
6pub mod platform;
8
9pub mod spinner;
11
12pub mod atomic_file {
14 use super::*;
15
16 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 pub fn write_string(path: &Path, content: &str) -> Result<()> {
28 with_concurrent_file_lock(path, || write_string_unlocked(path, content))
29 }
30
31 fn with_concurrent_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
33 where
34 F: FnOnce() -> Result<R>,
35 {
36 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 fn write_string_unlocked(path: &Path, content: &str) -> Result<()> {
51 let temp_path = path.with_extension("tmp");
53
54 {
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 file.sync_all().map_err(|e| {
69 CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
70 })?;
71 }
72
73 atomic_rename(&temp_path, path)
75 }
76
77 #[cfg(windows)]
79 fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
80 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 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 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 fs::rename(temp_path, final_path)
109 .map_err(|e| CascadeError::config(format!("Failed to finalize file write: {e}")))?;
110 Ok(())
111 }
112
113 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 {
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 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
142pub mod path_validation {
144 use super::*;
145 use std::path::PathBuf;
146
147 pub fn validate_config_path(path: &Path, base_dir: &Path) -> Result<PathBuf> {
150 if !path.exists() {
152 let canonical_base = base_dir.canonicalize().map_err(|e| {
154 CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
155 })?;
156
157 let mut check_path = path.to_path_buf();
159
160 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 Ok(path.to_path_buf())
179 } else {
180 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 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
210pub mod async_ops {
212 use super::*;
213 use tokio::task;
214
215 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 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
238pub mod git_lock {
240 use super::*;
241 use std::path::Path;
242 use std::process::Command;
243
244 fn has_running_git_processes() -> Option<bool> {
248 #[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 None
284 }
285 }
286
287 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 match has_running_git_processes() {
298 Some(true) => {
299 tracing::debug!(
301 "Git index lock exists and Git processes are running - not removing"
302 );
303 Ok(false)
304 }
305 Some(false) => {
306 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 tracing::debug!(
325 "Cannot detect Git processes (pgrep/tasklist unavailable) - not removing lock for safety"
326 );
327 Ok(false)
328 }
329 }
330 }
331
332 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 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 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 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 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 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
454pub 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 pub struct FileLock {
463 _file: File,
464 lock_path: std::path::PathBuf,
465 }
466
467 impl FileLock {
468 #[cfg(windows)]
470 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(windows)]
472 const RETRY_INTERVAL: Duration = Duration::from_millis(100); #[cfg(not(windows))]
475 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(not(windows))]
477 const RETRY_INTERVAL: Duration = Duration::from_millis(50); 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 pub fn try_acquire(lock_path: &Path) -> Result<Self> {
503 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 #[cfg(windows)]
514 fn create_lock_file(lock_path: &Path) -> Result<File> {
515 OpenOptions::new()
517 .write(true)
518 .create_new(true)
519 .open(lock_path)
520 .map_err(|e| {
521 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 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 pub fn acquire(file_path: &Path) -> Result<Self> {
554 Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
555 }
556
557 pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
559 let timeout = if cfg!(windows) {
560 Duration::from_secs(15) } else {
562 Duration::from_secs(8) };
564 Self::acquire_with_timeout(file_path, timeout)
565 }
566 }
567
568 impl Drop for FileLock {
569 fn drop(&mut self) {
570 let _ = std::fs::remove_file(&self.lock_path);
572 }
573 }
574
575 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 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}