rust_diff_analyzer/
config.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use std::{collections::HashSet, fs, path::Path};
5
6use masterror::AppError;
7use serde::{Deserialize, Serialize};
8
9use crate::error::{ConfigError, ConfigValidationError, FileReadError};
10
11/// Classification configuration
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ClassificationConfig {
14    /// Features that indicate test code
15    #[serde(default = "default_test_features")]
16    pub test_features: Vec<String>,
17    /// Paths that contain test code
18    #[serde(default = "default_test_paths")]
19    pub test_paths: Vec<String>,
20    /// Paths to ignore completely
21    #[serde(default)]
22    pub ignore_paths: Vec<String>,
23}
24
25impl Default for ClassificationConfig {
26    fn default() -> Self {
27        Self {
28            test_features: default_test_features(),
29            test_paths: default_test_paths(),
30            ignore_paths: Vec::new(),
31        }
32    }
33}
34
35fn default_test_features() -> Vec<String> {
36    vec![
37        "test-utils".to_string(),
38        "testing".to_string(),
39        "mock".to_string(),
40    ]
41}
42
43fn default_test_paths() -> Vec<String> {
44    vec![
45        "tests/".to_string(),
46        "benches/".to_string(),
47        "examples/".to_string(),
48    ]
49}
50
51/// Weight configuration for scoring
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct WeightsConfig {
54    /// Weight for public functions
55    #[serde(default = "default_public_function_weight")]
56    pub public_function: usize,
57    /// Weight for private functions
58    #[serde(default = "default_private_function_weight")]
59    pub private_function: usize,
60    /// Weight for public structs
61    #[serde(default = "default_public_struct_weight")]
62    pub public_struct: usize,
63    /// Weight for private structs
64    #[serde(default = "default_private_struct_weight")]
65    pub private_struct: usize,
66    /// Weight for impl blocks
67    #[serde(default = "default_impl_weight")]
68    pub impl_block: usize,
69    /// Weight for trait definitions
70    #[serde(default = "default_trait_weight")]
71    pub trait_definition: usize,
72    /// Weight for const/static items
73    #[serde(default = "default_const_weight")]
74    pub const_static: usize,
75}
76
77impl Default for WeightsConfig {
78    fn default() -> Self {
79        Self {
80            public_function: default_public_function_weight(),
81            private_function: default_private_function_weight(),
82            public_struct: default_public_struct_weight(),
83            private_struct: default_private_struct_weight(),
84            impl_block: default_impl_weight(),
85            trait_definition: default_trait_weight(),
86            const_static: default_const_weight(),
87        }
88    }
89}
90
91fn default_public_function_weight() -> usize {
92    3
93}
94
95fn default_private_function_weight() -> usize {
96    1
97}
98
99fn default_public_struct_weight() -> usize {
100    3
101}
102
103fn default_private_struct_weight() -> usize {
104    1
105}
106
107fn default_impl_weight() -> usize {
108    2
109}
110
111fn default_trait_weight() -> usize {
112    4
113}
114
115fn default_const_weight() -> usize {
116    1
117}
118
119/// Per-type limit configuration
120///
121/// All fields are optional. When set, the analyzer will check that the number
122/// of changed units of each type does not exceed the specified limit.
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct PerTypeLimits {
125    /// Maximum number of functions
126    pub functions: Option<usize>,
127    /// Maximum number of structs
128    pub structs: Option<usize>,
129    /// Maximum number of enums
130    pub enums: Option<usize>,
131    /// Maximum number of traits
132    pub traits: Option<usize>,
133    /// Maximum number of impl blocks
134    pub impl_blocks: Option<usize>,
135    /// Maximum number of constants
136    pub consts: Option<usize>,
137    /// Maximum number of statics
138    pub statics: Option<usize>,
139    /// Maximum number of type aliases
140    pub type_aliases: Option<usize>,
141    /// Maximum number of macros
142    pub macros: Option<usize>,
143    /// Maximum number of modules
144    pub modules: Option<usize>,
145}
146
147/// Limit configuration
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct LimitsConfig {
150    /// Maximum number of production units allowed
151    #[serde(default = "default_max_prod_units")]
152    pub max_prod_units: usize,
153    /// Maximum weighted score allowed
154    #[serde(default = "default_max_weighted_score")]
155    pub max_weighted_score: usize,
156    /// Maximum number of production lines added
157    #[serde(default)]
158    pub max_prod_lines: Option<usize>,
159    /// Per-type limits for fine-grained control
160    #[serde(default)]
161    pub per_type: Option<PerTypeLimits>,
162    /// Whether to fail when limits are exceeded
163    #[serde(default = "default_fail_on_exceed")]
164    pub fail_on_exceed: bool,
165}
166
167impl Default for LimitsConfig {
168    fn default() -> Self {
169        Self {
170            max_prod_units: default_max_prod_units(),
171            max_weighted_score: default_max_weighted_score(),
172            max_prod_lines: None,
173            per_type: None,
174            fail_on_exceed: default_fail_on_exceed(),
175        }
176    }
177}
178
179fn default_max_prod_units() -> usize {
180    30
181}
182
183fn default_max_weighted_score() -> usize {
184    100
185}
186
187fn default_fail_on_exceed() -> bool {
188    true
189}
190
191/// Output format
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
193#[serde(rename_all = "lowercase")]
194pub enum OutputFormat {
195    /// GitHub Actions output format
196    #[default]
197    Github,
198    /// JSON output format
199    Json,
200    /// Human-readable output format
201    Human,
202    /// Markdown comment format for PR comments
203    Comment,
204}
205
206/// Output configuration
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct OutputConfig {
209    /// Output format to use
210    #[serde(default)]
211    pub format: OutputFormat,
212    /// Whether to include detailed change information
213    #[serde(default = "default_include_details")]
214    pub include_details: bool,
215}
216
217impl Default for OutputConfig {
218    fn default() -> Self {
219        Self {
220            format: OutputFormat::default(),
221            include_details: default_include_details(),
222        }
223    }
224}
225
226fn default_include_details() -> bool {
227    true
228}
229
230/// Main configuration structure
231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
232pub struct Config {
233    /// Classification settings
234    #[serde(default)]
235    pub classification: ClassificationConfig,
236    /// Weight settings
237    #[serde(default)]
238    pub weights: WeightsConfig,
239    /// Limit settings
240    #[serde(default)]
241    pub limits: LimitsConfig,
242    /// Output settings
243    #[serde(default)]
244    pub output: OutputConfig,
245}
246
247impl Config {
248    /// Loads configuration from a TOML file
249    ///
250    /// # Arguments
251    ///
252    /// * `path` - Path to the configuration file
253    ///
254    /// # Returns
255    ///
256    /// Loaded configuration or error
257    ///
258    /// # Errors
259    ///
260    /// Returns error if file cannot be read or parsed
261    ///
262    /// # Examples
263    ///
264    /// ```no_run
265    /// use std::path::Path;
266    ///
267    /// use rust_diff_analyzer::Config;
268    ///
269    /// let config = Config::from_file(Path::new(".rust-diff-analyzer.toml"));
270    /// ```
271    pub fn from_file(path: &Path) -> Result<Self, AppError> {
272        let content =
273            fs::read_to_string(path).map_err(|e| AppError::from(FileReadError::new(path, e)))?;
274
275        toml::from_str(&content).map_err(|e| AppError::from(ConfigError::new(path, e.to_string())))
276    }
277
278    /// Validates configuration values
279    ///
280    /// # Returns
281    ///
282    /// Ok if valid, error otherwise
283    ///
284    /// # Errors
285    ///
286    /// Returns error if any configuration value is invalid
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use rust_diff_analyzer::Config;
292    ///
293    /// let config = Config::default();
294    /// assert!(config.validate().is_ok());
295    /// ```
296    pub fn validate(&self) -> Result<(), AppError> {
297        if self.limits.max_prod_units == 0 {
298            return Err(ConfigValidationError {
299                field: "limits.max_prod_units".to_string(),
300                message: "must be greater than 0".to_string(),
301            }
302            .into());
303        }
304
305        if self.limits.max_weighted_score == 0 {
306            return Err(ConfigValidationError {
307                field: "limits.max_weighted_score".to_string(),
308                message: "must be greater than 0".to_string(),
309            }
310            .into());
311        }
312
313        Ok(())
314    }
315
316    /// Returns set of test feature names
317    ///
318    /// # Returns
319    ///
320    /// HashSet of test feature names
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use rust_diff_analyzer::Config;
326    ///
327    /// let config = Config::default();
328    /// let features = config.test_features_set();
329    /// assert!(features.contains("test-utils"));
330    /// ```
331    pub fn test_features_set(&self) -> HashSet<&str> {
332        self.classification
333            .test_features
334            .iter()
335            .map(|s| s.as_str())
336            .collect()
337    }
338
339    /// Checks if a path should be ignored
340    ///
341    /// # Arguments
342    ///
343    /// * `path` - Path to check
344    ///
345    /// # Returns
346    ///
347    /// `true` if path should be ignored
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use std::path::Path;
353    ///
354    /// use rust_diff_analyzer::Config;
355    ///
356    /// let config = Config::default();
357    /// assert!(!config.should_ignore(Path::new("src/lib.rs")));
358    /// ```
359    pub fn should_ignore(&self, path: &Path) -> bool {
360        let path_str = path.to_string_lossy();
361        self.classification
362            .ignore_paths
363            .iter()
364            .any(|p| path_str.contains(p))
365    }
366
367    /// Checks if a path is in a test directory
368    ///
369    /// # Arguments
370    ///
371    /// * `path` - Path to check
372    ///
373    /// # Returns
374    ///
375    /// `true` if path is in a test directory
376    ///
377    /// # Examples
378    ///
379    /// ```
380    /// use std::path::Path;
381    ///
382    /// use rust_diff_analyzer::Config;
383    ///
384    /// let config = Config::default();
385    /// assert!(config.is_test_path(Path::new("tests/integration.rs")));
386    /// assert!(!config.is_test_path(Path::new("src/lib.rs")));
387    /// ```
388    pub fn is_test_path(&self, path: &Path) -> bool {
389        let path_str = path.to_string_lossy();
390        self.classification
391            .test_paths
392            .iter()
393            .any(|p| path_str.contains(p))
394    }
395
396    /// Checks if path is a build script
397    ///
398    /// # Arguments
399    ///
400    /// * `path` - Path to check
401    ///
402    /// # Returns
403    ///
404    /// `true` if path is build.rs
405    ///
406    /// # Examples
407    ///
408    /// ```
409    /// use std::path::Path;
410    ///
411    /// use rust_diff_analyzer::Config;
412    ///
413    /// let config = Config::default();
414    /// assert!(config.is_build_script(Path::new("build.rs")));
415    /// assert!(!config.is_build_script(Path::new("src/lib.rs")));
416    /// ```
417    pub fn is_build_script(&self, path: &Path) -> bool {
418        path.file_name().map(|n| n == "build.rs").unwrap_or(false)
419    }
420}
421
422/// Builder for creating configurations programmatically
423#[derive(Debug, Default)]
424pub struct ConfigBuilder {
425    config: Config,
426}
427
428impl ConfigBuilder {
429    /// Creates a new configuration builder
430    ///
431    /// # Returns
432    ///
433    /// A new ConfigBuilder with default values
434    ///
435    /// # Examples
436    ///
437    /// ```
438    /// use rust_diff_analyzer::config::ConfigBuilder;
439    ///
440    /// let builder = ConfigBuilder::new();
441    /// ```
442    pub fn new() -> Self {
443        Self::default()
444    }
445
446    /// Sets the output format
447    ///
448    /// # Arguments
449    ///
450    /// * `format` - Output format to use
451    ///
452    /// # Returns
453    ///
454    /// Self for method chaining
455    ///
456    /// # Examples
457    ///
458    /// ```
459    /// use rust_diff_analyzer::config::{ConfigBuilder, OutputFormat};
460    ///
461    /// let config = ConfigBuilder::new()
462    ///     .output_format(OutputFormat::Json)
463    ///     .build();
464    /// ```
465    pub fn output_format(mut self, format: OutputFormat) -> Self {
466        self.config.output.format = format;
467        self
468    }
469
470    /// Sets the maximum production units limit
471    ///
472    /// # Arguments
473    ///
474    /// * `limit` - Maximum number of production units
475    ///
476    /// # Returns
477    ///
478    /// Self for method chaining
479    ///
480    /// # Examples
481    ///
482    /// ```
483    /// use rust_diff_analyzer::config::ConfigBuilder;
484    ///
485    /// let config = ConfigBuilder::new().max_prod_units(50).build();
486    /// ```
487    pub fn max_prod_units(mut self, limit: usize) -> Self {
488        self.config.limits.max_prod_units = limit;
489        self
490    }
491
492    /// Sets the maximum weighted score limit
493    ///
494    /// # Arguments
495    ///
496    /// * `limit` - Maximum weighted score
497    ///
498    /// # Returns
499    ///
500    /// Self for method chaining
501    ///
502    /// # Examples
503    ///
504    /// ```
505    /// use rust_diff_analyzer::config::ConfigBuilder;
506    ///
507    /// let config = ConfigBuilder::new().max_weighted_score(200).build();
508    /// ```
509    pub fn max_weighted_score(mut self, limit: usize) -> Self {
510        self.config.limits.max_weighted_score = limit;
511        self
512    }
513
514    /// Sets whether to fail on exceeded limits
515    ///
516    /// # Arguments
517    ///
518    /// * `fail` - Whether to fail on exceeded limits
519    ///
520    /// # Returns
521    ///
522    /// Self for method chaining
523    ///
524    /// # Examples
525    ///
526    /// ```
527    /// use rust_diff_analyzer::config::ConfigBuilder;
528    ///
529    /// let config = ConfigBuilder::new().fail_on_exceed(false).build();
530    /// ```
531    pub fn fail_on_exceed(mut self, fail: bool) -> Self {
532        self.config.limits.fail_on_exceed = fail;
533        self
534    }
535
536    /// Sets the maximum production lines limit
537    ///
538    /// # Arguments
539    ///
540    /// * `limit` - Maximum number of production lines added
541    ///
542    /// # Returns
543    ///
544    /// Self for method chaining
545    ///
546    /// # Examples
547    ///
548    /// ```
549    /// use rust_diff_analyzer::config::ConfigBuilder;
550    ///
551    /// let config = ConfigBuilder::new().max_prod_lines(200).build();
552    /// ```
553    pub fn max_prod_lines(mut self, limit: usize) -> Self {
554        self.config.limits.max_prod_lines = Some(limit);
555        self
556    }
557
558    /// Sets per-type limits
559    ///
560    /// # Arguments
561    ///
562    /// * `limits` - Per-type limit configuration
563    ///
564    /// # Returns
565    ///
566    /// Self for method chaining
567    ///
568    /// # Examples
569    ///
570    /// ```
571    /// use rust_diff_analyzer::config::{ConfigBuilder, PerTypeLimits};
572    ///
573    /// let per_type = PerTypeLimits {
574    ///     functions: Some(5),
575    ///     structs: Some(3),
576    ///     ..Default::default()
577    /// };
578    /// let config = ConfigBuilder::new().per_type_limits(per_type).build();
579    /// ```
580    pub fn per_type_limits(mut self, limits: PerTypeLimits) -> Self {
581        self.config.limits.per_type = Some(limits);
582        self
583    }
584
585    /// Adds a test feature
586    ///
587    /// # Arguments
588    ///
589    /// * `feature` - Feature name to add
590    ///
591    /// # Returns
592    ///
593    /// Self for method chaining
594    ///
595    /// # Examples
596    ///
597    /// ```
598    /// use rust_diff_analyzer::config::ConfigBuilder;
599    ///
600    /// let config = ConfigBuilder::new()
601    ///     .add_test_feature("my-test-feature")
602    ///     .build();
603    /// ```
604    pub fn add_test_feature(mut self, feature: &str) -> Self {
605        self.config
606            .classification
607            .test_features
608            .push(feature.to_string());
609        self
610    }
611
612    /// Adds a path to ignore
613    ///
614    /// # Arguments
615    ///
616    /// * `path` - Path pattern to ignore
617    ///
618    /// # Returns
619    ///
620    /// Self for method chaining
621    ///
622    /// # Examples
623    ///
624    /// ```
625    /// use rust_diff_analyzer::config::ConfigBuilder;
626    ///
627    /// let config = ConfigBuilder::new().add_ignore_path("fixtures/").build();
628    /// ```
629    pub fn add_ignore_path(mut self, path: &str) -> Self {
630        self.config
631            .classification
632            .ignore_paths
633            .push(path.to_string());
634        self
635    }
636
637    /// Builds the configuration
638    ///
639    /// # Returns
640    ///
641    /// The built Config
642    ///
643    /// # Examples
644    ///
645    /// ```
646    /// use rust_diff_analyzer::config::ConfigBuilder;
647    ///
648    /// let config = ConfigBuilder::new().build();
649    /// ```
650    pub fn build(self) -> Config {
651        self.config
652    }
653}