Skip to main content

rscheck_cli/
config.rs

1use globset::{Glob, GlobSetBuilder};
2use serde::{Deserialize, Serialize, de::DeserializeOwned};
3use std::collections::BTreeMap;
4use std::fmt;
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8
9use crate::report::Severity;
10
11pub type RuleTable = toml::Table;
12
13const CURRENT_POLICY_VERSION: u32 = 2;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16#[serde(rename_all = "lowercase")]
17pub enum Level {
18    #[default]
19    Allow,
20    Warn,
21    Deny,
22}
23
24impl Level {
25    #[must_use]
26    pub fn enabled(self) -> bool {
27        !matches!(self, Self::Allow)
28    }
29
30    #[must_use]
31    pub fn to_severity(self) -> Severity {
32        match self {
33            Self::Allow => Severity::Info,
34            Self::Warn => Severity::Warn,
35            Self::Deny => Severity::Deny,
36        }
37    }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
41#[serde(rename_all = "lowercase")]
42pub enum EngineMode {
43    #[default]
44    Auto,
45    Require,
46    Off,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[serde(rename_all = "lowercase")]
51pub enum ToolchainMode {
52    #[default]
53    Current,
54    Auto,
55    Nightly,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
59#[serde(rename_all = "lowercase")]
60pub enum AdapterToolchainMode {
61    #[default]
62    Inherit,
63    Current,
64    Auto,
65    Nightly,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
69#[serde(rename_all = "lowercase")]
70pub enum OutputFormat {
71    #[default]
72    Text,
73    Json,
74    Sarif,
75    Html,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EngineConfig {
80    #[serde(default)]
81    pub semantic: EngineMode,
82    #[serde(default)]
83    pub toolchain: ToolchainMode,
84    #[serde(default = "EngineConfig::default_nightly_toolchain")]
85    pub nightly_toolchain: String,
86}
87
88impl EngineConfig {
89    fn default_nightly_toolchain() -> String {
90        "nightly".to_string()
91    }
92}
93
94impl Default for EngineConfig {
95    fn default() -> Self {
96        Self {
97            semantic: EngineMode::Auto,
98            toolchain: ToolchainMode::Current,
99            nightly_toolchain: Self::default_nightly_toolchain(),
100        }
101    }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct WorkspaceConfig {
106    #[serde(default = "WorkspaceConfig::default_include")]
107    pub include: Vec<String>,
108    #[serde(default = "WorkspaceConfig::default_exclude")]
109    pub exclude: Vec<String>,
110}
111
112impl WorkspaceConfig {
113    fn default_include() -> Vec<String> {
114        vec!["**/*.rs".to_string()]
115    }
116
117    fn default_exclude() -> Vec<String> {
118        vec!["target/**".to_string(), ".git/**".to_string()]
119    }
120}
121
122impl Default for WorkspaceConfig {
123    fn default() -> Self {
124        Self {
125            include: Self::default_include(),
126            exclude: Self::default_exclude(),
127        }
128    }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132pub struct OutputConfig {
133    #[serde(default)]
134    pub format: OutputFormat,
135    #[serde(default)]
136    pub output: Option<PathBuf>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ClippyAdapterConfig {
141    #[serde(default = "ClippyAdapterConfig::default_enabled")]
142    pub enabled: bool,
143    #[serde(default)]
144    pub args: Vec<String>,
145    #[serde(default)]
146    pub toolchain: AdapterToolchainMode,
147}
148
149impl ClippyAdapterConfig {
150    fn default_enabled() -> bool {
151        true
152    }
153}
154
155impl Default for ClippyAdapterConfig {
156    fn default() -> Self {
157        Self {
158            enabled: true,
159            args: Vec::new(),
160            toolchain: AdapterToolchainMode::Inherit,
161        }
162    }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
166pub struct AdaptersConfig {
167    #[serde(default)]
168    pub clippy: ClippyAdapterConfig,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172pub struct RuleSettings {
173    #[serde(default)]
174    pub level: Option<Level>,
175    #[serde(flatten)]
176    pub options: RuleTable,
177}
178
179impl RuleSettings {
180    #[must_use]
181    pub fn merge(&self, override_settings: &Self) -> Self {
182        let mut options = self.options.clone();
183        merge_tables(&mut options, &override_settings.options);
184        Self {
185            level: override_settings.level.or(self.level),
186            options,
187        }
188    }
189
190    #[must_use]
191    pub fn with_default_level(mut self, default_level: Level) -> Self {
192        if self.level.is_none() {
193            self.level = Some(default_level);
194        }
195        self
196    }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, Default)]
200pub struct ScopeConfig {
201    #[serde(default)]
202    pub include: Vec<String>,
203    #[serde(default)]
204    pub exclude: Vec<String>,
205    #[serde(default)]
206    pub rules: BTreeMap<String, RuleSettings>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct Policy {
211    #[serde(default = "Policy::default_version")]
212    pub version: u32,
213    #[serde(default)]
214    pub extends: Vec<PathBuf>,
215    #[serde(default)]
216    pub engine: EngineConfig,
217    #[serde(default)]
218    pub workspace: WorkspaceConfig,
219    #[serde(default)]
220    pub output: OutputConfig,
221    #[serde(default)]
222    pub adapters: AdaptersConfig,
223    #[serde(default)]
224    pub rules: BTreeMap<String, RuleSettings>,
225    #[serde(rename = "scope", default)]
226    pub scopes: Vec<ScopeConfig>,
227}
228
229impl Policy {
230    fn default_version() -> u32 {
231        CURRENT_POLICY_VERSION
232    }
233
234    #[must_use]
235    pub fn default_with_rules(
236        default_rules: impl IntoIterator<Item = (String, RuleSettings)>,
237    ) -> Self {
238        Self {
239            rules: default_rules.into_iter().collect(),
240            ..Self::default()
241        }
242    }
243
244    pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
245        let table = load_policy_table(path)?;
246        validate_legacy_shape(&table, path)?;
247        let policy: Self = toml::from_str(
248            &toml::to_string(&table).map_err(ConfigError::Serialize)?,
249        )
250        .map_err(|source| ConfigError::Parse {
251            path: path.to_path_buf(),
252            source,
253        })?;
254        policy.validate(path)?;
255        Ok(policy)
256    }
257
258    pub fn validate(&self, path: &Path) -> Result<(), ConfigError> {
259        if self.version != CURRENT_POLICY_VERSION {
260            return Err(ConfigError::UnsupportedVersion {
261                path: path.to_path_buf(),
262                version: self.version,
263            });
264        }
265        Ok(())
266    }
267
268    #[must_use]
269    pub fn rule_enabled_anywhere(&self, rule_id: &str, default_level: Level) -> bool {
270        if self
271            .rule_settings(rule_id, None, default_level)
272            .level
273            .unwrap_or(default_level)
274            .enabled()
275        {
276            return true;
277        }
278        self.scopes.iter().any(|scope| {
279            scope
280                .rules
281                .get(rule_id)
282                .and_then(|settings| settings.level)
283                .unwrap_or(default_level)
284                .enabled()
285        })
286    }
287
288    #[must_use]
289    pub fn rule_settings(
290        &self,
291        rule_id: &str,
292        file_path: Option<&Path>,
293        default_level: Level,
294    ) -> RuleSettings {
295        let mut resolved = self.rules.get(rule_id).cloned().unwrap_or_default();
296        for scope in &self.scopes {
297            if !scope_matches(scope, file_path) {
298                continue;
299            }
300            if let Some(scope_settings) = scope.rules.get(rule_id) {
301                resolved = resolved.merge(scope_settings);
302            }
303        }
304        resolved.with_default_level(default_level)
305    }
306
307    pub fn decode_rule<T>(&self, rule_id: &str, file_path: Option<&Path>) -> Result<T, ConfigError>
308    where
309        T: RuleOptions,
310    {
311        let resolved = self.rule_settings(rule_id, file_path, T::default_level());
312        decode_rule_settings::<T>(&resolved).map_err(|message| ConfigError::RuleDecode {
313            rule_id: rule_id.to_string(),
314            message,
315        })
316    }
317}
318
319impl Default for Policy {
320    fn default() -> Self {
321        Self {
322            version: CURRENT_POLICY_VERSION,
323            extends: Vec::new(),
324            engine: EngineConfig::default(),
325            workspace: WorkspaceConfig::default(),
326            output: OutputConfig::default(),
327            adapters: AdaptersConfig::default(),
328            rules: BTreeMap::new(),
329            scopes: Vec::new(),
330        }
331    }
332}
333
334pub trait RuleOptions: DeserializeOwned {
335    fn default_level() -> Level;
336}
337
338pub type Config = Policy;
339
340#[derive(Debug, thiserror::Error)]
341pub enum ConfigError {
342    #[error("failed to read config file: {path}")]
343    Read { path: PathBuf, source: io::Error },
344    #[error("failed to parse config file: {path}")]
345    Parse {
346        path: PathBuf,
347        source: toml::de::Error,
348    },
349    #[error("failed to serialize policy")]
350    Serialize(#[source] toml::ser::Error),
351    #[error("failed to write config file: {path}")]
352    Write { path: PathBuf, source: io::Error },
353    #[error("policy version {version} is not supported: {path}")]
354    UnsupportedVersion { path: PathBuf, version: u32 },
355    #[error("legacy config key `{key}` is not supported in v2: {path}. {message}")]
356    LegacyKey {
357        path: PathBuf,
358        key: String,
359        message: String,
360    },
361    #[error("failed to decode rule `{rule_id}`: {message}")]
362    RuleDecode { rule_id: String, message: String },
363    #[error("failed to build glob matcher: {pattern}")]
364    Glob {
365        pattern: String,
366        #[source]
367        source: globset::Error,
368    },
369}
370
371fn decode_rule_settings<T>(settings: &RuleSettings) -> Result<T, String>
372where
373    T: RuleOptions,
374{
375    let mut table = settings.options.clone();
376    if let Some(level) = settings.level {
377        table.insert(
378            "level".to_string(),
379            toml::Value::String(level_string(level).to_string()),
380        );
381    }
382    let text = toml::to_string(&table).map_err(|err| err.to_string())?;
383    toml::from_str(&text).map_err(|err| err.to_string())
384}
385
386fn level_string(level: Level) -> &'static str {
387    match level {
388        Level::Allow => "allow",
389        Level::Warn => "warn",
390        Level::Deny => "deny",
391    }
392}
393
394fn load_policy_table(path: &Path) -> Result<RuleTable, ConfigError> {
395    let mut visiting = Vec::new();
396    load_policy_table_inner(path, &mut visiting)
397}
398
399fn load_policy_table_inner(
400    path: &Path,
401    visiting: &mut Vec<PathBuf>,
402) -> Result<RuleTable, ConfigError> {
403    let canonical = path.to_path_buf();
404    if visiting.contains(&canonical) {
405        return Ok(RuleTable::new());
406    }
407    visiting.push(canonical);
408
409    let text = fs::read_to_string(path).map_err(|source| ConfigError::Read {
410        path: path.to_path_buf(),
411        source,
412    })?;
413    let table: RuleTable = toml::from_str(&text).map_err(|source| ConfigError::Parse {
414        path: path.to_path_buf(),
415        source,
416    })?;
417
418    let mut merged = RuleTable::new();
419    if let Some(extends) = table.get("extends").and_then(toml::Value::as_array) {
420        for entry in extends {
421            let Some(relative) = entry.as_str() else {
422                continue;
423            };
424            let parent = path.parent().unwrap_or_else(|| Path::new("."));
425            let nested = load_policy_table_inner(&parent.join(relative), visiting)?;
426            merge_tables(&mut merged, &nested);
427        }
428    }
429    merge_tables(&mut merged, &table);
430    let _ = visiting.pop();
431    Ok(merged)
432}
433
434fn validate_legacy_shape(table: &RuleTable, path: &Path) -> Result<(), ConfigError> {
435    if let Some(output) = table.get("output").and_then(toml::Value::as_table) {
436        if output.get("with_clippy").is_some() {
437            return Err(ConfigError::LegacyKey {
438                path: path.to_path_buf(),
439                key: "output.with_clippy".to_string(),
440                message: "Use `[adapters.clippy].enabled`.".to_string(),
441            });
442        }
443        if output.get("format").and_then(toml::Value::as_str) == Some("human") {
444            return Err(ConfigError::LegacyKey {
445                path: path.to_path_buf(),
446                key: "output.format".to_string(),
447                message: "Use `text` instead of `human`.".to_string(),
448            });
449        }
450    }
451
452    if let Some(rules) = table.get("rules").and_then(toml::Value::as_table) {
453        for key in rules.keys() {
454            if !key.contains('.') {
455                return Err(ConfigError::LegacyKey {
456                    path: path.to_path_buf(),
457                    key: format!("rules.{key}"),
458                    message: "Use dot rule IDs such as `shape.file_complexity`.".to_string(),
459                });
460            }
461        }
462    }
463
464    if table.get("include").is_some() || table.get("exclude").is_some() {
465        return Err(ConfigError::LegacyKey {
466            path: path.to_path_buf(),
467            key: "include/exclude".to_string(),
468            message: "Move these under `[workspace]`.".to_string(),
469        });
470    }
471    Ok(())
472}
473
474fn merge_tables(into: &mut RuleTable, overlay: &RuleTable) {
475    for (key, value) in overlay {
476        match (into.get_mut(key), value) {
477            (Some(toml::Value::Table(dst)), toml::Value::Table(src)) => merge_tables(dst, src),
478            _ => {
479                into.insert(key.clone(), value.clone());
480            }
481        }
482    }
483}
484
485fn scope_matches(scope: &ScopeConfig, file_path: Option<&Path>) -> bool {
486    let Some(file_path) = file_path else {
487        return false;
488    };
489    let display = file_path.to_string_lossy();
490
491    if !scope.include.is_empty() && !globset_matches(&scope.include, display.as_ref()) {
492        return false;
493    }
494    if !scope.exclude.is_empty() && globset_matches(&scope.exclude, display.as_ref()) {
495        return false;
496    }
497    true
498}
499
500fn globset_matches(patterns: &[String], candidate: &str) -> bool {
501    let mut builder = GlobSetBuilder::new();
502    for pattern in patterns {
503        let Ok(glob) = Glob::new(pattern) else {
504            continue;
505        };
506        builder.add(glob);
507    }
508    let Ok(set) = builder.build() else {
509        return false;
510    };
511    set.is_match(candidate)
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct AbsoluteModulePathsConfig {
516    #[serde(default = "AbsoluteModulePathsConfig::default_level")]
517    pub level: Level,
518    #[serde(default)]
519    pub allow_prefixes: Vec<String>,
520    #[serde(default = "AbsoluteModulePathsConfig::default_roots")]
521    pub roots: Vec<String>,
522    #[serde(default = "AbsoluteModulePathsConfig::default_allow_crate_root_macros")]
523    pub allow_crate_root_macros: bool,
524    #[serde(default = "AbsoluteModulePathsConfig::default_allow_crate_root_consts")]
525    pub allow_crate_root_consts: bool,
526    #[serde(default = "AbsoluteModulePathsConfig::default_allow_crate_root_fn_calls")]
527    pub allow_crate_root_fn_calls: bool,
528}
529
530impl AbsoluteModulePathsConfig {
531    fn default_level() -> Level {
532        Level::Deny
533    }
534
535    fn default_roots() -> Vec<String> {
536        vec![
537            "std".to_string(),
538            "core".to_string(),
539            "alloc".to_string(),
540            "crate".to_string(),
541        ]
542    }
543
544    fn default_allow_crate_root_macros() -> bool {
545        true
546    }
547
548    fn default_allow_crate_root_consts() -> bool {
549        true
550    }
551
552    fn default_allow_crate_root_fn_calls() -> bool {
553        true
554    }
555}
556
557impl Default for AbsoluteModulePathsConfig {
558    fn default() -> Self {
559        Self {
560            level: Self::default_level(),
561            allow_prefixes: Vec::new(),
562            roots: Self::default_roots(),
563            allow_crate_root_macros: true,
564            allow_crate_root_consts: true,
565            allow_crate_root_fn_calls: true,
566        }
567    }
568}
569
570impl RuleOptions for AbsoluteModulePathsConfig {
571    fn default_level() -> Level {
572        Self::default_level()
573    }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct AbsoluteFilesystemPathsConfig {
578    #[serde(default = "AbsoluteFilesystemPathsConfig::default_level")]
579    pub level: Level,
580    #[serde(default)]
581    pub allow_globs: Vec<String>,
582    #[serde(default)]
583    pub allow_regex: Vec<String>,
584    #[serde(default)]
585    pub check_comments: bool,
586}
587
588impl AbsoluteFilesystemPathsConfig {
589    fn default_level() -> Level {
590        Level::Warn
591    }
592}
593
594impl Default for AbsoluteFilesystemPathsConfig {
595    fn default() -> Self {
596        Self {
597            level: Self::default_level(),
598            allow_globs: Vec::new(),
599            allow_regex: Vec::new(),
600            check_comments: false,
601        }
602    }
603}
604
605impl RuleOptions for AbsoluteFilesystemPathsConfig {
606    fn default_level() -> Level {
607        Self::default_level()
608    }
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum ComplexityMode {
614    Cyclomatic,
615    PhysicalLoc,
616    LogicalLoc,
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct FileComplexityConfig {
621    #[serde(default = "FileComplexityConfig::default_level")]
622    pub level: Level,
623    #[serde(default = "FileComplexityConfig::default_mode")]
624    pub mode: ComplexityMode,
625    #[serde(default = "FileComplexityConfig::default_max_file")]
626    pub max_file: u32,
627    #[serde(default = "FileComplexityConfig::default_max_fn")]
628    pub max_fn: u32,
629    #[serde(default = "FileComplexityConfig::default_count_question")]
630    pub count_question: bool,
631    #[serde(default = "FileComplexityConfig::default_match_arms")]
632    pub match_arms: bool,
633}
634
635impl FileComplexityConfig {
636    fn default_level() -> Level {
637        Level::Warn
638    }
639    fn default_mode() -> ComplexityMode {
640        ComplexityMode::Cyclomatic
641    }
642    fn default_max_file() -> u32 {
643        200
644    }
645    fn default_max_fn() -> u32 {
646        25
647    }
648    fn default_count_question() -> bool {
649        false
650    }
651    fn default_match_arms() -> bool {
652        true
653    }
654}
655
656impl Default for FileComplexityConfig {
657    fn default() -> Self {
658        Self {
659            level: Self::default_level(),
660            mode: Self::default_mode(),
661            max_file: 200,
662            max_fn: 25,
663            count_question: false,
664            match_arms: true,
665        }
666    }
667}
668
669impl RuleOptions for FileComplexityConfig {
670    fn default_level() -> Level {
671        Self::default_level()
672    }
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct DuplicateLogicConfig {
677    #[serde(default = "DuplicateLogicConfig::default_level")]
678    pub level: Level,
679    #[serde(default = "DuplicateLogicConfig::default_min_tokens")]
680    pub min_tokens: usize,
681    #[serde(default = "DuplicateLogicConfig::default_threshold")]
682    pub threshold: f32,
683    #[serde(default = "DuplicateLogicConfig::default_max_results")]
684    pub max_results: usize,
685    #[serde(default)]
686    pub exclude_globs: Vec<String>,
687    #[serde(default = "DuplicateLogicConfig::default_kgram")]
688    pub kgram: usize,
689}
690
691impl DuplicateLogicConfig {
692    fn default_level() -> Level {
693        Level::Warn
694    }
695    fn default_min_tokens() -> usize {
696        80
697    }
698    fn default_threshold() -> f32 {
699        0.80
700    }
701    fn default_max_results() -> usize {
702        200
703    }
704    fn default_kgram() -> usize {
705        25
706    }
707}
708
709impl Default for DuplicateLogicConfig {
710    fn default() -> Self {
711        Self {
712            level: Self::default_level(),
713            min_tokens: 80,
714            threshold: 0.80,
715            max_results: 200,
716            exclude_globs: Vec::new(),
717            kgram: 25,
718        }
719    }
720}
721
722impl RuleOptions for DuplicateLogicConfig {
723    fn default_level() -> Level {
724        Self::default_level()
725    }
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct DuplicateTypesAliasConfig {
730    #[serde(default = "DuplicateTypesAliasConfig::default_level")]
731    pub level: Level,
732    #[serde(default = "DuplicateTypesAliasConfig::default_min_occurrences")]
733    pub min_occurrences: usize,
734    #[serde(default = "DuplicateTypesAliasConfig::default_min_len")]
735    pub min_len: usize,
736    #[serde(default = "DuplicateTypesAliasConfig::default_exclude_outer")]
737    pub exclude_outer: Vec<String>,
738}
739
740impl DuplicateTypesAliasConfig {
741    fn default_level() -> Level {
742        Level::Allow
743    }
744    fn default_min_occurrences() -> usize {
745        3
746    }
747    fn default_min_len() -> usize {
748        25
749    }
750    fn default_exclude_outer() -> Vec<String> {
751        vec!["Option".to_string()]
752    }
753}
754
755impl Default for DuplicateTypesAliasConfig {
756    fn default() -> Self {
757        Self {
758            level: Self::default_level(),
759            min_occurrences: 3,
760            min_len: 25,
761            exclude_outer: Self::default_exclude_outer(),
762        }
763    }
764}
765
766impl RuleOptions for DuplicateTypesAliasConfig {
767    fn default_level() -> Level {
768        Self::default_level()
769    }
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize)]
773pub struct SrpHeuristicConfig {
774    #[serde(default)]
775    pub level: Level,
776    #[serde(default = "SrpHeuristicConfig::default_method_count")]
777    pub method_count_threshold: usize,
778}
779
780impl SrpHeuristicConfig {
781    fn default_method_count() -> usize {
782        25
783    }
784}
785
786impl Default for SrpHeuristicConfig {
787    fn default() -> Self {
788        Self {
789            level: Level::Allow,
790            method_count_threshold: 25,
791        }
792    }
793}
794
795impl RuleOptions for SrpHeuristicConfig {
796    fn default_level() -> Level {
797        Level::Allow
798    }
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct BannedDependenciesConfig {
803    #[serde(default = "BannedDependenciesConfig::default_level")]
804    pub level: Level,
805    #[serde(default)]
806    pub banned_prefixes: Vec<String>,
807}
808
809impl BannedDependenciesConfig {
810    fn default_level() -> Level {
811        Level::Allow
812    }
813}
814
815impl Default for BannedDependenciesConfig {
816    fn default() -> Self {
817        Self {
818            level: Self::default_level(),
819            banned_prefixes: Vec::new(),
820        }
821    }
822}
823
824impl RuleOptions for BannedDependenciesConfig {
825    fn default_level() -> Level {
826        Self::default_level()
827    }
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
831pub struct PublicApiErrorsConfig {
832    #[serde(default = "PublicApiErrorsConfig::default_level")]
833    pub level: Level,
834    #[serde(default = "PublicApiErrorsConfig::default_allowed_error_types")]
835    pub allowed_error_types: Vec<String>,
836}
837
838impl PublicApiErrorsConfig {
839    fn default_level() -> Level {
840        Level::Allow
841    }
842
843    fn default_allowed_error_types() -> Vec<String> {
844        vec![
845            "crate::Error".to_string(),
846            "crate::error::Error".to_string(),
847        ]
848    }
849}
850
851impl Default for PublicApiErrorsConfig {
852    fn default() -> Self {
853        Self {
854            level: Self::default_level(),
855            allowed_error_types: Self::default_allowed_error_types(),
856        }
857    }
858}
859
860impl RuleOptions for PublicApiErrorsConfig {
861    fn default_level() -> Level {
862        Self::default_level()
863    }
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize)]
867pub struct LayerRuleSet {
868    pub name: String,
869    #[serde(default)]
870    pub include: Vec<String>,
871    #[serde(default)]
872    pub may_depend_on: Vec<String>,
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize)]
876pub struct LayerDirectionConfig {
877    #[serde(default = "LayerDirectionConfig::default_level")]
878    pub level: Level,
879    #[serde(default)]
880    pub layers: Vec<LayerRuleSet>,
881}
882
883impl LayerDirectionConfig {
884    fn default_level() -> Level {
885        Level::Allow
886    }
887}
888
889impl Default for LayerDirectionConfig {
890    fn default() -> Self {
891        Self {
892            level: Self::default_level(),
893            layers: Vec::new(),
894        }
895    }
896}
897
898impl RuleOptions for LayerDirectionConfig {
899    fn default_level() -> Level {
900        Self::default_level()
901    }
902}
903
904impl fmt::Display for OutputFormat {
905    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
906        match self {
907            Self::Text => f.write_str("text"),
908            Self::Json => f.write_str("json"),
909            Self::Sarif => f.write_str("sarif"),
910            Self::Html => f.write_str("html"),
911        }
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use super::{ConfigError, Level, Policy};
918    use std::fs;
919
920    #[test]
921    fn rejects_legacy_human_format() {
922        let dir = tempfile::tempdir().unwrap();
923        let path = dir.path().join(".rscheck.toml");
924        fs::write(&path, "[output]\nformat = \"human\"\n").unwrap();
925
926        let err = Policy::from_path(&path).unwrap_err();
927        assert!(matches!(err, ConfigError::LegacyKey { .. }));
928        assert!(err.to_string().contains("text"));
929    }
930
931    #[test]
932    fn merges_extended_policy() {
933        let dir = tempfile::tempdir().unwrap();
934        let base = dir.path().join("base.toml");
935        let child = dir.path().join("child.toml");
936
937        fs::write(
938            &base,
939            "version = 2\n[rules.\"shape.file_complexity\"]\nlevel = \"warn\"\nmax_file = 10\n",
940        )
941        .unwrap();
942        fs::write(
943            &child,
944            "version = 2\nextends = [\"base.toml\"]\n[rules.\"shape.file_complexity\"]\nmax_fn = 2\n",
945        )
946        .unwrap();
947
948        let policy = Policy::from_path(&child).unwrap();
949        let settings = policy.rule_settings("shape.file_complexity", None, Level::Warn);
950        assert_eq!(settings.level, Some(Level::Warn));
951        assert_eq!(
952            settings
953                .options
954                .get("max_file")
955                .and_then(toml::Value::as_integer),
956            Some(10)
957        );
958        assert_eq!(
959            settings
960                .options
961                .get("max_fn")
962                .and_then(toml::Value::as_integer),
963            Some(2)
964        );
965    }
966}