Skip to main content

prov_views/
lint.rs

1//! What a `views:` block gets wrong, reported rather than dropped.
2//!
3//! [`ViewSpec::parse`](crate::ViewSpec::parse) is deliberately lossy: it
4//! returns `None` for an entry it cannot make a view of, so a malformed
5//! declaration cannot put a lens in a picker that groups nothing. This module
6//! is the other half — the same judgment, keeping the *reason*.
7//!
8//! The two must agree, and the test at the bottom of this file is what holds
9//! them to it: every entry this reports as unusable is one `parse` drops, and
10//! every entry `parse` accepts is one this reports nothing fatal about. A
11//! linter that disagreed with the parser would report a clean config prov then
12//! ignored, which is the exact failure the config-issue machinery exists to
13//! prevent.
14//!
15//! Near-miss suggestions for a misspelled key are *not* computed here: the edit
16//! distance lives in `prov-config` alongside every other config near-miss, and
17//! [`VIEW_KEYS`] is what this crate exports so it can be
18//! computed there. One copy of the rule, in the crate that already owns it.
19
20use prov_graph::meta::Value;
21
22use crate::filter::{CONDITION_KEYS, Condition};
23use crate::spec::{GRAINS, Grain, VIEW_KEYS, ViewSpec};
24
25/// Something wrong with one `views.<name>` entry.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ViewIssue {
28    /// The view the entry declares.
29    pub view: String,
30    /// The key at fault, or the empty string when the entry as a whole is.
31    pub key: String,
32    /// What is wrong with it.
33    pub kind: ViewIssueKind,
34}
35
36/// The kinds of thing a `views.<name>` entry gets wrong.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ViewIssueKind {
39    /// The entry is not a mapping — `daily: date` rather than `daily: {…}`.
40    NotAMapping,
41    /// No `group:`, or one that is empty or not a string/list of strings. The
42    /// one key a view cannot do without.
43    NoGrouping,
44    /// A key this format does not define. Reported so a `labl:` is caught;
45    /// a near-miss suggestion is the caller's to add.
46    UnknownKey,
47    /// A `by:` or `nest:` whose value is not a grain.
48    ///
49    /// Carries no rendering of the offending value: the key names it, and how a
50    /// value is summarized for a human is the caller's vocabulary, not this
51    /// crate's — the same division that leaves near-miss suggestions to
52    /// `prov-config`.
53    BadGrain,
54    /// A `where:` that yields no condition — not a mapping, empty, or naming
55    /// only predicates this format does not define.
56    ///
57    /// Reported rather than treated as "no filter", because the two readings of
58    /// a broken `where:` are *select everything* and *select nothing*, and
59    /// picking either silently is how a typo publishes a workspace or hides
60    /// one.
61    NoCondition,
62}
63
64impl ViewIssueKind {
65    /// Whether this issue means the entry is not a view at all — the ones
66    /// [`ViewSpec::parse`](crate::ViewSpec::parse) drops.
67    pub fn is_fatal(&self) -> bool {
68        matches!(self, ViewIssueKind::NotAMapping | ViewIssueKind::NoGrouping)
69    }
70
71    /// The spellings a diagnostic should offer for this issue, if any.
72    pub fn expected(&self) -> &'static [&'static str] {
73        match self {
74            ViewIssueKind::UnknownKey => VIEW_KEYS,
75            ViewIssueKind::BadGrain => GRAINS,
76            ViewIssueKind::NoCondition => CONDITION_KEYS,
77            _ => &[],
78        }
79    }
80}
81
82/// Diagnose one `views.<name>` entry.
83pub fn diagnose_view(name: &str, value: &Value) -> Vec<ViewIssue> {
84    let issue = |key: &str, kind| ViewIssue {
85        view: name.to_string(),
86        key: key.to_string(),
87        kind,
88    };
89    let Some(map) = value.as_mapping() else {
90        return vec![issue("", ViewIssueKind::NotAMapping)];
91    };
92    let mut issues = Vec::new();
93    if ViewSpec::parse(name, value).is_none() {
94        issues.push(issue("group", ViewIssueKind::NoGrouping));
95    }
96    for (key, value) in map {
97        match key.as_str() {
98            "label" | "icon" | "under" | "group" => {}
99            "where" => {
100                if Condition::parse(value).is_none() {
101                    issues.push(issue(key, ViewIssueKind::NoCondition));
102                }
103            }
104            "by" | "nest" => {
105                // `ViewSpec::parse` reads an unparseable grain as *no grain* —
106                // it will not invent a cut the config did not ask for, and the
107                // view stays usable by grouping on the raw values. That is the
108                // right fallback and it is also completely silent, so this is
109                // the only place a `by: yearr` is ever heard from.
110                if Grain::parse(value).is_none() {
111                    issues.push(issue(key, ViewIssueKind::BadGrain));
112                }
113            }
114            _ => issues.push(issue(key, ViewIssueKind::UnknownKey)),
115        }
116    }
117    issues
118}
119
120/// Diagnose every entry of a `views:` block, in declaration order.
121pub fn diagnose_views(views: &Value) -> Vec<ViewIssue> {
122    let Some(map) = views.as_mapping() else {
123        return Vec::new();
124    };
125    map.iter()
126        .flat_map(|(name, value)| diagnose_view(name, value))
127        .collect()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use prov_graph::meta::Mapping;
134
135    fn view(pairs: &[(&str, &str)]) -> Value {
136        let mut map = Mapping::new();
137        for (k, v) in pairs {
138            map.insert((*k).into(), Value::String((*v).to_string()));
139        }
140        Value::Mapping(map)
141    }
142
143    #[test]
144    fn a_clean_view_reports_nothing() {
145        assert!(
146            diagnose_view(
147                "daily",
148                &view(&[
149                    ("label", "Daily"),
150                    ("icon", "calendar"),
151                    ("group", "created"),
152                    ("by", "month"),
153                    ("under", "[Daily](id:abc1234)"),
154                    ("nest", "year"),
155                ])
156            )
157            .is_empty()
158        );
159    }
160
161    #[test]
162    fn an_entry_that_is_not_a_mapping_is_reported_whole() {
163        let issues = diagnose_view("daily", &Value::String("created".into()));
164        assert_eq!(issues.len(), 1);
165        assert_eq!(issues[0].kind, ViewIssueKind::NotAMapping);
166    }
167
168    #[test]
169    fn a_missing_group_is_reported() {
170        let issues = diagnose_view("daily", &view(&[("label", "Daily")]));
171        assert!(issues.iter().any(|i| i.kind == ViewIssueKind::NoGrouping));
172    }
173
174    /// The failure the fallback would otherwise hide: `ViewSpec::parse` reads an
175    /// unparseable grain as no grain, so the view still works and nothing else
176    /// ever says the config was wrong.
177    #[test]
178    fn a_misspelled_grain_is_reported_for_both_axes() {
179        for key in ["by", "nest"] {
180            let issues = diagnose_view("daily", &view(&[("group", "created"), (key, "yearr")]));
181            assert_eq!(
182                issues,
183                vec![ViewIssue {
184                    view: "daily".into(),
185                    key: key.into(),
186                    kind: ViewIssueKind::BadGrain,
187                }]
188            );
189        }
190    }
191
192    /// A `where:` nobody can read has two possible silent readings — select
193    /// everything, or select nothing — and both are wrong. It is reported
194    /// instead.
195    #[test]
196    fn a_where_that_yields_no_condition_is_reported() {
197        for broken in [
198            Value::String("audience == public".into()),
199            Value::Mapping(Mapping::new()),
200            view(&[("hasnt", "draft")]),
201        ] {
202            let mut entry = Mapping::new();
203            entry.insert("group".into(), Value::String("created".into()));
204            entry.insert("where".into(), broken.clone());
205            let issues = diagnose_view("daily", &Value::Mapping(entry));
206            assert_eq!(
207                issues,
208                vec![ViewIssue {
209                    view: "daily".into(),
210                    key: "where".into(),
211                    kind: ViewIssueKind::NoCondition,
212                }],
213                "for {broken:?}"
214            );
215            assert_eq!(issues[0].kind.expected(), CONDITION_KEYS);
216        }
217    }
218
219    /// …and a `where:` that reads is silent, including the combinators.
220    #[test]
221    fn a_readable_where_reports_nothing() {
222        let mut entry = Mapping::new();
223        entry.insert("group".into(), Value::String("created".into()));
224        entry.insert(
225            "where".into(),
226            Value::Mapping({
227                let mut w = Mapping::new();
228                w.insert("not".into(), view(&[("has", "draft")]));
229                w
230            }),
231        );
232        assert!(diagnose_view("daily", &Value::Mapping(entry)).is_empty());
233    }
234
235    #[test]
236    fn an_unknown_key_is_reported() {
237        let issues = diagnose_view("daily", &view(&[("group", "created"), ("labl", "Daily")]));
238        assert_eq!(issues.len(), 1);
239        assert_eq!(issues[0].key, "labl");
240        assert_eq!(issues[0].kind, ViewIssueKind::UnknownKey);
241        assert_eq!(issues[0].kind.expected(), VIEW_KEYS);
242    }
243
244    /// The invariant that keeps the linter and the parser from drifting: an
245    /// entry is dropped by `parse` if and only if the linter calls it fatal.
246    #[test]
247    fn fatal_issues_are_exactly_the_entries_parse_drops() {
248        let cases = [
249            Value::String("created".into()),
250            Value::Sequence(vec![]),
251            view(&[("label", "Nameless")]),
252            view(&[("group", "  ")]),
253            view(&[("group", "created")]),
254            view(&[("group", "created"), ("by", "yearr")]),
255            view(&[("group", "created"), ("labl", "x")]),
256        ];
257        for case in cases {
258            let parsed = ViewSpec::parse("daily", &case).is_some();
259            let fatal = diagnose_view("daily", &case)
260                .iter()
261                .any(|i| i.kind.is_fatal());
262            assert_eq!(parsed, !fatal, "disagreed about {case:?}");
263        }
264    }
265}