use crate::effects::AnalysisEffect;
use crate::env::RealEnv;
use crate::errors::AnalysisError;
use indicatif::ProgressBar;
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use stillwater::effect::prelude::*;
use stillwater::{bracket, Effect, EffectExt};
#[derive(Debug, Clone)]
pub struct LockFile {
path: PathBuf,
}
impl LockFile {
pub fn path(&self) -> &Path {
&self.path
}
}
pub fn with_lock_file<T, F, Eff>(lock_path: PathBuf, effect_fn: F) -> AnalysisEffect<T>
where
T: Send + 'static,
F: FnOnce() -> Eff + Send + 'static,
Eff: Effect<Output = T, Error = AnalysisError, Env = RealEnv> + Send + 'static,
{
let acquire_path = lock_path.clone();
bracket(
from_fn(move |_env: &RealEnv| {
let _file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&acquire_path)
.map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
AnalysisError::io_with_path(
"Lock file exists. Another process may be running.",
&acquire_path,
)
} else {
AnalysisError::io_with_path(
format!("Failed to create lock: {}", e),
&acquire_path,
)
}
})?;
Ok(LockFile { path: acquire_path })
}),
|lock: LockFile| async move {
let _ = std::fs::remove_file(&lock.path);
Ok::<(), AnalysisError>(())
},
move |_lock: &LockFile| effect_fn(),
)
.boxed()
}
#[derive(Debug, Clone)]
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
pub fn path(&self) -> &Path {
&self.path
}
}
pub fn with_temp_dir<T, F, Eff>(prefix: &str, effect_fn: F) -> AnalysisEffect<T>
where
T: Send + 'static,
F: FnOnce(PathBuf) -> Eff + Send + 'static,
Eff: Effect<Output = T, Error = AnalysisError, Env = RealEnv> + Send + 'static,
{
let prefix = prefix.to_string();
bracket(
from_fn(move |_env: &RealEnv| {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let dir_name = format!("debtmap-{}-{}-{}", prefix, pid, timestamp);
let temp_path = std::env::temp_dir().join(dir_name);
std::fs::create_dir_all(&temp_path).map_err(|e| {
AnalysisError::io_with_path(format!("Failed to create temp dir: {}", e), &temp_path)
})?;
Ok(TempDir { path: temp_path })
}),
|temp: TempDir| async move {
let _ = std::fs::remove_dir_all(&temp.path);
Ok::<(), AnalysisError>(())
},
move |temp: &TempDir| effect_fn(temp.path.clone()),
)
.boxed()
}
#[derive(Debug, Clone)]
pub struct ProgressHandle {
bar: ProgressBar,
}
impl ProgressHandle {
pub fn bar(&self) -> &ProgressBar {
&self.bar
}
pub fn inc(&self, n: u64) {
self.bar.inc(n);
}
pub fn set_position(&self, pos: u64) {
self.bar.set_position(pos);
}
pub fn set_message(&self, msg: impl Into<std::borrow::Cow<'static, str>>) {
self.bar.set_message(msg);
}
}
pub fn with_progress<T, F, Eff>(message: &str, total: u64, effect_fn: F) -> AnalysisEffect<T>
where
T: Send + 'static,
F: FnOnce(ProgressHandle) -> Eff + Send + 'static,
Eff: Effect<Output = T, Error = AnalysisError, Env = RealEnv> + Send + 'static,
{
let message = message.to_string();
bracket(
from_fn(move |_env: &RealEnv| {
let bar = ProgressBar::new(total);
bar.set_style(
indicatif::ProgressStyle::default_bar()
.template("{msg} [{bar:40}] {pos}/{len}")
.unwrap_or_else(|_| indicatif::ProgressStyle::default_bar()),
);
bar.set_message(message);
Ok(ProgressHandle { bar })
}),
|handle: ProgressHandle| async move {
handle.bar.finish_and_clear();
Ok::<(), AnalysisError>(())
},
move |handle: &ProgressHandle| effect_fn(handle.clone()),
)
.boxed()
}
pub fn with_spinner<T, F, Eff>(message: &str, effect_fn: F) -> AnalysisEffect<T>
where
T: Send + 'static,
F: FnOnce() -> Eff + Send + 'static,
Eff: Effect<Output = T, Error = AnalysisError, Env = RealEnv> + Send + 'static,
{
let message = message.to_string();
bracket(
from_fn(move |_env: &RealEnv| {
let bar = ProgressBar::new_spinner();
bar.set_style(
indicatif::ProgressStyle::default_spinner()
.template("{spinner} {msg}")
.unwrap_or_else(|_| indicatif::ProgressStyle::default_spinner()),
);
bar.set_message(message);
bar.enable_steady_tick(std::time::Duration::from_millis(100));
Ok(ProgressHandle { bar })
}),
|handle: ProgressHandle| async move {
handle.bar.finish_and_clear();
Ok::<(), AnalysisError>(())
},
move |_handle: &ProgressHandle| effect_fn(),
)
.boxed()
}
#[derive(Debug, Clone)]
pub struct FileHandle {
file: std::sync::Arc<File>,
path: PathBuf,
}
impl FileHandle {
pub fn file(&self) -> &File {
&self.file
}
pub fn path(&self) -> &Path {
&self.path
}
}
pub fn with_file_read<T, F, Eff>(path: PathBuf, effect_fn: F) -> AnalysisEffect<T>
where
T: Send + 'static,
F: FnOnce(FileHandle) -> Eff + Send + 'static,
Eff: Effect<Output = T, Error = AnalysisError, Env = RealEnv> + Send + 'static,
{
let open_path = path.clone();
bracket(
from_fn(move |_env: &RealEnv| {
let file = File::open(&open_path).map_err(|e| {
AnalysisError::io_with_path(format!("Failed to open file: {}", e), &open_path)
})?;
Ok(FileHandle {
file: std::sync::Arc::new(file),
path: open_path,
})
}),
|_handle: FileHandle| async move { Ok::<(), AnalysisError>(()) },
move |handle: &FileHandle| effect_fn(handle.clone()),
)
.boxed()
}
pub fn bracket_io<R, T, AcquireFn, ReleaseFn, UseFn, UseEff>(
acquire_fn: AcquireFn,
release_fn: ReleaseFn,
use_fn: UseFn,
) -> AnalysisEffect<T>
where
R: Clone + Send + 'static,
T: Send + 'static,
AcquireFn: FnOnce() -> Result<R, AnalysisError> + Send + 'static,
ReleaseFn: FnOnce(R) + Send + 'static,
UseFn: FnOnce(R) -> UseEff + Send + 'static,
UseEff: Effect<Output = T, Error = AnalysisError, Env = RealEnv> + Send + 'static,
{
bracket(
from_fn(move |_env: &RealEnv| acquire_fn()),
|resource: R| async move {
release_fn(resource);
Ok::<(), AnalysisError>(())
},
move |resource: &R| use_fn(resource.clone()),
)
.boxed()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DebtmapConfig;
use crate::effects::{effect_fail, effect_pure};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tempfile::TempDir as TempFileDir;
fn run_effect<T: Send + 'static>(effect: AnalysisEffect<T>) -> Result<T, AnalysisError> {
let env = RealEnv::new(DebtmapConfig::default());
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(effect.run(&env))
}
#[test]
fn test_with_lock_file_creates_and_removes_lock() {
let temp = TempFileDir::new().unwrap();
let lock_path = temp.path().join("test.lock");
assert!(!lock_path.exists());
let effect = with_lock_file(lock_path.clone(), || effect_pure(42));
let result = run_effect(effect);
assert_eq!(result.unwrap(), 42);
assert!(!lock_path.exists());
}
#[test]
fn test_with_lock_file_cleanup_on_error() {
let temp = TempFileDir::new().unwrap();
let lock_path = temp.path().join("test.lock");
let effect: AnalysisEffect<i32> = with_lock_file(lock_path.clone(), || {
effect_fail(AnalysisError::other("intentional failure"))
});
let result = run_effect(effect);
assert!(result.is_err());
assert!(!lock_path.exists(), "Lock file should be removed on error");
}
#[test]
fn test_with_lock_file_fails_if_exists() {
let temp = TempFileDir::new().unwrap();
let lock_path = temp.path().join("test.lock");
File::create(&lock_path).unwrap();
let effect = with_lock_file(lock_path.clone(), || effect_pure(42));
let result = run_effect(effect);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Lock file exists"));
}
#[test]
fn test_with_temp_dir_creates_and_removes() {
let captured_path = Arc::new(std::sync::Mutex::new(None::<PathBuf>));
let captured_clone = captured_path.clone();
let effect = with_temp_dir("test", move |path| {
*captured_clone.lock().unwrap() = Some(path.clone());
assert!(path.exists());
effect_pure(42)
});
let result = run_effect(effect);
assert_eq!(result.unwrap(), 42);
let path = captured_path.lock().unwrap().clone().unwrap();
assert!(!path.exists(), "Temp dir should be removed");
}
#[test]
fn test_with_temp_dir_cleanup_on_error() {
let captured_path = Arc::new(std::sync::Mutex::new(None::<PathBuf>));
let captured_clone = captured_path.clone();
let effect: AnalysisEffect<i32> = with_temp_dir("test", move |path| {
*captured_clone.lock().unwrap() = Some(path.clone());
effect_fail(AnalysisError::other("intentional failure"))
});
let result = run_effect(effect);
assert!(result.is_err());
let path = captured_path.lock().unwrap().clone().unwrap();
assert!(!path.exists(), "Temp dir should be removed on error");
}
#[test]
fn test_with_temp_dir_unique_names() {
let path1 = Arc::new(std::sync::Mutex::new(None::<PathBuf>));
let path2 = Arc::new(std::sync::Mutex::new(None::<PathBuf>));
let path1_clone = path1.clone();
let path2_clone = path2.clone();
let effect1 = with_temp_dir("test", move |path| {
*path1_clone.lock().unwrap() = Some(path);
effect_pure(1)
});
let effect2 = with_temp_dir("test", move |path| {
*path2_clone.lock().unwrap() = Some(path);
effect_pure(2)
});
run_effect(effect1).unwrap();
run_effect(effect2).unwrap();
let p1 = path1.lock().unwrap().clone().unwrap();
let p2 = path2.lock().unwrap().clone().unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_with_progress_runs_effect() {
let effect = with_progress("Testing", 100, |_handle| effect_pure(42));
let result = run_effect(effect);
assert_eq!(result.unwrap(), 42);
}
#[test]
fn test_with_progress_cleanup_on_error() {
let effect: AnalysisEffect<i32> = with_progress("Testing", 100, |_handle| {
effect_fail(AnalysisError::other("intentional failure"))
});
let result = run_effect(effect);
assert!(result.is_err());
}
#[test]
fn test_with_spinner_runs_effect() {
let effect = with_spinner("Processing", || effect_pure("done"));
let result = run_effect(effect);
assert_eq!(result.unwrap(), "done");
}
#[test]
fn test_with_file_read_opens_and_closes() {
let temp = TempFileDir::new().unwrap();
let file_path = temp.path().join("test.txt");
std::fs::write(&file_path, "hello world").unwrap();
let effect = with_file_read(file_path.clone(), |handle| {
assert!(handle.path().exists());
effect_pure(42)
});
let result = run_effect(effect);
assert_eq!(result.unwrap(), 42);
}
#[test]
fn test_with_file_read_fails_for_nonexistent() {
let effect = with_file_read(PathBuf::from("/nonexistent/file.txt"), |_handle| {
effect_pure(42)
});
let result = run_effect(effect);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to open"));
}
#[test]
fn test_bracket_io_custom_resource() {
let acquired = Arc::new(AtomicBool::new(false));
let released = Arc::new(AtomicBool::new(false));
let acquired_clone = acquired.clone();
let released_clone = released.clone();
let effect = bracket_io(
move || {
acquired_clone.store(true, Ordering::SeqCst);
Ok("resource".to_string())
},
move |_resource| {
released_clone.store(true, Ordering::SeqCst);
},
|resource| {
assert_eq!(resource, "resource");
effect_pure(42)
},
);
let result = run_effect(effect);
assert_eq!(result.unwrap(), 42);
assert!(acquired.load(Ordering::SeqCst));
assert!(released.load(Ordering::SeqCst));
}
#[test]
fn test_bracket_io_releases_on_error() {
let released = Arc::new(AtomicBool::new(false));
let released_clone = released.clone();
let effect: AnalysisEffect<i32> = bracket_io(
|| Ok("resource".to_string()),
move |_resource| {
released_clone.store(true, Ordering::SeqCst);
},
|_resource| effect_fail(AnalysisError::other("intentional failure")),
);
let result = run_effect(effect);
assert!(result.is_err());
assert!(
released.load(Ordering::SeqCst),
"Resource should be released on error"
);
}
#[test]
fn test_nested_resources() {
let temp = TempFileDir::new().unwrap();
let lock_path = temp.path().join("test.lock");
let inner_ran = Arc::new(AtomicBool::new(false));
let inner_ran_clone = inner_ran.clone();
let effect = with_lock_file(lock_path.clone(), move || {
with_temp_dir("nested", move |_temp_path| {
inner_ran_clone.store(true, Ordering::SeqCst);
effect_pure(42)
})
});
let result = run_effect(effect);
assert_eq!(result.unwrap(), 42);
assert!(inner_ran.load(Ordering::SeqCst));
assert!(!lock_path.exists(), "Lock should be cleaned up");
}
}