Skip to main content

lanekeep_core/
query_cover.rs

1//! The exact cover between a rule's declared languages and its per-language queries.
2//!
3//! One validation, shared by the two gates that enforce it — `lanekeep-config`'s
4//! `build_rule` for an extracted TypeScript rule and `lanekeep-wasm`'s `validate_metadata`
5//! for a component — so the two paths cannot drift in what they accept or in how they say
6//! no. Each gate wraps [`QueryCoverProblem::describe`] in its own error type; the words are
7//! shared, the types are not.
8//!
9//! Two checks are deliberately *not* here. An empty `languages` list has its own refusal in
10//! both gates, older than this module and asserted by its own tests on each side. And the
11//! per-entry "query text is empty" refusal belongs to `build_rule` alone: probe fixtures
12//! answer `metadata` with an empty query on purpose, so the host gate admits one and the
13//! config gate — the last gate before a rule runs — refuses it.
14
15/// Why a rule's languages and queries do not cover each other.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum QueryCoverProblem {
18    /// The rule declares no query at all.
19    NoQueries,
20    /// Two queries name one language. Only one of them could ever run, and which one would
21    /// be decided by position — the other is discarded with nothing reporting it.
22    Duplicate {
23        /// The language named twice.
24        language: String,
25    },
26    /// A declared language has no query of its own, so the rule can never match on it.
27    Missing {
28        /// The language with no query.
29        language: String,
30    },
31    /// A query names a language the rule does not target, so it can never run.
32    Undeclared {
33        /// The language the rule does not target.
34        language: String,
35    },
36}
37
38impl QueryCoverProblem {
39    /// The refusal, phrased for the rule author, without the rule's id.
40    ///
41    /// Both gates prefix the id in their own error type; sharing the sentence is what keeps
42    /// the component path and the TypeScript path saying the same thing for the same
43    /// mistake.
44    #[must_use]
45    pub fn describe(&self) -> String {
46        match self {
47            Self::NoQueries => "declares no query for any language — a rule with no query \
48                                can never match, and silently"
49                .to_owned(),
50            Self::Duplicate { language } => format!(
51                "declares two queries for `{language}` — only one of them could run, and \
52                 the other would be discarded silently"
53            ),
54            Self::Missing { language } => format!(
55                "declares no query for `{language}` — a rule runs only on files whose \
56                 language it names, so a language with no query can never match"
57            ),
58            Self::Undeclared { language } => format!(
59                "declares a query for `{language}`, which it does not target — that query \
60                 can never run, and nothing would report the mistake"
61            ),
62        }
63    }
64}
65
66/// Check that the query entries exactly cover `languages`, with no language named twice.
67///
68/// `queries` is the entry languages in declaration order. When several problems exist the
69/// first in check order — no queries, a duplicate, a missing language, an undeclared one —
70/// is named, and within a check the first offender in declaration order, so two runs over
71/// one rule report the same refusal.
72///
73/// # Errors
74///
75/// The first [`QueryCoverProblem`] found, in the order above.
76pub fn check<'a, I>(languages: &[String], queries: I) -> Result<(), QueryCoverProblem>
77where
78    I: IntoIterator<Item = &'a str>,
79{
80    let entries: Vec<&str> = queries.into_iter().collect();
81
82    if entries.is_empty() {
83        return Err(QueryCoverProblem::NoQueries);
84    }
85
86    let mut seen: Vec<&str> = Vec::with_capacity(entries.len());
87    for language in &entries {
88        if seen.contains(language) {
89            return Err(QueryCoverProblem::Duplicate {
90                language: (*language).to_owned(),
91            });
92        }
93        seen.push(language);
94    }
95
96    for language in languages {
97        if !entries.contains(&language.as_str()) {
98            return Err(QueryCoverProblem::Missing {
99                language: language.clone(),
100            });
101        }
102    }
103
104    for language in &entries {
105        if !languages.iter().any(|declared| declared == language) {
106            return Err(QueryCoverProblem::Undeclared {
107                language: (*language).to_owned(),
108            });
109        }
110    }
111
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn languages(ids: &[&str]) -> Vec<String> {
120        ids.iter().map(|id| (*id).to_owned()).collect()
121    }
122
123    #[test]
124    fn an_exact_cover_passes() {
125        assert_eq!(
126            check(
127                &languages(&["typescript", "python"]),
128                ["typescript", "python"]
129            ),
130            Ok(())
131        );
132    }
133
134    #[test]
135    fn entry_order_does_not_matter_for_a_cover() {
136        assert_eq!(
137            check(
138                &languages(&["typescript", "python"]),
139                ["python", "typescript"]
140            ),
141            Ok(())
142        );
143    }
144
145    #[test]
146    fn no_queries_at_all_is_refused() {
147        assert_eq!(
148            check(&languages(&["rust"]), []),
149            Err(QueryCoverProblem::NoQueries)
150        );
151    }
152
153    #[test]
154    fn a_language_named_twice_is_refused_naming_it() {
155        // The silent failure this exists to close: both cover directions hold — every
156        // declared language has an entry, every entry names a declared language — and one
157        // of the two queries would be discarded by position.
158        assert_eq!(
159            check(&languages(&["rust"]), ["rust", "rust"]),
160            Err(QueryCoverProblem::Duplicate {
161                language: "rust".to_owned()
162            })
163        );
164    }
165
166    #[test]
167    fn a_declared_language_without_a_query_is_refused_naming_it() {
168        assert_eq!(
169            check(&languages(&["rust", "go"]), ["rust"]),
170            Err(QueryCoverProblem::Missing {
171                language: "go".to_owned()
172            })
173        );
174    }
175
176    #[test]
177    fn a_query_for_an_undeclared_language_is_refused_naming_it() {
178        assert_eq!(
179            check(&languages(&["rust"]), ["rust", "go"]),
180            Err(QueryCoverProblem::Undeclared {
181                language: "go".to_owned()
182            })
183        );
184    }
185
186    #[test]
187    fn the_first_problem_in_declaration_order_is_the_one_named() {
188        // Deterministic refusals: a rule with two duplicates names the first.
189        assert_eq!(
190            check(&languages(&["a", "b"]), ["b", "b", "a", "a"]),
191            Err(QueryCoverProblem::Duplicate {
192                language: "b".to_owned()
193            })
194        );
195    }
196
197    #[test]
198    fn every_problem_describes_itself_without_the_id() {
199        // The gates prefix `\`{id}\` ` themselves; a description starting with the verb is
200        // what keeps that composition grammatical on both sides.
201        for problem in [
202            QueryCoverProblem::NoQueries,
203            QueryCoverProblem::Duplicate {
204                language: "x".to_owned(),
205            },
206            QueryCoverProblem::Missing {
207                language: "x".to_owned(),
208            },
209            QueryCoverProblem::Undeclared {
210                language: "x".to_owned(),
211            },
212        ] {
213            assert!(problem.describe().starts_with("declares"), "{problem:?}");
214        }
215    }
216}