Skip to main content

deps_cli/update/
ignore.rs

1//! `[update].ignore` rule evaluation (#1119, spec 068).
2
3use deps_core::edit::UpdateKind;
4use deps_core::lsp_helpers::EcosystemFormatter;
5
6use crate::config::IgnoreRule;
7use crate::update::SkipReason;
8
9/// Normalized, ready-to-evaluate `[update].ignore` rules.
10///
11/// Built once per run via [`Self::new`] (normal mode, honoring an explicit `--config`'s
12/// rules) or [`Self::empty`] (no `--config` given — FR-007: `update` never auto-discovers a
13/// `deps.toml`, so there are never any rules to evaluate in that case).
14#[derive(Debug, Default)]
15pub struct IgnoreRules {
16    rules: Vec<(String, Option<Vec<crate::config::UpdateTypeToken>>)>,
17}
18
19impl IgnoreRules {
20    /// No rules — every dependency is eligible (FR-007's no-`--config` case).
21    #[must_use]
22    pub const fn empty() -> Self {
23        Self { rules: Vec::new() }
24    }
25
26    /// Normalizes `rules`' names via `formatter.normalize_package_name` once, up front, so
27    /// [`Self::skip_reason`] never has to.
28    #[must_use]
29    pub fn new(rules: Vec<IgnoreRule>, formatter: &dyn EcosystemFormatter) -> Self {
30        let rules = rules
31            .into_iter()
32            .map(|rule| {
33                let normalized =
34                    formatter.normalize_package_name(&deps_core::PackageName::new(rule.name));
35                (normalized, rule.update_types)
36            })
37            .collect();
38        Self { rules }
39    }
40
41    /// Whether any rule names `normalized_name` at all, regardless of its `update_types`
42    /// scope — used only by `--security-only`'s FR-008 override report (never to suppress a
43    /// candidate there), since an overridden rule is reported as "would have applied"
44    /// independent of whether its scope would actually have matched this update's kind.
45    #[must_use]
46    pub fn matches_name(&self, normalized_name: &str) -> bool {
47        self.rules.iter().any(|(name, _)| name == normalized_name)
48    }
49
50    /// Whether a dependency named `normalized_name` (already normalized), classified `kind`,
51    /// should be skipped — `Some(SkipReason::IgnoreRule)` when a rule matches, `None`
52    /// otherwise.
53    ///
54    /// A rule with no `update_types` matches every kind, including [`UpdateKind::Unknown`]. A
55    /// rule with `update_types` matches its listed kinds **and** `Unknown` — fail-closed
56    /// (FR-006): a rule that cannot confirm an update is below its stated threshold treats it
57    /// as if it met the threshold.
58    #[must_use]
59    pub fn skip_reason(&self, normalized_name: &str, kind: UpdateKind) -> Option<SkipReason> {
60        let (_, update_types) = self
61            .rules
62            .iter()
63            .find(|(name, _)| name == normalized_name)?;
64
65        let matches = match update_types {
66            None => true,
67            Some(types) => kind == UpdateKind::Unknown || types.iter().any(|t| t.matches(kind)),
68        };
69        matches.then_some(SkipReason::IgnoreRule)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::config::UpdateTypeToken;
77
78    const STUB_FORMATTER: deps_core::test_util::StubFormatter =
79        deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
80
81    #[test]
82    fn test_empty_rules_never_skip() {
83        let rules = IgnoreRules::empty();
84        assert_eq!(rules.skip_reason("tokio", UpdateKind::Major), None);
85    }
86
87    #[test]
88    fn test_rule_with_no_update_types_matches_every_kind() {
89        let rules = IgnoreRules::new(
90            vec![IgnoreRule {
91                name: "legacy-thing".to_string(),
92                update_types: None,
93            }],
94            &STUB_FORMATTER,
95        );
96        assert_eq!(
97            rules.skip_reason("legacy-thing", UpdateKind::Patch),
98            Some(SkipReason::IgnoreRule)
99        );
100        assert_eq!(
101            rules.skip_reason("legacy-thing", UpdateKind::Unknown),
102            Some(SkipReason::IgnoreRule)
103        );
104    }
105
106    #[test]
107    fn test_scoped_rule_only_matches_listed_kind() {
108        let rules = IgnoreRules::new(
109            vec![IgnoreRule {
110                name: "tokio".to_string(),
111                update_types: Some(vec![UpdateTypeToken::Major]),
112            }],
113            &STUB_FORMATTER,
114        );
115        assert_eq!(
116            rules.skip_reason("tokio", UpdateKind::Major),
117            Some(SkipReason::IgnoreRule)
118        );
119        assert_eq!(rules.skip_reason("tokio", UpdateKind::Minor), None);
120        assert_eq!(rules.skip_reason("tokio", UpdateKind::Patch), None);
121    }
122
123    /// FR-006 fail-closed clause: a scoped rule still skips an `Unknown`-classified update,
124    /// even though `unknown` is not itself a listed token.
125    #[test]
126    fn test_scoped_rule_fail_closed_on_unknown_kind() {
127        let rules = IgnoreRules::new(
128            vec![IgnoreRule {
129                name: "tokio".to_string(),
130                update_types: Some(vec![UpdateTypeToken::Major]),
131            }],
132            &STUB_FORMATTER,
133        );
134        assert_eq!(
135            rules.skip_reason("tokio", UpdateKind::Unknown),
136            Some(SkipReason::IgnoreRule)
137        );
138    }
139
140    #[test]
141    fn test_non_matching_name_is_never_skipped() {
142        let rules = IgnoreRules::new(
143            vec![IgnoreRule {
144                name: "tokio".to_string(),
145                update_types: None,
146            }],
147            &STUB_FORMATTER,
148        );
149        assert_eq!(rules.skip_reason("serde", UpdateKind::Major), None);
150    }
151}