1use std::collections::HashSet;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Map, Value};
8
9use super::policy::matches_pattern;
10use super::query::parse_records_json;
11use super::record_store::RECORDS_SCHEME;
12use super::records::{is_record_kind, validate_record_schema};
13use super::sources::parse_source;
14use super::exposes::{validate_package_exposes, validate_upstreams};
15use super::tools::validate_tools;
16use crate::catalog::{A2UI_BASIC_CATALOG_ID, A2UI_VERSION, CORE_CATALOG_ID, CORE_LITE_CATALOG_ID};
17use crate::types::ExtensionDef;
18
19pub const PACKAGE_FORMAT_VERSION: u32 = 3;
24pub const EXTENSION_VERSION_PATTERN: &str = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$";
26
27#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
29pub struct AvailableExtension {
30 pub id: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub version: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub components: Option<Vec<String>>,
37}
38
39pub fn available_extensions(defs: &[ExtensionDef]) -> Vec<AvailableExtension> {
43 defs.iter()
44 .map(|d| AvailableExtension {
45 id: d.id.clone(),
46 version: d.version.clone().filter(|v| !v.is_empty()),
47 components: Some(d.components.keys().chain(d.macros.iter().flat_map(|m| m.keys())).cloned().collect()),
48 })
49 .collect()
50}
51
52fn semver(v: &str) -> Option<[f64; 3]> {
55 let mut parts = v.split('.');
56 let mut out = [0.0; 3];
57 for slot in &mut out {
58 let p = parts.next()?;
59 if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) || (p.len() > 1 && p.starts_with('0')) {
60 return None;
61 }
62 *slot = p.parse().ok()?;
63 }
64 parts.next().is_none().then_some(out)
65}
66
67pub fn version_satisfies(have: &str, want: &str) -> bool {
69 let (Some([h_major, h_minor, h_patch]), Some([w_major, w_minor, w_patch])) = (semver(have), semver(want)) else { return false };
70 h_major == w_major && (h_minor > w_minor || (h_minor == w_minor && h_patch >= w_patch))
71}
72
73pub fn supported_catalog_ids<S: AsRef<str>>(extension_ids: &[S]) -> Vec<String> {
76 let mut out: Vec<String> = Vec::new();
77 for id in [CORE_CATALOG_ID, CORE_LITE_CATALOG_ID, A2UI_BASIC_CATALOG_ID].into_iter().chain(extension_ids.iter().map(AsRef::as_ref)) {
78 if !out.iter().any(|x| x == id) {
79 out.push(id.to_string());
80 }
81 }
82 out
83}
84
85pub fn client_capabilities<S: AsRef<str>>(extension_ids: &[S]) -> Value {
87 json!({"v0.9": {"supportedCatalogIds": supported_catalog_ids(extension_ids)}})
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct PackageIssue {
92 pub path: String,
93 pub message: String,
94}
95
96fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
97 PackageIssue { path: path.into(), message: message.into() }
98}
99
100fn is_pointer(s: &str) -> bool {
103 if s.is_empty() {
104 return true;
105 }
106 if !s.starts_with('/') {
107 return false;
108 }
109 let mut chars = s.chars().peekable();
110 while let Some(c) = chars.next() {
111 if c == '~' && !matches!(chars.next(), Some('0' | '1')) {
112 return false;
113 }
114 }
115 true
116}
117
118pub fn validate_package<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S]) -> Vec<PackageIssue> {
123 validate_package_with(pkg, catalog_ids, None)
124}
125
126pub fn validate_package_with<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S], extensions: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
130 validate_package_in(pkg, catalog_ids, extensions, &crate::catalog::CatalogView::core())
131}
132
133pub fn validate_package_in<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S], extensions: Option<&[AvailableExtension]>, view: &std::sync::Arc<crate::catalog::CatalogView>) -> Vec<PackageIssue> {
138 let mut issues = Vec::new();
139 let Some(p) = pkg.as_object() else { return vec![issue("", "a package is an object")] };
140 match p.get("formatVersion").map(|version| version.as_f64().filter(|v| v.fract() == 0.0 && *v >= 1.0)) {
141 None => issues.push(issue("/formatVersion", format!("formatVersion is required ({PACKAGE_FORMAT_VERSION})"))),
142 Some(None) => issues.push(issue("/formatVersion", "formatVersion is a positive integer")),
143 Some(Some(v)) if v > f64::from(PACKAGE_FORMAT_VERSION) => {
144 issues.push(issue("/formatVersion", format!("format version {} is newer than this host ({PACKAGE_FORMAT_VERSION})", crate::json::number_to_string(v))))
145 }
146 Some(Some(v)) if v < f64::from(PACKAGE_FORMAT_VERSION) => {
147 issues.push(issue("/formatVersion", format!("format version {} is no longer read: write {PACKAGE_FORMAT_VERSION}", crate::json::number_to_string(v))))
148 }
149 Some(Some(_)) => {}
150 }
151 for key in ["id", "name", "version", "catalogId"] {
152 if p.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
153 issues.push(issue(format!("/{key}"), format!("{key} is a required string")));
154 }
155 }
156 if let Some(catalog_id) = p.get("catalogId").and_then(Value::as_str).filter(|s| !s.is_empty()) {
157 if !catalog_ids.iter().any(|c| c.as_ref() == catalog_id) {
158 issues.push(issue("/catalogId", format!("unsupported catalog {catalog_id}")));
159 }
160 }
161 match p.get("templates").and_then(Value::as_object).filter(|t| !t.is_empty()) {
162 None => issues.push(issue("/templates", "templates is a non-empty object")),
163 Some(templates) => {
164 for (id, template) in sorted_entries(templates) {
167 let at = format!("/templates/{id}");
168 let Some(template) = template.as_object() else {
169 issues.push(issue(at, "a template is an object"));
170 continue;
171 };
172 match template.get("components").and_then(Value::as_array).filter(|c| !c.is_empty()) {
173 None => issues.push(issue(format!("{at}/components"), "components is a non-empty array")),
174 Some(components) => {
175 let mut ids: HashSet<&str> = HashSet::new();
176 for (i, c) in components.iter().enumerate() {
177 let id = c.as_object().filter(|o| o.get("component").is_some_and(Value::is_string)).and_then(|o| o.get("id")).and_then(Value::as_str);
178 match id {
179 None => issues.push(issue(format!("{at}/components/{i}"), "a component needs an id and a component")),
180 Some(id) if ids.contains(id) => issues.push(issue(format!("{at}/components/{i}/id"), format!("duplicate id {id}"))),
181 Some(id) => {
182 ids.insert(id);
183 }
184 }
185 }
186 if !ids.contains("root") {
187 issues.push(issue(format!("{at}/components"), "no component has the id root"));
188 }
189 }
190 }
191 if let Some(bindings) = template.get("bindings") {
192 match bindings.as_array() {
193 None => issues.push(issue(format!("{at}/bindings"), "bindings is an array")),
194 Some(list) => {
195 for (i, b) in list.iter().enumerate() {
196 let bp = format!("{at}/bindings/{i}");
197 let b = b.as_object();
198 if b.and_then(|o| o.get("path")).and_then(Value::as_str).is_none_or(|p| !is_pointer(p)) {
199 issues.push(issue(format!("{bp}/path"), "path is a JSON pointer"));
200 }
201 if b.and_then(|o| o.get("source")).and_then(Value::as_str).is_none_or(|s| parse_source(s).is_none()) {
202 issues.push(issue(format!("{bp}/source"), "source is a scheme:name URI"));
203 }
204 }
205 }
206 }
207 }
208 }
209 }
210 }
211 if let Some(functions) = p.get("functions") {
212 let ok = functions.as_array().is_some_and(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty())));
213 if !ok {
214 issues.push(issue("/functions", "functions is a list of names or prefix* patterns"));
215 }
216 }
217 if let Some(records) = p.get("records") {
219 match records.as_object() {
220 None => issues.push(issue("/records", "records is an object of kinds")),
221 Some(kinds) => {
222 let mut names: Vec<&String> = kinds.keys().collect();
223 names.sort();
224 for kind in names {
225 let def = &kinds[kind];
226 let at = format!("/records/{kind}");
227 if !is_record_kind(kind) {
228 issues.push(issue(&at, "a kind is lowercase letters, digits and underscores"));
229 }
230 match def.as_object() {
231 None => issues.push(issue(&at, "a kind is {schema, title?, policy?}")),
232 Some(def) => {
233 issues.extend(validate_record_schema(def.get("schema").unwrap_or(&Value::Null), &format!("{at}/schema")));
234 if let Some(policy) = def.get("policy") {
236 issues.extend(super::record_policy::validate_record_policy(policy, &format!("{at}/policy")));
237 }
238 issues.extend(super::expressions::validate_record_computed(&Value::Object(def.clone()), &at));
240 issues.extend(super::records::validate_record_refs(&Value::Object(def.clone()), &at, p.get("records")));
242 }
243 }
244 }
245 }
246 }
247 }
248 if let Some(sources) = p.get("sources") {
251 match sources.as_array().filter(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty()))) {
252 None => issues.push(issue("/sources", "sources is a list of scheme:name sources or prefix* patterns")),
253 Some(list) => {
254 let allowed: Vec<String> = list.iter().filter_map(Value::as_str).map(str::to_string).collect();
255 for (id, template) in p.get("templates").and_then(Value::as_object).map(sorted_entries).into_iter().flatten() {
256 for (i, b) in template.get("bindings").and_then(Value::as_array).into_iter().flatten().enumerate() {
257 if let Some(source) = b.get("source").and_then(Value::as_str).filter(|s| parse_source(s).is_some()) {
258 if !source_allowed(Some(&allowed), source) {
259 issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("source {source} is not in the package's sources")));
260 }
261 }
262 }
263 }
264 }
265 }
266 }
267 for (id, template) in p.get("templates").and_then(Value::as_object).map(sorted_entries).into_iter().flatten() {
269 let Some(bindings) = template.as_object().and_then(|t| t.get("bindings")).and_then(Value::as_array) else { continue };
270 for (i, b) in bindings.iter().enumerate() {
271 let Some(parsed) = b.as_object().and_then(|o| o.get("source")).and_then(Value::as_str).and_then(parse_source) else { continue };
272 if parsed.scheme != RECORDS_SCHEME {
273 continue;
274 }
275 if !p.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(&parsed.name)) {
276 issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("records:{} names no declared kind", parsed.name)));
277 }
278 }
279 }
280 if let Some(queries) = p.get("queries") {
283 match queries.as_object() {
284 None => issues.push(issue("/queries", "queries is an object of named queries")),
285 Some(queries) => {
286 let mut names: Vec<&String> = queries.keys().collect();
287 names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
288 for name in names {
289 let at = format!("/queries/{name}");
290 if !is_query_name(name) {
291 issues.push(issue(at.clone(), "a query name is a letter, then letters, digits and underscores"));
292 }
293 let q = &queries[name];
294 for i in parse_records_json(q, None).1 {
295 issues.push(issue(format!("{at}{}", i.path), i.message));
296 }
297 let from = q.as_object().and_then(|o| o.get("from")).and_then(Value::as_str).map(super::js_trim).unwrap_or("");
298 if !from.is_empty() && !from.contains("${") && !p.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(from)) {
299 issues.push(issue(format!("{at}/from"), format!("records:{from} names no declared kind")));
300 } else if !from.is_empty() && !from.contains("${") && q.is_object() {
301 issues.extend(query_field_issues(q, from, p.get("records").and_then(|r| r.get(from)), &at));
302 }
303 let def = (!from.is_empty()).then(|| p.get("records").and_then(|r| r.get(from))).flatten().filter(|d| d.is_object());
305 if let (Some(def), Some(expand)) = (def, q.get("expand").and_then(Value::as_array)) {
306 let refs = def.get("refs");
307 for (i, f) in expand.iter().enumerate() {
308 let Some(f) = f.as_str().map(super::js_trim).filter(|f| !f.is_empty()) else { continue };
309 let known = match refs {
310 None => false,
311 Some(r) => r.as_object().is_some_and(|r| r.contains_key(f)),
312 };
313 if !known {
314 issues.push(issue(format!("{at}/expand/{i}"), format!("{f} is not a ref of {from}")));
315 }
316 }
317 }
318 }
319 }
320 }
321 }
322 for (i, expose) in p.get("exposes").and_then(Value::as_array).into_iter().flatten().enumerate() {
324 let Some(run) = expose.get("run").and_then(|r| r.get("records")).filter(|r| r.is_object()) else { continue };
325 let Some(name) = run.get("query").and_then(Value::as_str) else { continue };
326 let at = format!("/exposes/{i}/run/records/query");
327 let Some(q) = p.get("queries").and_then(Value::as_object).and_then(|qs| qs.get(name)) else {
328 issues.push(issue(at, format!("query {name} names no package query")));
329 continue;
330 };
331 let from = q.as_object().and_then(|o| o.get("from")).and_then(Value::as_str).map(super::js_trim).unwrap_or("");
332 if let Some(kind) = run.get("kind").and_then(Value::as_str) {
333 if !from.is_empty() && !from.contains("${") && from != kind {
334 issues.push(issue(at, format!("query {name} reads records:{from}, the tool's kind is {kind}")));
335 }
336 }
337 }
338 for (id, template) in p.get("templates").and_then(Value::as_object).map(sorted_entries).into_iter().flatten() {
339 let Some(bindings) = template.as_object().and_then(|t| t.get("bindings")).and_then(Value::as_array) else { continue };
340 for (i, b) in bindings.iter().enumerate() {
341 let Some(parsed) = b.as_object().and_then(|o| o.get("source")).and_then(Value::as_str).and_then(parse_source) else { continue };
342 let Some(name) = parsed.params.get("query") else { continue };
343 if parsed.scheme != RECORDS_SCHEME {
344 continue;
345 }
346 if !p.get("queries").and_then(Value::as_object).is_some_and(|q| q.contains_key(name)) {
347 issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("query {name} names no package query")));
348 }
349 }
350 }
351 issues.extend(super::components::validate_package_components(p, catalog_ids, view));
353 issues.extend(super::expressions::validate_package_expressions(p));
355 issues.extend(super::actions::validate_package_effects(p));
357 issues.extend(super::actions::validate_package_actions(p));
358 if let Some(tools) = p.get("tools") {
359 issues.extend(validate_tools(tools));
360 }
361 if let Some(upstreams) = p.get("upstreams") {
362 issues.extend(validate_upstreams(upstreams));
363 }
364 if let Some(exposes) = p.get("exposes") {
365 issues.extend(validate_package_exposes(exposes, p.get("records"), p.get("upstreams")));
366 }
367 if let Some(list) = p.get("extensions") {
368 issues.extend(validate_extensions(list, catalog_ids, extensions));
369 }
370 issues.extend(super::tests::validate_package_tests(p));
373 if issues.is_empty() {
374 issues.extend(super::tests::package_test_issues(pkg));
375 }
376 issues
377}
378
379fn validate_extensions<S: AsRef<str>>(list: &Value, catalog_ids: &[S], available: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
380 let Some(list) = list.as_array() else { return vec![issue("/extensions", "extensions is a list of {id, version, components?}")] };
381 let mut issues = Vec::new();
382 let mut seen: HashSet<&str> = HashSet::new();
383 for (i, ext) in list.iter().enumerate() {
384 let at = format!("/extensions/{i}");
385 let Some(ext) = ext.as_object() else {
386 issues.push(issue(at, "an extension is {id, version, components?}"));
387 continue;
388 };
389 let id = ext.get("id").and_then(Value::as_str).filter(|s| !s.is_empty());
390 if id.is_none() {
391 issues.push(issue(format!("{at}/id"), "id is a required string"));
392 }
393 let version = ext.get("version").and_then(Value::as_str).filter(|v| semver(v).is_some());
394 if version.is_none() {
395 issues.push(issue(format!("{at}/version"), "version is MAJOR.MINOR.PATCH"));
396 }
397 let components: Option<Vec<&str>> = match ext.get("components") {
398 None => Some(Vec::new()),
399 Some(c) => c.as_array().and_then(|l| l.iter().map(|c| c.as_str().filter(|s| !s.is_empty())).collect()),
400 };
401 if components.is_none() {
402 issues.push(issue(format!("{at}/components"), "components is a list of component names"));
403 }
404 let Some(id) = id else { continue };
405 if !seen.insert(id) {
406 issues.push(issue(format!("{at}/id"), format!("duplicate extension {id}")));
407 continue;
408 }
409 let have = available.and_then(|a| a.iter().find(|a| a.id == id));
410 if !catalog_ids.iter().any(|c| c.as_ref() == id) || (available.is_some() && have.is_none()) {
411 issues.push(issue(format!("{at}/id"), format!("extension {id} is not available on this host")));
412 continue;
413 }
414 let Some(have) = have else { continue };
415 if let (Some(version), Some(have_version)) = (version, have.version.as_deref()) {
416 if !version_satisfies(have_version, version) {
417 issues.push(issue(format!("{at}/version"), format!("extension {id} {have_version} does not satisfy ^{version}")));
418 }
419 }
420 if let Some(have_components) = &have.components {
421 for (j, name) in components.iter().flatten().enumerate() {
422 if !have_components.iter().any(|c| c == name) {
423 issues.push(issue(format!("{at}/components/{j}"), format!("extension {id} has no component {name}")));
424 }
425 }
426 }
427 }
428 issues
429}
430
431pub fn source_allowed(allowed: Option<&[String]>, source: &str) -> bool {
436 let Some(allowed) = allowed else { return true };
437 let Some(parsed) = parse_source(source) else { return false };
438 let name = format!("{}:{}", parsed.scheme, parsed.name);
439 allowed.iter().any(|p| matches_pattern(p, &name) || matches_pattern(p, source))
440}
441
442pub fn package_sources(pkg: &Value) -> Option<Vec<String>> {
444 pkg.get("sources").and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).map(str::to_string).collect())
445}
446
447pub fn template_messages(pkg: &Value, template_id: &str, surface_id: &str, data: Option<&Value>) -> Option<Vec<Value>> {
452 let template = pkg.get("templates").and_then(|t| t.get(template_id)).filter(|t| super::js_truthy(t))?;
453 let version = A2UI_VERSION;
454 let mut create = Map::new();
455 create.insert("surfaceId".into(), Value::String(surface_id.into()));
456 if let Some(catalog_id) = pkg.get("catalogId") {
457 create.insert("catalogId".into(), catalog_id.clone());
458 }
459 let mut components = Map::new();
460 components.insert("surfaceId".into(), Value::String(surface_id.into()));
461 if let Some(list) = template.get("components") {
462 components.insert("components".into(), list.clone());
463 }
464 let mut out = vec![json!({"version": version, "createSurface": create}), json!({"version": version, "updateComponents": components})];
465 if let Some(merged) = merge_data(template.get("data"), data) {
466 out.push(json!({"version": version, "updateDataModel": {"surfaceId": surface_id, "path": "/", "value": merged}}));
467 }
468 for b in template.get("bindings").and_then(Value::as_array).into_iter().flatten() {
469 let mut bind = Map::new();
470 bind.insert("surfaceId".into(), Value::String(surface_id.into()));
471 for key in ["path", "source"] {
472 if let Some(v) = b.get(key) {
473 bind.insert(key.into(), v.clone());
474 }
475 }
476 out.push(json!({"version": version, "bindDataModel": bind}));
477 }
478 Some(out)
479}
480
481pub fn merge_data(base: Option<&Value>, over: Option<&Value>) -> Option<Value> {
484 match (base, over) {
485 (base, None) => base.cloned(),
486 (Some(Value::Object(b)), Some(Value::Object(o))) => {
487 let mut merged = b.clone();
488 for (k, v) in o {
489 merged.insert(k.clone(), v.clone());
490 }
491 Some(Value::Object(merged))
492 }
493 (_, Some(over)) => Some(over.clone()),
494 }
495}
496
497pub const QUERY_NAME_PATTERN: &str = "^[a-z][A-Za-z0-9_]{0,31}$";
499
500pub fn is_query_name(name: &str) -> bool {
502 let b = name.as_bytes();
503 !b.is_empty() && b.len() <= 32 && b[0].is_ascii_lowercase() && b[1..].iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_')
504}
505
506pub(crate) fn sorted_entries(map: &serde_json::Map<String, Value>) -> Vec<(&String, &Value)> {
509 let mut entries: Vec<(&String, &Value)> = map.iter().collect();
510 entries.sort_by(|a, b| a.0.cmp(b.0));
511 entries
512}
513
514fn query_field_issues(json: &Value, kind: &str, def: Option<&Value>, at: &str) -> Vec<PackageIssue> {
521 use super::query::RecordsAggregateOp as Op;
522 let (query, _) = parse_records_json(json, Some(kind));
523 let row = super::expressions::record_row_type(def);
524 let row = row.as_object().cloned().unwrap_or_default();
525 let has = |f: &str| row.contains_key(f);
526 let mut outputs: HashSet<String> = HashSet::from(["count".to_string()]);
527 outputs.extend(query.aggregate.iter().map(|a| a.name.clone()));
528 outputs.extend(query.group.iter().map(|g| g.field.clone()));
529 let mut issues = Vec::new();
530 let mut seen: HashSet<(String, String)> = HashSet::new();
531 let mut unknown = |issues: &mut Vec<PackageIssue>, key: String, f: &str, also: bool| {
532 if has(f) || also || !seen.insert((key.clone(), f.to_string())) {
533 return;
534 }
535 issues.push(issue(format!("{at}/{key}"), format!("{f} is no field of records:{kind}")));
536 };
537 let numeric = |issues: &mut Vec<PackageIssue>, key: String, op: &str, f: &str| {
538 let t = row.get(f).map(crate::cel::required_type).unwrap_or_else(|| json!("dyn"));
539 if t != json!("number") && t != json!("dyn") {
540 let name = match &t {
541 Value::String(s) => s.clone(),
542 Value::Array(_) => "list".into(),
543 _ => "map".into(),
544 };
545 issues.push(issue(format!("{at}/{key}"), format!("{op} reads a number: {f} is {name}")));
546 }
547 };
548 let listed = |k: &str| json.get(k).is_some_and(Value::is_array);
549 for f in &query.where_ {
550 unknown(&mut issues, "where".into(), &f.field, false);
551 }
552 for alt in &query.or {
553 for f in alt {
554 unknown(&mut issues, "or".into(), &f.field, false);
555 }
556 }
557 for (i, g) in query.group.iter().enumerate() {
558 unknown(&mut issues, if listed("group") { format!("group/{i}") } else { "group".into() }, &g.field, false);
559 }
560 for a in &query.aggregate {
561 let Some(f) = &a.field else { continue };
562 unknown(&mut issues, format!("aggregate/{}", a.name), f, false);
563 if matches!(a.op, Op::Sum | Op::Avg) && has(f) {
564 numeric(&mut issues, format!("aggregate/{}", a.name), if matches!(a.op, Op::Sum) { "sum" } else { "avg" }, f);
565 }
566 }
567 if let Some(f) = &query.sum {
568 unknown(&mut issues, "sum".into(), f, false);
569 if has(f) {
570 numeric(&mut issues, "sum".into(), "sum", f);
571 }
572 }
573 if let Some(m) = &query.month {
574 unknown(&mut issues, "month".into(), &m.field, false);
575 }
576 if let Some(b) = &query.before {
577 unknown(&mut issues, "before".into(), &b.field, false);
578 }
579 for (i, s) in query.sort.iter().enumerate() {
580 unknown(&mut issues, if listed("sort") { format!("sort/{i}") } else { "sort".into() }, &s.field, outputs.contains(&s.field));
581 }
582 for r in &query.running {
583 unknown(&mut issues, format!("running/{}", r.field), &r.field, outputs.contains(&r.field));
584 }
585 issues
586}