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}