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}