debtmap/effects.rs
1//! Effect type aliases and helpers for debtmap analysis.
2//!
3//! This module provides type aliases that integrate stillwater's effect system
4//! with debtmap's environment and error types. Using these aliases:
5//!
6//! - Reduces boilerplate in function signatures
7//! - Centralizes the environment and error types
8//! - Makes it easy to refactor if types change
9//!
10//! # Effect vs Validation
11//!
12//! - **Effect**: Represents a computation that may perform I/O and may fail.
13//! Use for operations like reading files or loading coverage data.
14//!
15//! - **Validation**: Represents a validation check that accumulates ALL errors
16//! instead of failing at the first one. Use for configuration validation,
17//! input checking, and anywhere you want comprehensive error reporting.
18//!
19//! # Reader Pattern (Spec 199)
20//!
21//! This module also provides **Reader pattern** helpers using stillwater 0.11.0's
22//! zero-cost `ask`, `asks`, and `local` primitives. The Reader pattern eliminates
23//! config parameter threading by making configuration available through the
24//! environment.
25//!
26//! ## Reader Pattern Benefits
27//!
28//! **Before (parameter threading):**
29//! ```rust,ignore
30//! fn analyze(ast: &Ast, config: &Config) -> Metrics {
31//! calculate_complexity(ast, &config.thresholds)
32//! }
33//! ```
34//!
35//! **After (Reader pattern):**
36//! ```rust,ignore
37//! use debtmap::effects::asks_config;
38//!
39//! fn analyze_effect<Env>(ast: Ast) -> impl Effect<...>
40//! where Env: AnalysisEnv + Clone + Send + Sync
41//! {
42//! asks_config(move |config| calculate_complexity(&ast, &config.thresholds))
43//! }
44//! ```
45//!
46//! ## Available Reader Helpers
47//!
48//! - [`asks_config`]: Access the full config via closure
49//! - [`asks_thresholds`]: Access thresholds config section
50//! - [`asks_scoring`]: Access scoring weights config section
51//! - [`asks_entropy`]: Access entropy config section
52//! - [`local_with_config`]: Run effect with modified config (temporary override)
53//!
54//! # Example: Using Effects
55//!
56//! ```rust,ignore
57//! use debtmap::effects::AnalysisEffect;
58//! use debtmap::env::AnalysisEnv;
59//! use stillwater::Effect;
60//!
61//! fn read_source(path: PathBuf) -> AnalysisEffect<String> {
62//! Effect::from_fn(move |env: &dyn AnalysisEnv| {
63//! env.file_system()
64//! .read_to_string(&path)
65//! .map_err(Into::into)
66//! })
67//! }
68//! ```
69//!
70//! # Example: Using Validation
71//!
72//! ```rust,ignore
73//! use debtmap::effects::{AnalysisValidation, validation_success, validation_failure};
74//!
75//! fn validate_thresholds(complexity: u32, lines: usize) -> AnalysisValidation<()> {
76//! let v1 = if complexity <= 50 {
77//! validation_success(())
78//! } else {
79//! validation_failure(AnalysisError::validation("Complexity too high"))
80//! };
81//!
82//! let v2 = if lines <= 1000 {
83//! validation_success(())
84//! } else {
85//! validation_failure(AnalysisError::validation("File too long"))
86//! };
87//!
88//! // Combine validations - collects ALL errors
89//! v1.and(v2)
90//! }
91//! ```
92//!
93//! # Example: Using Reader Pattern
94//!
95//! ```rust,ignore
96//! use debtmap::effects::{asks_config, asks_thresholds, local_with_config};
97//! use debtmap::env::AnalysisEnv;
98//! use stillwater::Effect;
99//!
100//! // Query config via closure
101//! fn get_complexity_threshold<Env>() -> impl Effect<Output = Option<u32>, Error = AnalysisError, Env = Env>
102//! where
103//! Env: AnalysisEnv + Clone + Send + Sync,
104//! {
105//! asks_config(|config| config.thresholds.as_ref().and_then(|t| t.complexity))
106//! }
107//!
108//! // Use temporary config override
109//! fn analyze_strict<Env>(path: PathBuf) -> impl Effect<...>
110//! where
111//! Env: AnalysisEnv + Clone + Send + Sync,
112//! {
113//! local_with_config(
114//! |config| {
115//! let mut strict = config.clone();
116//! // Apply stricter thresholds
117//! strict
118//! },
119//! analyze_file_effect(path)
120//! )
121//! }
122//! ```
123
124use crate::config::DebtmapConfig;
125use crate::env::{AnalysisEnv, RealEnv};
126use crate::errors::{errors_to_anyhow, AnalysisError};
127use stillwater::effect::prelude::*;
128use stillwater::{BoxedEffect, NonEmptyVec, Validation};
129
130/// Error collection type for validation accumulation.
131///
132/// This type holds multiple errors during validation, enabling comprehensive
133/// error reporting instead of failing at the first error.
134pub type AnalysisErrors = NonEmptyVec<AnalysisError>;
135
136/// Effect type for debtmap analysis operations.
137///
138/// This type alias parameterizes stillwater's Effect with:
139/// - Success type `T` (the computation result)
140/// - Error type `AnalysisError` (our unified error type)
141/// - Environment type `RealEnv` (production I/O capabilities)
142///
143/// # Usage
144///
145/// ```rust,ignore
146/// fn analyze_file(path: PathBuf) -> AnalysisEffect<FileMetrics> {
147/// Effect::from_fn(move |env| {
148/// let content = env.file_system().read_to_string(&path)?;
149/// let metrics = compute_metrics(&content);
150/// Ok(metrics)
151/// })
152/// }
153/// ```
154pub type AnalysisEffect<T> = BoxedEffect<T, AnalysisError, RealEnv>;
155
156/// Validation type for debtmap validations.
157///
158/// This type alias uses stillwater's Validation with:
159/// - Success type `T` (the validated value)
160/// - Error type `NonEmptyVec<AnalysisError>` (accumulated errors)
161///
162/// Unlike Result, Validation accumulates ALL errors instead of short-circuiting
163/// at the first failure. This is useful for:
164/// - Configuration validation (report all issues at once)
165/// - Input validation (show all problems to the user)
166/// - Analysis validation (collect all findings)
167///
168/// # Usage
169///
170/// ```rust
171/// use debtmap::effects::{AnalysisValidation, validation_success, validation_failure};
172/// use debtmap::errors::AnalysisError;
173///
174/// fn validate_name(name: &str) -> AnalysisValidation<String> {
175/// if name.is_empty() {
176/// validation_failure(AnalysisError::validation("Name cannot be empty"))
177/// } else {
178/// validation_success(name.to_string())
179/// }
180/// }
181/// ```
182pub type AnalysisValidation<T> = Validation<T, AnalysisErrors>;
183
184/// Create a successful validation result.
185///
186/// # Example
187///
188/// ```rust
189/// use debtmap::effects::validation_success;
190///
191/// let v: debtmap::effects::AnalysisValidation<i32> = validation_success(42);
192/// assert!(v.is_success());
193/// ```
194pub fn validation_success<T>(value: T) -> AnalysisValidation<T> {
195 Validation::Success(value)
196}
197
198/// Create a failed validation result with a single error.
199///
200/// # Example
201///
202/// ```rust
203/// use debtmap::effects::validation_failure;
204/// use debtmap::errors::AnalysisError;
205///
206/// let v: debtmap::effects::AnalysisValidation<i32> =
207/// validation_failure(AnalysisError::validation("Invalid input"));
208/// assert!(v.is_failure());
209/// ```
210pub fn validation_failure<T>(error: AnalysisError) -> AnalysisValidation<T> {
211 Validation::Failure(NonEmptyVec::new(error, Vec::new()))
212}
213
214/// Create a failed validation result with multiple errors.
215///
216/// # Panics
217///
218/// Panics if the errors vector is empty. Use `validation_failure` for
219/// single errors or ensure the vector is non-empty.
220///
221/// # Example
222///
223/// ```rust
224/// use debtmap::effects::validation_failures;
225/// use debtmap::errors::AnalysisError;
226///
227/// let errors = vec![
228/// AnalysisError::validation("Error 1"),
229/// AnalysisError::validation("Error 2"),
230/// ];
231/// let v: debtmap::effects::AnalysisValidation<i32> = validation_failures(errors);
232/// ```
233pub fn validation_failures<T>(errors: Vec<AnalysisError>) -> AnalysisValidation<T> {
234 let nev =
235 NonEmptyVec::from_vec(errors).expect("validation_failures requires at least one error");
236 Validation::Failure(nev)
237}
238
239/// Create an effect from a pure value (no I/O).
240///
241/// This is useful for wrapping pure computations in the effect system.
242///
243/// # Example
244///
245/// ```rust,ignore
246/// let effect = effect_pure(42);
247/// assert_eq!(effect.run(&env).unwrap(), 42);
248/// ```
249pub fn effect_pure<T: Send + 'static>(value: T) -> AnalysisEffect<T> {
250 pure(value).boxed()
251}
252
253/// Create an effect from an error.
254///
255/// This is useful for creating failing effects without needing I/O.
256///
257/// # Example
258///
259/// ```rust,ignore
260/// let effect: AnalysisEffect<i32> = effect_fail(AnalysisError::validation("bad input"));
261/// assert!(effect.run(&env).is_err());
262/// ```
263pub fn effect_fail<T: Send + 'static>(error: AnalysisError) -> AnalysisEffect<T> {
264 fail(error).boxed()
265}
266
267/// Create an effect from a synchronous function.
268///
269/// The function receives the environment and should return a Result.
270///
271/// # Example
272///
273/// ```rust,ignore
274/// fn read_config() -> AnalysisEffect<Config> {
275/// effect_from_fn(|env| {
276/// let content = env.file_system().read_to_string(Path::new("config.toml"))?;
277/// parse_config(&content).map_err(Into::into)
278/// })
279/// }
280/// ```
281pub fn effect_from_fn<T, F>(f: F) -> AnalysisEffect<T>
282where
283 T: Send + 'static,
284 F: FnOnce(&RealEnv) -> Result<T, AnalysisError> + Send + 'static,
285{
286 from_fn(f).boxed()
287}
288
289/// Run an effect and convert the result to anyhow::Result for backwards compatibility.
290///
291/// This function bridges the new effect system with existing code that uses
292/// anyhow::Result. Use this at the boundaries of your code where you need
293/// to integrate with existing APIs.
294///
295/// Note: This uses tokio's block_on to run the async effect synchronously.
296/// For better performance in async contexts, use `run_effect_async` instead.
297///
298/// # Example
299///
300/// ```rust,ignore
301/// // Old code using anyhow
302/// fn old_api() -> anyhow::Result<Metrics> {
303/// let config = DebtmapConfig::default();
304/// run_effect(analyze_effect(), config)
305/// }
306/// ```
307pub fn run_effect<T: Send + 'static>(
308 effect: AnalysisEffect<T>,
309 config: DebtmapConfig,
310) -> anyhow::Result<T> {
311 let env = RealEnv::new(config);
312 // Use tokio runtime to block on the async effect
313 let rt = tokio::runtime::Builder::new_current_thread()
314 .enable_all()
315 .build()
316 .map_err(|e| anyhow::anyhow!("Failed to create tokio runtime: {}", e))?;
317 rt.block_on(effect.run(&env)).map_err(Into::into)
318}
319
320/// Run an effect with a custom environment.
321///
322/// This is useful when you have an existing environment or need custom
323/// I/O implementations.
324///
325/// Note: This uses tokio's block_on to run the async effect synchronously.
326pub fn run_effect_with_env<T: Send + 'static, E: AnalysisEnv + Sync + 'static>(
327 effect: BoxedEffect<T, AnalysisError, E>,
328 env: &E,
329) -> anyhow::Result<T> {
330 let rt = tokio::runtime::Builder::new_current_thread()
331 .enable_all()
332 .build()
333 .map_err(|e| anyhow::anyhow!("Failed to create tokio runtime: {}", e))?;
334 rt.block_on(effect.run(env)).map_err(Into::into)
335}
336
337/// Run an effect asynchronously.
338///
339/// This is the preferred method when you're already in an async context.
340pub async fn run_effect_async<T: Send + 'static>(
341 effect: AnalysisEffect<T>,
342 config: DebtmapConfig,
343) -> anyhow::Result<T> {
344 let env = RealEnv::new(config);
345 effect.run(&env).await.map_err(Into::into)
346}
347
348/// Convert a Validation result to anyhow::Result for backwards compatibility.
349///
350/// If the validation failed with multiple errors, they are formatted as a list.
351///
352/// # Example
353///
354/// ```rust
355/// use debtmap::effects::{run_validation, validation_success, validation_failure};
356/// use debtmap::errors::AnalysisError;
357///
358/// let success = validation_success(42);
359/// assert_eq!(run_validation(success).unwrap(), 42);
360///
361/// let failure: debtmap::effects::AnalysisValidation<i32> =
362/// validation_failure(AnalysisError::validation("bad input"));
363/// assert!(run_validation(failure).is_err());
364/// ```
365pub fn run_validation<T>(validation: AnalysisValidation<T>) -> anyhow::Result<T> {
366 match validation {
367 Validation::Success(value) => Ok(value),
368 Validation::Failure(errors) => Err(errors_to_anyhow(errors.into_vec())),
369 }
370}
371
372/// Combine multiple validations, accumulating all errors.
373///
374/// This is the core of error accumulation - if any validation fails,
375/// all errors are collected. If all succeed, the results are collected.
376///
377/// # Example
378///
379/// ```rust
380/// use debtmap::effects::{combine_validations, validation_success, validation_failure};
381/// use debtmap::errors::AnalysisError;
382///
383/// let validations = vec![
384/// validation_success(1),
385/// validation_failure(AnalysisError::validation("error 1")),
386/// validation_success(3),
387/// validation_failure(AnalysisError::validation("error 2")),
388/// ];
389///
390/// let result = combine_validations(validations);
391/// // Result contains BOTH errors, not just the first one
392/// ```
393pub fn combine_validations<T>(validations: Vec<AnalysisValidation<T>>) -> AnalysisValidation<Vec<T>>
394where
395 T: Clone,
396{
397 let mut successes = Vec::new();
398 let mut failures: Vec<AnalysisError> = Vec::new();
399
400 for v in validations {
401 match v {
402 Validation::Success(value) => successes.push(value),
403 Validation::Failure(errors) => {
404 for err in errors {
405 failures.push(err);
406 }
407 }
408 }
409 }
410
411 if failures.is_empty() {
412 Validation::Success(successes)
413 } else {
414 Validation::Failure(NonEmptyVec::from_vec(failures).expect("failures cannot be empty here"))
415 }
416}
417
418/// Map a function over a validation's success value.
419///
420/// If the validation is successful, applies the function.
421/// If it failed, passes through the errors unchanged.
422pub fn validation_map<T, U, F>(validation: AnalysisValidation<T>, f: F) -> AnalysisValidation<U>
423where
424 F: FnOnce(T) -> U,
425{
426 match validation {
427 Validation::Success(value) => Validation::Success(f(value)),
428 Validation::Failure(errors) => Validation::Failure(errors),
429 }
430}
431
432// =============================================================================
433// Reader Pattern Helpers (Spec 199)
434// =============================================================================
435//
436// These helpers use stillwater 0.11.0's zero-cost Reader primitives to provide
437// config access without parameter threading.
438
439use crate::config::{EntropyConfig, ScoringWeights, ThresholdsConfig};
440use stillwater::Effect;
441
442use std::sync::Arc;
443
444/// A wrapper type that makes a shared function callable via `Fn` traits.
445///
446/// This wrapper holds an `Arc<F>` and implements `Fn` by cloning the `Arc`
447/// on each call, allowing the function to be called multiple times.
448#[derive(Clone)]
449pub struct SharedFn<F>(Arc<F>);
450
451impl<F> SharedFn<F> {
452 fn new(f: F) -> Self {
453 Self(Arc::new(f))
454 }
455}
456
457/// Query config through closure - the core Reader pattern primitive.
458///
459/// This function creates an effect that queries the environment's config
460/// using a provided closure. The closure receives a reference to the
461/// [`DebtmapConfig`] and returns any value.
462///
463/// Uses `Arc` internally to allow the closure to be called multiple times.
464///
465/// # Type Parameters
466///
467/// - `U`: The return type of the query
468/// - `Env`: The environment type (must implement [`AnalysisEnv`])
469/// - `F`: The query function type
470///
471/// # Example
472///
473/// ```rust,ignore
474/// use debtmap::effects::asks_config;
475///
476/// // Get ignore patterns from config
477/// fn get_ignore_patterns<Env>() -> impl Effect<Output = Vec<String>, Error = AnalysisError, Env = Env>
478/// where
479/// Env: AnalysisEnv + Clone + Send + Sync,
480/// {
481/// asks_config(|config| config.get_ignore_patterns())
482/// }
483///
484/// // Get complexity threshold
485/// fn get_complexity_threshold<Env>() -> impl Effect<Output = Option<u32>, Error = AnalysisError, Env = Env>
486/// where
487/// Env: AnalysisEnv + Clone + Send + Sync,
488/// {
489/// asks_config(|config| config.thresholds.as_ref().and_then(|t| t.complexity))
490/// }
491/// ```
492pub fn asks_config<U, Env, F>(f: F) -> impl Effect<Output = U, Error = AnalysisError, Env = Env>
493where
494 U: Send + 'static,
495 Env: AnalysisEnv + Clone + Send + Sync + 'static,
496 F: Fn(&DebtmapConfig) -> U + Send + Sync + 'static,
497{
498 let shared = SharedFn::new(f);
499 stillwater::asks(move |env: &Env| (shared.0)(env.config()))
500}
501
502/// Query thresholds config section.
503///
504/// Convenience helper for accessing the thresholds configuration.
505/// Returns `None` if thresholds are not configured.
506///
507/// # Example
508///
509/// ```rust,ignore
510/// use debtmap::effects::asks_thresholds;
511///
512/// fn get_max_file_length<Env>() -> impl Effect<Output = Option<usize>, Error = AnalysisError, Env = Env>
513/// where
514/// Env: AnalysisEnv + Clone + Send + Sync,
515/// {
516/// asks_thresholds(|thresholds| thresholds.and_then(|t| t.max_file_length))
517/// }
518/// ```
519pub fn asks_thresholds<U, Env, F>(f: F) -> impl Effect<Output = U, Error = AnalysisError, Env = Env>
520where
521 U: Send + 'static,
522 Env: AnalysisEnv + Clone + Send + Sync + 'static,
523 F: Fn(Option<&ThresholdsConfig>) -> U + Send + Sync + 'static,
524{
525 let shared = SharedFn::new(f);
526 stillwater::asks(move |env: &Env| (shared.0)(env.config().thresholds.as_ref()))
527}
528
529/// Query scoring weights config section.
530///
531/// Convenience helper for accessing the scoring weights configuration.
532/// Returns `None` if scoring weights are not configured.
533///
534/// # Example
535///
536/// ```rust,ignore
537/// use debtmap::effects::asks_scoring;
538///
539/// fn get_coverage_weight<Env>() -> impl Effect<Output = f64, Error = AnalysisError, Env = Env>
540/// where
541/// Env: AnalysisEnv + Clone + Send + Sync,
542/// {
543/// asks_scoring(|scoring| scoring.map(|s| s.coverage).unwrap_or(0.5))
544/// }
545/// ```
546pub fn asks_scoring<U, Env, F>(f: F) -> impl Effect<Output = U, Error = AnalysisError, Env = Env>
547where
548 U: Send + 'static,
549 Env: AnalysisEnv + Clone + Send + Sync + 'static,
550 F: Fn(Option<&ScoringWeights>) -> U + Send + Sync + 'static,
551{
552 let shared = SharedFn::new(f);
553 stillwater::asks(move |env: &Env| (shared.0)(env.config().scoring.as_ref()))
554}
555
556/// Query entropy config section.
557///
558/// Convenience helper for accessing the entropy configuration.
559/// Returns `None` if entropy config is not set.
560///
561/// # Example
562///
563/// ```rust,ignore
564/// use debtmap::effects::asks_entropy;
565///
566/// fn is_entropy_enabled<Env>() -> impl Effect<Output = bool, Error = AnalysisError, Env = Env>
567/// where
568/// Env: AnalysisEnv + Clone + Send + Sync,
569/// {
570/// asks_entropy(|entropy| entropy.map(|e| e.enabled).unwrap_or(true))
571/// }
572/// ```
573pub fn asks_entropy<U, Env, F>(f: F) -> impl Effect<Output = U, Error = AnalysisError, Env = Env>
574where
575 U: Send + 'static,
576 Env: AnalysisEnv + Clone + Send + Sync + 'static,
577 F: Fn(Option<&EntropyConfig>) -> U + Send + Sync + 'static,
578{
579 let shared = SharedFn::new(f);
580 stillwater::asks(move |env: &Env| (shared.0)(env.config().entropy.as_ref()))
581}
582
583/// Run an effect with a temporarily modified config.
584///
585/// This is the Reader pattern's `local` operation - it allows running an
586/// inner effect with a modified environment. The modification is only
587/// visible to the inner effect; after it completes, the original
588/// environment is restored.
589///
590/// This is useful for:
591/// - **Strict mode**: Running analysis with stricter thresholds
592/// - **Custom thresholds**: Temporarily overriding specific settings
593/// - **Feature flags**: Temporarily enabling/disabling features
594///
595/// # Type Parameters
596///
597/// - `Inner`: The inner effect type
598/// - `F`: The environment transformation function
599/// - `Env`: The environment type
600///
601/// # Example
602///
603/// ```rust,ignore
604/// use debtmap::effects::local_with_config;
605///
606/// // Run analysis in strict mode (lower complexity threshold)
607/// fn analyze_strict<Env>(
608/// path: PathBuf,
609/// ) -> impl Effect<Output = FileAnalysis, Error = AnalysisError, Env = Env>
610/// where
611/// Env: AnalysisEnv + Clone + Send + Sync,
612/// {
613/// local_with_config(
614/// |config| {
615/// let mut strict = config.clone();
616/// if let Some(ref mut thresholds) = strict.thresholds {
617/// // Reduce complexity threshold by half
618/// if let Some(complexity) = thresholds.complexity {
619/// thresholds.complexity = Some(complexity / 2);
620/// }
621/// }
622/// strict
623/// },
624/// analyze_file_effect(path)
625/// )
626/// }
627///
628/// // Temporarily disable entropy-based scoring
629/// fn analyze_without_entropy<Env>(
630/// inner: impl Effect<Output = T, Error = AnalysisError, Env = Env>
631/// ) -> impl Effect<Output = T, Error = AnalysisError, Env = Env>
632/// where
633/// Env: AnalysisEnv + Clone + Send + Sync,
634/// {
635/// local_with_config(
636/// |config| {
637/// let mut modified = config.clone();
638/// if let Some(ref mut entropy) = modified.entropy {
639/// entropy.enabled = false;
640/// }
641/// modified
642/// },
643/// inner
644/// )
645/// }
646/// ```
647pub fn local_with_config<Inner, F, Env>(
648 f: F,
649 inner: Inner,
650) -> impl Effect<Output = Inner::Output, Error = Inner::Error, Env = Env>
651where
652 Env: AnalysisEnv + Clone + Send + Sync + 'static,
653 F: Fn(&DebtmapConfig) -> DebtmapConfig + Send + Sync + 'static,
654 Inner: Effect<Env = Env>,
655{
656 let shared = SharedFn::new(f);
657 stillwater::local(
658 move |env: &Env| {
659 let new_config = (shared.0)(env.config());
660 env.clone().with_config(new_config)
661 },
662 inner,
663 )
664}
665
666/// Query the entire environment.
667///
668/// This is a low-level helper that returns a clone of the entire environment.
669/// Prefer using [`asks_config`] or the specific query helpers when you only
670/// need config access.
671///
672/// # Example
673///
674/// ```rust,ignore
675/// use debtmap::effects::ask_env;
676///
677/// fn get_full_env<Env>() -> impl Effect<Output = Env, Error = AnalysisError, Env = Env>
678/// where
679/// Env: AnalysisEnv + Clone + Send + Sync,
680/// {
681/// ask_env()
682/// }
683/// ```
684pub fn ask_env<Env>() -> stillwater::effect::reader::Ask<AnalysisError, Env>
685where
686 Env: Clone + Send + Sync + 'static,
687{
688 stillwater::ask::<AnalysisError, Env>()
689}
690
691// =============================================================================
692// Retry Pattern Helpers (Spec 205)
693// =============================================================================
694//
695// These helpers enable automatic retry of transient failures with configurable
696// backoff strategies.
697
698use crate::config::RetryConfig;
699use log::{error, info, warn};
700use std::time::Instant;
701
702/// Wrap an effect with retry logic using the configured policy.
703///
704/// This combinator automatically retries the effect when it fails with
705/// a retryable error, using the configured retry strategy and delays.
706///
707/// # Arguments
708///
709/// * `effect_factory` - A function that creates the effect to retry.
710/// Called each time a retry is needed.
711/// * `retry_config` - Configuration for retry behavior.
712///
713/// # Retryable Errors
714///
715/// Only errors where `error.is_retryable()` returns `true` will trigger
716/// a retry. Non-retryable errors (parse errors, validation errors, etc.)
717/// cause immediate failure.
718///
719/// # Example
720///
721/// ```rust,ignore
722/// use debtmap::effects::{with_retry, AnalysisEffect};
723/// use debtmap::config::RetryConfig;
724/// use debtmap::io::effects::read_file_effect;
725///
726/// fn read_file_resilient(path: PathBuf) -> AnalysisEffect<String> {
727/// let config = RetryConfig::default();
728/// with_retry(
729/// move || read_file_effect(path.clone()),
730/// config,
731/// )
732/// }
733/// ```
734///
735/// # Logging
736///
737/// Retry attempts are logged at WARN level for visibility:
738/// ```text
739/// WARN Retrying operation (attempt 2/3): I/O error: Resource busy
740/// ```
741pub fn with_retry<T, F>(effect_factory: F, retry_config: RetryConfig) -> AnalysisEffect<T>
742where
743 T: Send + 'static,
744 F: Fn() -> AnalysisEffect<T> + Send + Sync + 'static,
745{
746 from_async(move |env: &RealEnv| {
747 let env = env.clone();
748 let config = retry_config.clone();
749 let factory = SharedFn::new(effect_factory);
750
751 async move {
752 let start = Instant::now();
753 let mut attempt = 0u32;
754 let mut last_error: Option<AnalysisError> = None;
755
756 loop {
757 let effect = (factory.0)();
758 match effect.run(&env).await {
759 Ok(value) => {
760 if attempt > 0 {
761 info!("Operation succeeded after {} retry attempt(s)", attempt);
762 }
763 return Ok(value);
764 }
765 Err(e) => {
766 let elapsed = start.elapsed();
767
768 // Check if error is retryable and we should try again
769 if e.is_retryable() && config.should_retry(attempt, elapsed) {
770 attempt += 1;
771 warn!(
772 "Retrying operation (attempt {}/{}): {}",
773 attempt, config.max_retries, e
774 );
775
776 // Sleep before retry
777 let delay = config.delay_for_attempt(attempt);
778 tokio::time::sleep(delay).await;
779
780 let _ = last_error.insert(e);
781 } else {
782 // Not retryable or exhausted retries
783 if attempt > 0 {
784 error!(
785 "Operation failed after {} retry attempt(s): {}",
786 attempt, e
787 );
788 }
789 return Err(e);
790 }
791 }
792 }
793 }
794 }
795 })
796 .boxed()
797}
798
799/// Wrap an effect with retry logic, using the retry config from environment.
800///
801/// This is a convenience function that reads the retry configuration from
802/// the environment's config. If no retry config is set, uses defaults.
803///
804/// # Example
805///
806/// ```rust,ignore
807/// use debtmap::effects::with_retry_from_env;
808/// use debtmap::io::effects::read_file_effect;
809///
810/// fn read_file_resilient(path: PathBuf) -> AnalysisEffect<String> {
811/// with_retry_from_env(move || read_file_effect(path.clone()))
812/// }
813/// ```
814pub fn with_retry_from_env<T, F>(effect_factory: F) -> AnalysisEffect<T>
815where
816 T: Send + 'static,
817 F: Fn() -> AnalysisEffect<T> + Send + Sync + Clone + 'static,
818{
819 from_async(move |env: &RealEnv| {
820 let env = env.clone();
821 let factory = effect_factory.clone();
822
823 async move {
824 let config = env.config().retry.clone().unwrap_or_default();
825
826 // If retries are disabled, just run the effect directly
827 if !config.enabled {
828 return factory().run(&env).await;
829 }
830
831 let start = Instant::now();
832 let mut attempt = 0u32;
833
834 loop {
835 let effect = factory();
836 match effect.run(&env).await {
837 Ok(value) => {
838 if attempt > 0 {
839 info!("Operation succeeded after {} retry attempt(s)", attempt);
840 }
841 return Ok(value);
842 }
843 Err(e) => {
844 let elapsed = start.elapsed();
845
846 if e.is_retryable() && config.should_retry(attempt, elapsed) {
847 attempt += 1;
848 warn!(
849 "Retrying operation (attempt {}/{}): {}",
850 attempt, config.max_retries, e
851 );
852
853 let delay = config.delay_for_attempt(attempt);
854 tokio::time::sleep(delay).await;
855 } else {
856 if attempt > 0 {
857 error!(
858 "Operation failed after {} retry attempt(s): {}",
859 attempt, e
860 );
861 }
862 return Err(e);
863 }
864 }
865 }
866 }
867 }
868 })
869 .boxed()
870}
871
872/// Check if retries are enabled in the given config.
873///
874/// Returns `true` if the retry config is present and enabled.
875pub fn is_retry_enabled(config: &DebtmapConfig) -> bool {
876 config.retry.as_ref().map(|r| r.enabled).unwrap_or(true) // Default is enabled
877}
878
879/// Get the effective retry config from a DebtmapConfig.
880///
881/// Returns the configured retry settings or defaults if not set.
882pub fn get_retry_config(config: &DebtmapConfig) -> RetryConfig {
883 config.retry.clone().unwrap_or_default()
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 #[test]
891 fn test_validation_success() {
892 let v: AnalysisValidation<i32> = validation_success(42);
893 assert!(v.is_success());
894 match v {
895 Validation::Success(n) => assert_eq!(n, 42),
896 Validation::Failure(_) => panic!("Expected success"),
897 }
898 }
899
900 #[test]
901 fn test_validation_failure() {
902 let v: AnalysisValidation<i32> =
903 validation_failure(AnalysisError::validation("test error"));
904 assert!(v.is_failure());
905 }
906
907 #[test]
908 fn test_validation_failures() {
909 let errors = vec![
910 AnalysisError::validation("error 1"),
911 AnalysisError::validation("error 2"),
912 ];
913 let v: AnalysisValidation<i32> = validation_failures(errors);
914 assert!(v.is_failure());
915 match v {
916 Validation::Failure(nev) => {
917 let vec: Vec<_> = nev.into_iter().collect();
918 assert_eq!(vec.len(), 2);
919 }
920 _ => panic!("Expected failure"),
921 }
922 }
923
924 #[test]
925 fn test_run_validation_success() {
926 let v = validation_success(42);
927 let result = run_validation(v);
928 assert_eq!(result.unwrap(), 42);
929 }
930
931 #[test]
932 fn test_run_validation_failure() {
933 let v: AnalysisValidation<i32> =
934 validation_failure(AnalysisError::validation("test error"));
935 let result = run_validation(v);
936 assert!(result.is_err());
937 assert!(result.unwrap_err().to_string().contains("test error"));
938 }
939
940 #[test]
941 fn test_combine_validations_all_success() {
942 let validations = vec![
943 validation_success(1),
944 validation_success(2),
945 validation_success(3),
946 ];
947 let result = combine_validations(validations);
948 match result {
949 Validation::Success(values) => assert_eq!(values, vec![1, 2, 3]),
950 _ => panic!("Expected success"),
951 }
952 }
953
954 #[test]
955 fn test_combine_validations_accumulates_errors() {
956 let validations = vec![
957 validation_success(1),
958 validation_failure(AnalysisError::validation("error 1")),
959 validation_success(3),
960 validation_failure(AnalysisError::validation("error 2")),
961 ];
962 let result: AnalysisValidation<Vec<i32>> = combine_validations(validations);
963 match result {
964 Validation::Failure(errors) => {
965 let vec: Vec<_> = errors.into_iter().collect();
966 assert_eq!(vec.len(), 2);
967 }
968 _ => panic!("Expected failure with accumulated errors"),
969 }
970 }
971
972 #[test]
973 fn test_validation_map_success() {
974 let v = validation_success(21);
975 let v2 = validation_map(v, |n| n * 2);
976 match v2 {
977 Validation::Success(n) => assert_eq!(n, 42),
978 _ => panic!("Expected success"),
979 }
980 }
981
982 #[test]
983 fn test_validation_map_failure() {
984 let v: AnalysisValidation<i32> = validation_failure(AnalysisError::validation("error"));
985 let v2: AnalysisValidation<i32> = validation_map(v, |n| n * 2);
986 assert!(v2.is_failure());
987 }
988
989 #[test]
990 fn test_effect_pure() {
991 let effect = effect_pure(42);
992 let result = run_effect(effect, DebtmapConfig::default());
993 assert_eq!(result.unwrap(), 42);
994 }
995
996 #[test]
997 fn test_effect_fail() {
998 let effect: AnalysisEffect<i32> = effect_fail(AnalysisError::validation("test"));
999 let result = run_effect(effect, DebtmapConfig::default());
1000 assert!(result.is_err());
1001 }
1002
1003 #[test]
1004 fn test_run_effect() {
1005 let effect = effect_pure(42);
1006 let result = run_effect(effect, DebtmapConfig::default());
1007 assert_eq!(result.unwrap(), 42);
1008 }
1009
1010 // =========================================================================
1011 // Reader Pattern Tests (Spec 199)
1012 // =========================================================================
1013
1014 #[tokio::test]
1015 async fn test_asks_config_returns_config_value() {
1016 use crate::config::IgnoreConfig;
1017
1018 let config = DebtmapConfig {
1019 ignore: Some(IgnoreConfig {
1020 patterns: vec!["test/**".to_string()],
1021 }),
1022 ..Default::default()
1023 };
1024 let env = RealEnv::new(config);
1025
1026 // Create effect that queries config
1027 let effect = asks_config::<Vec<String>, RealEnv, _>(|config| config.get_ignore_patterns());
1028
1029 let patterns = effect.run(&env).await.unwrap();
1030 assert_eq!(patterns, vec!["test/**".to_string()]);
1031 }
1032
1033 #[tokio::test]
1034 async fn test_asks_config_with_default_config() {
1035 let env = RealEnv::default();
1036
1037 // Query ignore patterns from default config
1038 let effect = asks_config::<Vec<String>, RealEnv, _>(|config| config.get_ignore_patterns());
1039
1040 let patterns = effect.run(&env).await.unwrap();
1041 assert!(patterns.is_empty()); // Default config has no ignore patterns
1042 }
1043
1044 #[tokio::test]
1045 async fn test_asks_thresholds_with_thresholds() {
1046 use crate::config::ThresholdsConfig;
1047
1048 let config = DebtmapConfig {
1049 thresholds: Some(ThresholdsConfig {
1050 complexity: Some(15),
1051 max_file_length: Some(500),
1052 ..Default::default()
1053 }),
1054 ..Default::default()
1055 };
1056 let env = RealEnv::new(config);
1057
1058 let effect = asks_thresholds::<Option<u32>, RealEnv, _>(|thresholds| {
1059 thresholds.and_then(|t| t.complexity)
1060 });
1061
1062 let complexity = effect.run(&env).await.unwrap();
1063 assert_eq!(complexity, Some(15));
1064 }
1065
1066 #[tokio::test]
1067 async fn test_asks_thresholds_without_thresholds() {
1068 let env = RealEnv::default();
1069
1070 let effect = asks_thresholds::<Option<u32>, RealEnv, _>(|thresholds| {
1071 thresholds.and_then(|t| t.complexity)
1072 });
1073
1074 let complexity = effect.run(&env).await.unwrap();
1075 assert_eq!(complexity, None);
1076 }
1077
1078 #[tokio::test]
1079 async fn test_asks_scoring_with_weights() {
1080 let config = DebtmapConfig {
1081 scoring: Some(ScoringWeights {
1082 coverage: 0.6,
1083 complexity: 0.3,
1084 dependency: 0.1,
1085 ..Default::default()
1086 }),
1087 ..Default::default()
1088 };
1089 let env = RealEnv::new(config);
1090
1091 let effect =
1092 asks_scoring::<f64, RealEnv, _>(|scoring| scoring.map(|s| s.coverage).unwrap_or(0.5));
1093
1094 let coverage = effect.run(&env).await.unwrap();
1095 assert!((coverage - 0.6).abs() < 0.001);
1096 }
1097
1098 #[tokio::test]
1099 async fn test_asks_entropy_enabled() {
1100 let config = DebtmapConfig {
1101 entropy: Some(EntropyConfig {
1102 enabled: false,
1103 ..Default::default()
1104 }),
1105 ..Default::default()
1106 };
1107 let env = RealEnv::new(config);
1108
1109 let effect =
1110 asks_entropy::<bool, RealEnv, _>(|entropy| entropy.map(|e| e.enabled).unwrap_or(true));
1111
1112 let enabled = effect.run(&env).await.unwrap();
1113 assert!(!enabled);
1114 }
1115
1116 #[tokio::test]
1117 async fn test_local_with_config_modifies_config() {
1118 use crate::config::IgnoreConfig;
1119
1120 let original_config = DebtmapConfig {
1121 ignore: Some(IgnoreConfig {
1122 patterns: vec!["original/**".to_string()],
1123 }),
1124 ..Default::default()
1125 };
1126 let env = RealEnv::new(original_config);
1127
1128 // Inner effect that queries config
1129 let inner = asks_config::<Vec<String>, RealEnv, _>(|config| config.get_ignore_patterns());
1130
1131 // Wrap with local that modifies config
1132 let effect = local_with_config(
1133 |_config| DebtmapConfig {
1134 ignore: Some(IgnoreConfig {
1135 patterns: vec!["modified/**".to_string()],
1136 }),
1137 ..Default::default()
1138 },
1139 inner,
1140 );
1141
1142 let patterns = effect.run(&env).await.unwrap();
1143 assert_eq!(patterns, vec!["modified/**".to_string()]);
1144 }
1145
1146 #[tokio::test]
1147 async fn test_local_with_config_restores_after() {
1148 use crate::config::IgnoreConfig;
1149
1150 let original_config = DebtmapConfig {
1151 ignore: Some(IgnoreConfig {
1152 patterns: vec!["original/**".to_string()],
1153 }),
1154 ..Default::default()
1155 };
1156 let env = RealEnv::new(original_config.clone());
1157
1158 // Run with modified config
1159 let inner = asks_config::<Vec<String>, RealEnv, _>(|config| config.get_ignore_patterns());
1160 let modified_effect = local_with_config(
1161 |_| DebtmapConfig {
1162 ignore: Some(IgnoreConfig {
1163 patterns: vec!["modified/**".to_string()],
1164 }),
1165 ..Default::default()
1166 },
1167 inner,
1168 );
1169 let _ = modified_effect.run(&env).await.unwrap();
1170
1171 // Original env should be unchanged (run a new query)
1172 let check_effect =
1173 asks_config::<Vec<String>, RealEnv, _>(|config| config.get_ignore_patterns());
1174 let patterns = check_effect.run(&env).await.unwrap();
1175 assert_eq!(patterns, vec!["original/**".to_string()]);
1176 }
1177
1178 #[tokio::test]
1179 async fn test_ask_env_returns_cloned_env() {
1180 let config = DebtmapConfig::default();
1181 let env = RealEnv::new(config);
1182
1183 let effect = ask_env::<RealEnv>();
1184
1185 let cloned_env = effect.run(&env).await.unwrap();
1186 // Both should have the same config
1187 assert_eq!(
1188 format!("{:?}", cloned_env.config()),
1189 format!("{:?}", env.config())
1190 );
1191 }
1192
1193 #[tokio::test]
1194 async fn test_reader_pattern_composition() {
1195 use stillwater::EffectExt;
1196
1197 let config = DebtmapConfig {
1198 scoring: Some(ScoringWeights {
1199 coverage: 0.6,
1200 complexity: 0.4,
1201 dependency: 0.0,
1202 ..Default::default()
1203 }),
1204 ..Default::default()
1205 };
1206 let env = RealEnv::new(config);
1207
1208 // Compose multiple Reader queries
1209 let coverage_effect =
1210 asks_scoring::<f64, RealEnv, _>(|scoring| scoring.map(|s| s.coverage).unwrap_or(0.5));
1211
1212 let complexity_effect = asks_scoring::<f64, RealEnv, _>(|scoring| {
1213 scoring.map(|s| s.complexity).unwrap_or(0.35)
1214 });
1215
1216 // Use and_then to compose effects
1217 let combined =
1218 coverage_effect.and_then(move |cov| complexity_effect.map(move |comp| cov + comp));
1219
1220 let sum = combined.run(&env).await.unwrap();
1221 assert!((sum - 1.0).abs() < 0.001); // 0.6 + 0.4 = 1.0
1222 }
1223
1224 // =========================================================================
1225 // Retry Pattern Tests (Spec 205)
1226 // =========================================================================
1227
1228 use std::sync::atomic::{AtomicUsize, Ordering};
1229
1230 #[tokio::test]
1231 async fn test_with_retry_succeeds_first_attempt() {
1232 let config = RetryConfig::default();
1233 let env = RealEnv::default();
1234
1235 let effect = with_retry(|| effect_pure(42), config);
1236 let result = effect.run(&env).await;
1237
1238 assert!(result.is_ok());
1239 assert_eq!(result.unwrap(), 42);
1240 }
1241
1242 #[tokio::test]
1243 async fn test_with_retry_succeeds_after_transient_failure() {
1244 let config = RetryConfig {
1245 max_retries: 3,
1246 base_delay_ms: 10, // Short delay for tests
1247 jitter_factor: 0.0,
1248 ..Default::default()
1249 };
1250 let env = RealEnv::default();
1251
1252 // Counter to track attempts
1253 let attempt_count = Arc::new(AtomicUsize::new(0));
1254 let attempt_clone = attempt_count.clone();
1255
1256 let effect = with_retry(
1257 move || {
1258 let count = attempt_clone.fetch_add(1, Ordering::SeqCst);
1259 if count < 2 {
1260 // Fail with retryable error for first 2 attempts
1261 effect_fail(AnalysisError::io("Resource busy"))
1262 } else {
1263 // Succeed on third attempt
1264 effect_pure("success".to_string())
1265 }
1266 },
1267 config,
1268 );
1269
1270 let result = effect.run(&env).await;
1271
1272 assert!(result.is_ok());
1273 assert_eq!(result.unwrap(), "success");
1274 // Should have made 3 attempts (indices 0, 1, 2)
1275 assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
1276 }
1277
1278 #[tokio::test]
1279 async fn test_with_retry_fails_on_permanent_error() {
1280 let config = RetryConfig {
1281 max_retries: 3,
1282 base_delay_ms: 10,
1283 jitter_factor: 0.0,
1284 ..Default::default()
1285 };
1286 let env = RealEnv::default();
1287
1288 let attempt_count = Arc::new(AtomicUsize::new(0));
1289 let attempt_clone = attempt_count.clone();
1290
1291 let effect: AnalysisEffect<String> = with_retry(
1292 move || {
1293 attempt_clone.fetch_add(1, Ordering::SeqCst);
1294 // Parse errors are not retryable
1295 effect_fail(AnalysisError::parse("Syntax error"))
1296 },
1297 config,
1298 );
1299
1300 let result = effect.run(&env).await;
1301
1302 assert!(result.is_err());
1303 // Should have only made 1 attempt (immediate failure, no retry)
1304 assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
1305 }
1306
1307 #[tokio::test]
1308 async fn test_with_retry_exhausts_retries() {
1309 let config = RetryConfig {
1310 max_retries: 2,
1311 base_delay_ms: 10,
1312 jitter_factor: 0.0,
1313 ..Default::default()
1314 };
1315 let env = RealEnv::default();
1316
1317 let attempt_count = Arc::new(AtomicUsize::new(0));
1318 let attempt_clone = attempt_count.clone();
1319
1320 let effect: AnalysisEffect<String> = with_retry(
1321 move || {
1322 attempt_clone.fetch_add(1, Ordering::SeqCst);
1323 // Always fail with retryable error
1324 effect_fail(AnalysisError::io("Resource busy"))
1325 },
1326 config,
1327 );
1328
1329 let result = effect.run(&env).await;
1330
1331 assert!(result.is_err());
1332 // Initial attempt + 2 retries = 3 total attempts
1333 assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
1334 }
1335
1336 #[tokio::test]
1337 async fn test_with_retry_disabled() {
1338 let config = RetryConfig::disabled();
1339 let env = RealEnv::default();
1340
1341 let attempt_count = Arc::new(AtomicUsize::new(0));
1342 let attempt_clone = attempt_count.clone();
1343
1344 let effect: AnalysisEffect<String> = with_retry(
1345 move || {
1346 attempt_clone.fetch_add(1, Ordering::SeqCst);
1347 effect_fail(AnalysisError::io("Resource busy"))
1348 },
1349 config,
1350 );
1351
1352 let result = effect.run(&env).await;
1353
1354 assert!(result.is_err());
1355 // With retries disabled, should only try once
1356 assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
1357 }
1358
1359 #[tokio::test]
1360 async fn test_with_retry_from_env_uses_config() {
1361 let config = DebtmapConfig {
1362 retry: Some(RetryConfig {
1363 enabled: true,
1364 max_retries: 2,
1365 base_delay_ms: 10,
1366 jitter_factor: 0.0,
1367 ..Default::default()
1368 }),
1369 ..Default::default()
1370 };
1371 let env = RealEnv::new(config);
1372
1373 let attempt_count = Arc::new(AtomicUsize::new(0));
1374 let attempt_clone = attempt_count.clone();
1375
1376 let factory = move || {
1377 let count = attempt_clone.fetch_add(1, Ordering::SeqCst);
1378 if count < 1 {
1379 effect_fail(AnalysisError::io("Resource busy"))
1380 } else {
1381 effect_pure("success".to_string())
1382 }
1383 };
1384
1385 let effect = with_retry_from_env(factory);
1386 let result = effect.run(&env).await;
1387
1388 assert!(result.is_ok());
1389 assert_eq!(result.unwrap(), "success");
1390 // Should have made 2 attempts
1391 assert_eq!(attempt_count.load(Ordering::SeqCst), 2);
1392 }
1393
1394 #[tokio::test]
1395 async fn test_with_retry_from_env_disabled() {
1396 let config = DebtmapConfig {
1397 retry: Some(RetryConfig::disabled()),
1398 ..Default::default()
1399 };
1400 let env = RealEnv::new(config);
1401
1402 let attempt_count = Arc::new(AtomicUsize::new(0));
1403 let attempt_clone = attempt_count.clone();
1404
1405 let factory = move || {
1406 attempt_clone.fetch_add(1, Ordering::SeqCst);
1407 effect_fail::<String>(AnalysisError::io("Resource busy"))
1408 };
1409
1410 let effect = with_retry_from_env(factory);
1411 let result = effect.run(&env).await;
1412
1413 assert!(result.is_err());
1414 assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
1415 }
1416
1417 #[test]
1418 fn test_is_retry_enabled_default() {
1419 let config = DebtmapConfig::default();
1420 assert!(is_retry_enabled(&config));
1421 }
1422
1423 #[test]
1424 fn test_is_retry_enabled_explicit_true() {
1425 let config = DebtmapConfig {
1426 retry: Some(RetryConfig {
1427 enabled: true,
1428 ..Default::default()
1429 }),
1430 ..Default::default()
1431 };
1432 assert!(is_retry_enabled(&config));
1433 }
1434
1435 #[test]
1436 fn test_is_retry_enabled_explicit_false() {
1437 let config = DebtmapConfig {
1438 retry: Some(RetryConfig::disabled()),
1439 ..Default::default()
1440 };
1441 assert!(!is_retry_enabled(&config));
1442 }
1443
1444 #[test]
1445 fn test_get_retry_config_default() {
1446 let config = DebtmapConfig::default();
1447 let retry_config = get_retry_config(&config);
1448
1449 assert!(retry_config.enabled);
1450 assert_eq!(retry_config.max_retries, 3);
1451 }
1452
1453 #[test]
1454 fn test_get_retry_config_custom() {
1455 let config = DebtmapConfig {
1456 retry: Some(RetryConfig {
1457 enabled: true,
1458 max_retries: 5,
1459 base_delay_ms: 200,
1460 ..Default::default()
1461 }),
1462 ..Default::default()
1463 };
1464 let retry_config = get_retry_config(&config);
1465
1466 assert!(retry_config.enabled);
1467 assert_eq!(retry_config.max_retries, 5);
1468 assert_eq!(retry_config.base_delay_ms, 200);
1469 }
1470}