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}