Skip to main content

debtmap/commands/
state.rs

1//! Type-state pattern for validating configuration before execution
2//!
3//! This module provides compile-time guarantees that configuration
4//! is validated before being executed. Using phantom types, we encode
5//! validation states in the type system itself.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use debtmap::commands::state::{AnalyzeConfig, Unvalidated, Validated};
11//!
12//! // Create unvalidated config
13//! let config: AnalyzeConfig<Unvalidated> = AnalyzeConfig::new(path);
14//!
15//! // This won't compile - unvalidated config cannot be executed:
16//! // config.execute(); // ERROR: method not found
17//!
18//! // Must validate first:
19//! let validated = config.validate()?;
20//! validated.execute()?; // OK - validated config can execute
21//! ```
22
23use std::marker::PhantomData;
24use std::path::PathBuf;
25
26use anyhow::{Context, Result};
27
28use crate::cli;
29use crate::formatting::FormattingConfig;
30
31/// Marker type representing unvalidated state
32#[derive(Debug, Clone, Copy)]
33pub struct Unvalidated;
34
35/// Marker type representing validated state
36#[derive(Debug, Clone, Copy)]
37pub struct Validated;
38
39/// Configuration with type-state validation
40///
41/// Generic parameter `State` encodes whether config has been validated.
42/// Default is `Unvalidated` to ensure validation is explicit.
43#[derive(Debug, Clone)]
44pub struct AnalyzeConfig<State = Unvalidated> {
45    // Configuration fields
46    pub path: PathBuf,
47    pub format: cli::OutputFormat,
48    pub output: Option<PathBuf>,
49    pub threshold_complexity: u32,
50    pub threshold_duplication: usize,
51    pub languages: Option<Vec<String>>,
52    pub coverage_file: Option<PathBuf>,
53    pub enable_context: bool,
54    pub context_providers: Option<Vec<String>>,
55    pub disable_context: Option<Vec<String>>,
56    pub top: Option<usize>,
57    pub tail: Option<usize>,
58    pub summary: bool,
59    pub semantic_off: bool,
60    pub verbosity: u8,
61    pub verbose_macro_warnings: bool,
62    pub show_macro_stats: bool,
63    pub min_priority: Option<String>,
64    pub min_score: Option<f64>,
65    pub filter_categories: Option<Vec<String>>,
66    pub no_context_aware: bool,
67    pub threshold_preset: Option<cli::ThresholdPreset>,
68    pub _formatting_config: FormattingConfig,
69    pub parallel: bool,
70    pub jobs: usize,
71    pub multi_pass: bool,
72    pub show_attribution: bool,
73    pub aggregate_only: bool,
74    pub no_aggregation: bool,
75    pub aggregation_method: Option<String>,
76    pub min_problematic: Option<usize>,
77    pub no_god_object: bool,
78    pub max_files: Option<usize>,
79    pub debug_call_graph: bool,
80    pub trace_functions: Option<Vec<String>>,
81    pub call_graph_stats_only: bool,
82    pub debug_format: cli::DebugFormatArg,
83    pub validate_call_graph: bool,
84    pub ast_functional_analysis: bool,
85    pub functional_analysis_profile: Option<cli::FunctionalAnalysisProfile>,
86    pub no_tui: bool,
87    pub show_filter_stats: bool,
88    pub reference_time: chrono::DateTime<chrono::Utc>,
89
90    // Phantom type to encode validation state
91    _state: PhantomData<State>,
92}
93
94impl AnalyzeConfig<Unvalidated> {
95    /// Create a new unvalidated configuration
96    #[allow(clippy::too_many_arguments)]
97    pub fn new(
98        path: PathBuf,
99        format: cli::OutputFormat,
100        output: Option<PathBuf>,
101        threshold_complexity: u32,
102        threshold_duplication: usize,
103        languages: Option<Vec<String>>,
104        coverage_file: Option<PathBuf>,
105        enable_context: bool,
106        context_providers: Option<Vec<String>>,
107        disable_context: Option<Vec<String>>,
108        top: Option<usize>,
109        tail: Option<usize>,
110        summary: bool,
111        semantic_off: bool,
112        verbosity: u8,
113        verbose_macro_warnings: bool,
114        show_macro_stats: bool,
115        min_priority: Option<String>,
116        min_score: Option<f64>,
117        filter_categories: Option<Vec<String>>,
118        no_context_aware: bool,
119        threshold_preset: Option<cli::ThresholdPreset>,
120        _formatting_config: FormattingConfig,
121        parallel: bool,
122        jobs: usize,
123        multi_pass: bool,
124        show_attribution: bool,
125        aggregate_only: bool,
126        no_aggregation: bool,
127        aggregation_method: Option<String>,
128        min_problematic: Option<usize>,
129        no_god_object: bool,
130        max_files: Option<usize>,
131        debug_call_graph: bool,
132        trace_functions: Option<Vec<String>>,
133        call_graph_stats_only: bool,
134        debug_format: cli::DebugFormatArg,
135        validate_call_graph: bool,
136        ast_functional_analysis: bool,
137        functional_analysis_profile: Option<cli::FunctionalAnalysisProfile>,
138        no_tui: bool,
139        show_filter_stats: bool,
140        reference_time: chrono::DateTime<chrono::Utc>,
141    ) -> Self {
142        AnalyzeConfig {
143            path,
144            format,
145            output,
146            threshold_complexity,
147            threshold_duplication,
148            languages,
149            coverage_file,
150            enable_context,
151            context_providers,
152            disable_context,
153            top,
154            tail,
155            summary,
156            semantic_off,
157            verbosity,
158            verbose_macro_warnings,
159            show_macro_stats,
160            min_priority,
161            min_score,
162            filter_categories,
163            no_context_aware,
164            threshold_preset,
165            _formatting_config,
166            parallel,
167            jobs,
168            multi_pass,
169            show_attribution,
170            aggregate_only,
171            no_aggregation,
172            aggregation_method,
173            min_problematic,
174            no_god_object,
175            max_files,
176            debug_call_graph,
177            trace_functions,
178            call_graph_stats_only,
179            debug_format,
180            validate_call_graph,
181            ast_functional_analysis,
182            functional_analysis_profile,
183            no_tui,
184            show_filter_stats,
185            reference_time,
186            _state: PhantomData,
187        }
188    }
189
190    /// Validate configuration and transition to validated state
191    ///
192    /// This method performs validation checks and returns a validated
193    /// configuration on success. The return type guarantees at compile-time
194    /// that only validated config can be executed.
195    pub fn validate(self) -> Result<AnalyzeConfig<Validated>> {
196        // Validate path exists
197        if !self.path.exists() {
198            anyhow::bail!("Path does not exist: {}", self.path.display());
199        }
200
201        // Validate thresholds are reasonable
202        if self.threshold_complexity == 0 {
203            anyhow::bail!("Complexity threshold must be greater than 0");
204        }
205
206        // Validate output path if specified
207        if let Some(ref output) = self.output
208            && let Some(parent) = output.parent()
209        {
210            // Empty parent means current directory (e.g., "file.json" has parent "")
211            // which is always valid
212            if !parent.as_os_str().is_empty() && !parent.exists() {
213                anyhow::bail!("Output directory does not exist: {}", parent.display());
214            }
215        }
216
217        // Validate coverage file if specified
218        if let Some(ref coverage_file) = self.coverage_file
219            && !coverage_file.exists()
220        {
221            anyhow::bail!("Coverage file does not exist: {}", coverage_file.display());
222        }
223
224        // Validate job count
225        if self.jobs == 0 && self.parallel {
226            anyhow::bail!("Job count must be greater than 0 when parallel is enabled");
227        }
228
229        // Validate min_score if specified
230        if let Some(min_score) = self.min_score
231            && !(0.0..=100.0).contains(&min_score)
232        {
233            anyhow::bail!("Minimum score must be between 0.0 and 100.0");
234        }
235
236        if let Some(ref min_priority) = self.min_priority
237            && !matches!(
238                min_priority.to_ascii_lowercase().as_str(),
239                "low" | "medium" | "high" | "critical"
240            )
241        {
242            anyhow::bail!("Minimum priority must be one of: low, medium, high, critical");
243        }
244
245        // Validation successful - transition to validated state
246        Ok(AnalyzeConfig {
247            path: self.path,
248            format: self.format,
249            output: self.output,
250            threshold_complexity: self.threshold_complexity,
251            threshold_duplication: self.threshold_duplication,
252            languages: self.languages,
253            coverage_file: self.coverage_file,
254            enable_context: self.enable_context,
255            context_providers: self.context_providers,
256            disable_context: self.disable_context,
257            top: self.top,
258            tail: self.tail,
259            summary: self.summary,
260            semantic_off: self.semantic_off,
261            verbosity: self.verbosity,
262            verbose_macro_warnings: self.verbose_macro_warnings,
263            show_macro_stats: self.show_macro_stats,
264            min_priority: self.min_priority,
265            min_score: self.min_score,
266            filter_categories: self.filter_categories,
267            no_context_aware: self.no_context_aware,
268            threshold_preset: self.threshold_preset,
269            _formatting_config: self._formatting_config,
270            parallel: self.parallel,
271            jobs: self.jobs,
272            multi_pass: self.multi_pass,
273            show_attribution: self.show_attribution,
274            aggregate_only: self.aggregate_only,
275            no_aggregation: self.no_aggregation,
276            aggregation_method: self.aggregation_method,
277            min_problematic: self.min_problematic,
278            no_god_object: self.no_god_object,
279            max_files: self.max_files,
280            debug_call_graph: self.debug_call_graph,
281            trace_functions: self.trace_functions,
282            call_graph_stats_only: self.call_graph_stats_only,
283            debug_format: self.debug_format,
284            validate_call_graph: self.validate_call_graph,
285            ast_functional_analysis: self.ast_functional_analysis,
286            functional_analysis_profile: self.functional_analysis_profile,
287            no_tui: self.no_tui,
288            show_filter_stats: self.show_filter_stats,
289            reference_time: self.reference_time,
290            _state: PhantomData,
291        })
292    }
293}
294
295impl AnalyzeConfig<Validated> {
296    /// Execute the analysis with validated configuration
297    ///
298    /// This method is only available on validated configs, providing
299    /// compile-time guarantee that validation has occurred.
300    pub fn execute(self) -> Result<()> {
301        // Convert to old-style config for backward compatibility
302        let old_config = super::analyze::AnalyzeConfig {
303            path: self.path,
304            format: self.format,
305            output: self.output,
306            threshold_complexity: self.threshold_complexity,
307            threshold_duplication: self.threshold_duplication,
308            languages: self.languages,
309            coverage_file: self.coverage_file,
310            enable_context: self.enable_context,
311            context_providers: self.context_providers,
312            disable_context: self.disable_context,
313            top: self.top,
314            tail: self.tail,
315            summary: self.summary,
316            semantic_off: self.semantic_off,
317            verbosity: self.verbosity,
318            verbose_macro_warnings: self.verbose_macro_warnings,
319            show_macro_stats: self.show_macro_stats,
320            min_priority: self.min_priority,
321            min_score: self.min_score,
322            filter_categories: self.filter_categories,
323            no_context_aware: self.no_context_aware,
324            threshold_preset: self.threshold_preset,
325            _formatting_config: self._formatting_config,
326            parallel: self.parallel,
327            jobs: self.jobs,
328            multi_pass: self.multi_pass,
329            show_attribution: self.show_attribution,
330            aggregate_only: self.aggregate_only,
331            no_aggregation: self.no_aggregation,
332            aggregation_method: self.aggregation_method,
333            min_problematic: self.min_problematic,
334            no_god_object: self.no_god_object,
335            max_files: self.max_files,
336            debug_call_graph: self.debug_call_graph,
337            trace_functions: self.trace_functions,
338            call_graph_stats_only: self.call_graph_stats_only,
339            debug_format: self.debug_format,
340            validate_call_graph: self.validate_call_graph,
341            ast_functional_analysis: self.ast_functional_analysis,
342            functional_analysis_profile: self.functional_analysis_profile,
343            no_tui: self.no_tui,
344            show_filter_stats: self.show_filter_stats,
345            reference_time: self.reference_time,
346        };
347
348        super::analyze::handle_analyze(old_config).context("Analysis execution failed")
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn test_config(
357        path: PathBuf,
358        threshold_complexity: u32,
359        min_priority: Option<String>,
360        min_score: Option<f64>,
361    ) -> AnalyzeConfig<Unvalidated> {
362        AnalyzeConfig::new(
363            path,
364            cli::OutputFormat::Terminal,
365            None,
366            threshold_complexity,
367            5,
368            None,
369            None,
370            false,
371            None,
372            None,
373            None,
374            None,
375            false,
376            false,
377            0,
378            false,
379            false,
380            min_priority,
381            min_score,
382            None,
383            false,
384            None,
385            FormattingConfig::default(),
386            true,
387            4,
388            true,
389            false,
390            false,
391            false,
392            None,
393            None,
394            false,
395            None,
396            false,
397            None,
398            false,
399            cli::DebugFormatArg::Text,
400            false,
401            false,
402            None,
403            false,
404            false,
405            chrono::Utc::now(),
406        )
407    }
408
409    #[test]
410    fn test_validation_transition() {
411        let config = test_config(PathBuf::from("."), 10, None, None);
412
413        // Validate should succeed for current directory
414        let validated = config.validate();
415        assert!(validated.is_ok());
416    }
417
418    #[test]
419    fn test_validation_fails_for_nonexistent_path() {
420        let config = test_config(
421            PathBuf::from("/nonexistent/path/that/should/not/exist"),
422            10,
423            None,
424            None,
425        );
426
427        // Validation should fail
428        let result = config.validate();
429        assert!(result.is_err());
430    }
431
432    #[test]
433    fn test_validation_fails_for_zero_complexity() {
434        let config = test_config(PathBuf::from("."), 0, None, None);
435
436        let result = config.validate();
437        assert!(result.is_err());
438    }
439
440    #[test]
441    fn test_validation_fails_for_invalid_score() {
442        let config = test_config(PathBuf::from("."), 10, None, Some(150.0));
443
444        let result = config.validate();
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn test_validation_fails_for_invalid_min_priority() {
450        let config = test_config(PathBuf::from("."), 10, Some("urgent".to_string()), None);
451
452        let result = config.validate();
453        assert!(result.is_err());
454    }
455}