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        let mut seen = std::collections::HashSet::new();
328        for author in &self.classification.ignored_authors {
329            if author.is_empty() {
330                return Err(ConfigValidationError {
331                    field: "classification.ignored_authors".to_string(),
332                    message: "author cannot be empty".to_string(),
333                }
334                .into());
335            }
336            if !seen.insert(author) {
337                return Err(ConfigValidationError {
338                    field: "classification.ignored_authors".to_string(),
339                    message: format!("duplicate author: {}", author),
340                }
341                .into());
342            }
343        }
344
345        Ok(())
346    }
347
348    /// Returns set of test feature names
349    ///
350    /// # Returns
351    ///
352    /// HashSet of test feature names
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// use rust_diff_analyzer::Config;
358    ///
359    /// let config = Config::default();
360    /// let features = config.test_features_set();
361    /// assert!(features.contains("test-utils"));
362    /// ```
363    pub fn test_features_set(&self) -> HashSet<&str> {
364        self.classification
365            .test_features
366            .iter()
367            .map(|s| s.as_str())
368            .collect()
369    }
370
371    /// Checks if a path should be ignored
372    ///
373    /// Patterns match whole path components: `src/gen` ignores files under
374    /// `src/gen/` but not `src/generic.rs`.
375    ///
376    /// # Arguments
377    ///
378    /// * `path` - Path to check
379    ///
380    /// # Returns
381    ///
382    /// `true` if path should be ignored
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// use std::path::Path;
388    ///
389    /// use rust_diff_analyzer::Config;
390    ///
391    /// let config = Config::default();
392    /// assert!(!config.should_ignore(Path::new("src/lib.rs")));
393    /// ```
394    pub fn should_ignore(&self, path: &Path) -> bool {
395        self.matched_ignore_pattern(path).is_some()
396    }
397
398    /// Returns the first ignore pattern matching the path, if any
399    ///
400    /// # Arguments
401    ///
402    /// * `path` - Path to check
403    ///
404    /// # Returns
405    ///
406    /// The matching pattern or `None`
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// use std::path::Path;
412    ///
413    /// use rust_diff_analyzer::Config;
414    ///
415    /// let mut config = Config::default();
416    /// config
417    ///     .classification
418    ///     .ignore_paths
419    ///     .push("generated/".to_string());
420    /// assert_eq!(
421    ///     config.matched_ignore_pattern(Path::new("generated/api.rs")),
422    ///     Some("generated/")
423    /// );
424    /// ```
425    pub fn matched_ignore_pattern(&self, path: &Path) -> Option<&str> {
426        self.classification
427            .ignore_paths
428            .iter()
429            .find(|p| crate::classifier::path_classifier::path_matches_pattern(path, p))
430            .map(|s| s.as_str())
431    }
432
433    /// Checks if an author should be ignored
434    ///
435    /// # Arguments
436    ///
437    /// * `author` - Author name to check
438    ///
439    /// # Returns
440    ///
441    /// `true` if author is in the ignore list
442    ///
443    /// # Examples
444    ///
445    /// ```
446    /// use rust_diff_analyzer::Config;
447    ///
448    /// let mut config = Config::default();
449    /// config
450    ///     .classification
451    ///     .ignored_authors
452    ///     .push("dependabot[bot]".to_string());
453    /// assert!(config.should_ignore_author("dependabot[bot]"));
454    /// assert!(!config.should_ignore_author("developer"));
455    /// ```
456    pub fn should_ignore_author(&self, author: &str) -> bool {
457        self.classification
458            .ignored_authors
459            .iter()
460            .any(|ignored| author.contains(ignored) || ignored == author)
461    }
462
463    /// Checks if a commit should be ignored based on author
464    ///
465    /// This method checks if the given author matches any of the ignored authors.
466    /// It performs a substring match, so "github-actions\[bot]" will match
467    /// "github-actions\[bot]" and "github-actions\[bot]@users.noreply.github.com".
468    ///
469    /// # Arguments
470    ///
471    /// * `author` - Author string from git commit
472    ///
473    /// # Returns
474    ///
475    /// `true` if the commit author should be ignored
476    ///
477    /// # Examples
478    ///
479    /// ```
480    /// use rust_diff_analyzer::Config;
481    ///
482    /// let mut config = Config::default();
483    /// config
484    ///     .classification
485    ///     .ignored_authors
486    ///     .push("dependabot".to_string());
487    /// assert!(config.should_ignore_author("dependabot[bot]"));
488    /// ```
489    pub fn should_ignore_commit(&self, author: &str) -> bool {
490        self.should_ignore_author(author)
491    }
492
493    /// Checks if a path is in a test directory
494    ///
495    /// # Arguments
496    ///
497    /// * `path` - Path to check
498    ///
499    /// # Returns
500    ///
501    /// `true` if path is in a test directory
502    ///
503    /// # Examples
504    ///
505    /// ```
506    /// use std::path::Path;
507    ///
508    /// use rust_diff_analyzer::Config;
509    ///
510    /// let config = Config::default();
511    /// assert!(config.is_test_path(Path::new("tests/integration.rs")));
512    /// assert!(!config.is_test_path(Path::new("src/lib.rs")));
513    /// ```
514    pub fn is_test_path(&self, path: &Path) -> bool {
515        self.classification
516            .test_paths
517            .iter()
518            .any(|p| crate::classifier::path_classifier::path_matches_pattern(path, p))
519    }
520
521    /// Checks if path is a build script
522    ///
523    /// # Arguments
524    ///
525    /// * `path` - Path to check
526    ///
527    /// # Returns
528    ///
529    /// `true` if path is build.rs
530    ///
531    /// # Examples
532    ///
533    /// ```
534    /// use std::path::Path;
535    ///
536    /// use rust_diff_analyzer::Config;
537    ///
538    /// let config = Config::default();
539    /// assert!(config.is_build_script(Path::new("build.rs")));
540    /// assert!(!config.is_build_script(Path::new("src/lib.rs")));
541    /// ```
542    pub fn is_build_script(&self, path: &Path) -> bool {
543        path.file_name().map(|n| n == "build.rs").unwrap_or(false)
544    }
545}
546
547/// Builder for creating configurations programmatically
548#[derive(Debug, Default)]
549pub struct ConfigBuilder {
550    config: Config,
551}
552
553impl ConfigBuilder {
554    /// Creates a new configuration builder
555    ///
556    /// # Returns
557    ///
558    /// A new ConfigBuilder with default values
559    ///
560    /// # Examples
561    ///
562    /// ```
563    /// use rust_diff_analyzer::config::ConfigBuilder;
564    ///
565    /// let builder = ConfigBuilder::new();
566    /// ```
567    pub fn new() -> Self {
568        Self::default()
569    }
570
571    /// Sets the output format
572    ///
573    /// # Arguments
574    ///
575    /// * `format` - Output format to use
576    ///
577    /// # Returns
578    ///
579    /// Self for method chaining
580    ///
581    /// # Examples
582    ///
583    /// ```
584    /// use rust_diff_analyzer::config::{ConfigBuilder, OutputFormat};
585    ///
586    /// let config = ConfigBuilder::new()
587    ///     .output_format(OutputFormat::Json)
588    ///     .build();
589    /// ```
590    pub fn output_format(mut self, format: OutputFormat) -> Self {
591        self.config.output.format = format;
592        self
593    }
594
595    /// Sets the maximum production units limit
596    ///
597    /// # Arguments
598    ///
599    /// * `limit` - Maximum number of production units
600    ///
601    /// # Returns
602    ///
603    /// Self for method chaining
604    ///
605    /// # Examples
606    ///
607    /// ```
608    /// use rust_diff_analyzer::config::ConfigBuilder;
609    ///
610    /// let config = ConfigBuilder::new().max_prod_units(50).build();
611    /// ```
612    pub fn max_prod_units(mut self, limit: usize) -> Self {
613        self.config.limits.max_prod_units = limit;
614        self
615    }
616
617    /// Sets the maximum weighted score limit
618    ///
619    /// # Arguments
620    ///
621    /// * `limit` - Maximum weighted score
622    ///
623    /// # Returns
624    ///
625    /// Self for method chaining
626    ///
627    /// # Examples
628    ///
629    /// ```
630    /// use rust_diff_analyzer::config::ConfigBuilder;
631    ///
632    /// let config = ConfigBuilder::new().max_weighted_score(200).build();
633    /// ```
634    pub fn max_weighted_score(mut self, limit: usize) -> Self {
635        self.config.limits.max_weighted_score = limit;
636        self
637    }
638
639    /// Sets whether to fail on exceeded limits
640    ///
641    /// # Arguments
642    ///
643    /// * `fail` - Whether to fail on exceeded limits
644    ///
645    /// # Returns
646    ///
647    /// Self for method chaining
648    ///
649    /// # Examples
650    ///
651    /// ```
652    /// use rust_diff_analyzer::config::ConfigBuilder;
653    ///
654    /// let config = ConfigBuilder::new().fail_on_exceed(false).build();
655    /// ```
656    pub fn fail_on_exceed(mut self, fail: bool) -> Self {
657        self.config.limits.fail_on_exceed = fail;
658        self
659    }
660
661    /// Sets the maximum production lines limit
662    ///
663    /// # Arguments
664    ///
665    /// * `limit` - Maximum number of production lines added
666    ///
667    /// # Returns
668    ///
669    /// Self for method chaining
670    ///
671    /// # Examples
672    ///
673    /// ```
674    /// use rust_diff_analyzer::config::ConfigBuilder;
675    ///
676    /// let config = ConfigBuilder::new().max_prod_lines(200).build();
677    /// ```
678    pub fn max_prod_lines(mut self, limit: usize) -> Self {
679        self.config.limits.max_prod_lines = Some(limit);
680        self
681    }
682
683    /// Sets per-type limits
684    ///
685    /// # Arguments
686    ///
687    /// * `limits` - Per-type limit configuration
688    ///
689    /// # Returns
690    ///
691    /// Self for method chaining
692    ///
693    /// # Examples
694    ///
695    /// ```
696    /// use rust_diff_analyzer::config::{ConfigBuilder, PerTypeLimits};
697    ///
698    /// let per_type = PerTypeLimits {
699    ///     functions: Some(5),
700    ///     structs: Some(3),
701    ///     ..Default::default()
702    /// };
703    /// let config = ConfigBuilder::new().per_type_limits(per_type).build();
704    /// ```
705    pub fn per_type_limits(mut self, limits: PerTypeLimits) -> Self {
706        self.config.limits.per_type = Some(limits);
707        self
708    }
709
710    /// Adds a test feature
711    ///
712    /// # Arguments
713    ///
714    /// * `feature` - Feature name to add
715    ///
716    /// # Returns
717    ///
718    /// Self for method chaining
719    ///
720    /// # Examples
721    ///
722    /// ```
723    /// use rust_diff_analyzer::config::ConfigBuilder;
724    ///
725    /// let config = ConfigBuilder::new()
726    ///     .add_test_feature("my-test-feature")
727    ///     .build();
728    /// ```
729    pub fn add_test_feature(mut self, feature: &str) -> Self {
730        self.config
731            .classification
732            .test_features
733            .push(feature.to_string());
734        self
735    }
736
737    /// Adds a path to ignore
738    ///
739    /// # Arguments
740    ///
741    /// * `path` - Path pattern to ignore
742    ///
743    /// # Returns
744    ///
745    /// Self for method chaining
746    ///
747    /// # Examples
748    ///
749    /// ```
750    /// use rust_diff_analyzer::config::ConfigBuilder;
751    ///
752    /// let config = ConfigBuilder::new().add_ignore_path("fixtures/").build();
753    /// ```
754    pub fn add_ignore_path(mut self, path: &str) -> Self {
755        self.config
756            .classification
757            .ignore_paths
758            .push(path.to_string());
759        self
760    }
761
762    /// Adds an author to ignore
763    ///
764    /// # Arguments
765    ///
766    /// * `author` - Author name to ignore (e.g., "dependabot\[bot]")
767    ///
768    /// # Returns
769    ///
770    /// Self for method chaining
771    ///
772    /// # Examples
773    ///
774    /// ```
775    /// use rust_diff_analyzer::config::ConfigBuilder;
776    ///
777    /// let config = ConfigBuilder::new()
778    ///     .add_ignored_author("dependabot[bot]")
779    ///     .build();
780    /// ```
781    pub fn add_ignored_author(mut self, author: &str) -> Self {
782        self.config
783            .classification
784            .ignored_authors
785            .push(author.to_string());
786        self
787    }
788
789    /// Builds the configuration
790    ///
791    /// # Returns
792    ///
793    /// The built Config
794    ///
795    /// # Examples
796    ///
797    /// ```
798    /// use rust_diff_analyzer::config::ConfigBuilder;
799    ///
800    /// let config = ConfigBuilder::new().build();
801    /// ```
802    pub fn build(self) -> Config {
803        self.config
804    }
805}