Skip to main content

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