1use prov_graph::meta::Value;
21
22use crate::filter::{CONDITION_KEYS, Condition};
23use crate::spec::{GRAINS, Grain, VIEW_KEYS, ViewSpec};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ViewIssue {
28 pub view: String,
30 pub key: String,
32 pub kind: ViewIssueKind,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ViewIssueKind {
39 NotAMapping,
41 NoGrouping,
44 UnknownKey,
47 BadGrain,
54 NoCondition,
62}
63
64impl ViewIssueKind {
65 pub fn is_fatal(&self) -> bool {
68 matches!(self, ViewIssueKind::NotAMapping | ViewIssueKind::NoGrouping)
69 }
70
71 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
82pub 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 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
120pub 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 #[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 #[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 #[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 #[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}