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}