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 = repo_path.join(".git").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>
342 where
343 F: Fn() -> std::result::Result<R, git2::Error>,
344 {
345 match operation() {
346 Ok(result) => Ok(result),
347 Err(e) if is_lock_error(&e) => {
348 tracing::debug!("Git operation failed with lock error: {}", e);
350
351 match clean_stale_index_lock(repo_path) {
352 Ok(true) => {
353 tracing::info!("Retrying operation after removing stale lock");
355 operation().map_err(CascadeError::Git)
356 }
357 Ok(false) => {
358 Err(CascadeError::Git(e))
360 }
361 Err(cleanup_err) => {
362 tracing::warn!("Failed to clean up stale lock: {}", cleanup_err);
364 Err(CascadeError::Git(e))
365 }
366 }
367 }
368 Err(e) => Err(CascadeError::Git(e)),
369 }
370 }
371}
372
373pub mod file_locking {
375 use super::*;
376 use std::fs::{File, OpenOptions};
377 use std::path::Path;
378 use std::time::{Duration, Instant};
379
380 pub struct FileLock {
382 _file: File,
383 lock_path: std::path::PathBuf,
384 }
385
386 impl FileLock {
387 #[cfg(windows)]
389 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(windows)]
391 const RETRY_INTERVAL: Duration = Duration::from_millis(100); #[cfg(not(windows))]
394 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(not(windows))]
396 const RETRY_INTERVAL: Duration = Duration::from_millis(50); pub fn acquire_with_timeout(file_path: &Path, timeout: Duration) -> Result<Self> {
400 let lock_path = file_path.with_extension("lock");
401 let start_time = Instant::now();
402
403 loop {
404 match Self::try_acquire(&lock_path) {
405 Ok(lock) => return Ok(lock),
406 Err(e) => {
407 if start_time.elapsed() >= timeout {
408 return Err(CascadeError::config(format!(
409 "Timeout waiting for lock on {file_path:?} after {}ms (platform: {}): {e}",
410 timeout.as_millis(),
411 if cfg!(windows) { "windows" } else { "unix" }
412 )));
413 }
414 std::thread::sleep(Self::RETRY_INTERVAL);
415 }
416 }
417 }
418 }
419
420 pub fn try_acquire(lock_path: &Path) -> Result<Self> {
422 let file = Self::create_lock_file(lock_path)?;
424
425 Ok(Self {
426 _file: file,
427 lock_path: lock_path.to_path_buf(),
428 })
429 }
430
431 #[cfg(windows)]
433 fn create_lock_file(lock_path: &Path) -> Result<File> {
434 OpenOptions::new()
436 .write(true)
437 .create_new(true)
438 .open(lock_path)
439 .map_err(|e| {
440 match e.kind() {
442 std::io::ErrorKind::AlreadyExists => {
443 CascadeError::config(format!(
444 "Lock file {lock_path:?} already exists - another process may be accessing the file"
445 ))
446 }
447 std::io::ErrorKind::PermissionDenied => {
448 CascadeError::config(format!(
449 "Permission denied creating lock file {lock_path:?} - check file permissions"
450 ))
451 }
452 _ => CascadeError::config(format!(
453 "Failed to acquire lock {lock_path:?} on Windows: {e}"
454 ))
455 }
456 })
457 }
458
459 #[cfg(not(windows))]
460 fn create_lock_file(lock_path: &Path) -> Result<File> {
461 OpenOptions::new()
463 .write(true)
464 .create_new(true)
465 .open(lock_path)
466 .map_err(|e| {
467 CascadeError::config(format!("Failed to acquire lock {lock_path:?}: {e}"))
468 })
469 }
470
471 pub fn acquire(file_path: &Path) -> Result<Self> {
473 Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
474 }
475
476 pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
478 let timeout = if cfg!(windows) {
479 Duration::from_secs(15) } else {
481 Duration::from_secs(8) };
483 Self::acquire_with_timeout(file_path, timeout)
484 }
485 }
486
487 impl Drop for FileLock {
488 fn drop(&mut self) {
489 let _ = std::fs::remove_file(&self.lock_path);
491 }
492 }
493
494 pub fn with_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
496 where
497 F: FnOnce() -> Result<R>,
498 {
499 let _lock = FileLock::acquire(file_path)?;
500 operation()
501 }
502
503 pub async fn with_file_lock_async<F, Fut, R>(file_path: &Path, operation: F) -> Result<R>
505 where
506 F: FnOnce() -> Fut,
507 Fut: std::future::Future<Output = Result<R>>,
508 {
509 let file_path = file_path.to_path_buf();
510 let _lock = tokio::task::spawn_blocking(move || FileLock::acquire(&file_path))
511 .await
512 .map_err(|e| CascadeError::config(format!("Lock task failed: {e}")))?;
513
514 operation().await
515 }
516}