Skip to main content

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