1use nmbrs_workload::model::{StopConditionSpec, WorkloadPhase};
36
37const ACTIVITY_FAMILIES: [&str; 18] = [
42 "cycles_servicetime",
43 "cycles_waittime",
44 "cycles_responsetime",
45 "result_success",
46 "result_failure",
47 "result_total",
48 "cycles_total",
49 "skips_total",
50 "errors_total",
51 "attempt_total",
52 "attempt_success",
53 "attempt_failure",
54 "stanzas_total",
55 "daemon_cancelled_total",
56 "daemon_errors_total",
57 "result_elements",
58 "result_bytes",
59 "tries",
60];
61
62pub fn lint_workload<'a>(
66 workload_stop_when: &[StopConditionSpec],
67 phases: impl Iterator<Item = (&'a str, &'a WorkloadPhase)> + Clone,
68) -> Result<Vec<String>, String> {
69 let mut warnings = Vec::new();
70
71 let mut known: Vec<String> = ACTIVITY_FAMILIES.iter().map(|s| s.to_string()).collect();
75 for (_, phase) in phases.clone() {
76 known.extend(phase.metrics.keys().cloned());
77 }
78
79 for sc in workload_stop_when {
80 lint_metric_families(&sc.when, &known, "workload `stop_when`", &mut warnings);
81 }
82
83 for (name, phase) in phases {
84 let ctx = format!("phase '{name}'");
85 if let Some(spec) = phase.errors.as_deref() {
86 lint_error_router(spec, &ctx, &mut warnings)?;
87 }
88 for op in &phase.ops {
89 if let Some(serde_json::Value::String(spec)) = op.params.get("errors") {
90 lint_error_router(spec, &format!("{ctx} op '{}'", op.name), &mut warnings)?;
91 }
92 }
93 for sc in &phase.stop_when {
94 lint_metric_families(
95 &sc.when,
96 &known,
97 &format!("{ctx} `stop_when`"),
98 &mut warnings,
99 );
100 }
101 if let Some(gate) = &phase.continue_if {
102 lint_metric_families(
103 &gate.when,
104 &known,
105 &format!("{ctx} `continue_if`"),
106 &mut warnings,
107 );
108 }
109 if let Some(poll) = &phase.poll {
110 lint_metric_families(
111 &poll.until,
112 &known,
113 &format!("{ctx} `poll.until`"),
114 &mut warnings,
115 );
116 }
117 }
118
119 Ok(warnings)
120}
121
122fn lint_error_router(spec: &str, ctx: &str, warnings: &mut Vec<String>) -> Result<(), String> {
126 if spec.contains('{') {
127 return Ok(()); }
129 let router = nmbrs_errorhandler::ErrorRouter::parse(spec)
130 .map_err(|e| format!("{ctx}: invalid `errors:` spec '{spec}': {e}"))?;
131 if !router.has_catch_all() {
132 warnings.push(format!(
133 "{ctx}: `errors: \"{spec}\"` has no catch-all rule — an error \
134 class matching no pattern falls through to `stop`; end the \
135 spec with `.*:<verbs>` to make the default explicit"
136 ));
137 }
138 Ok(())
139}
140
141fn lint_metric_families(src: &str, known: &[String], ctx: &str, warnings: &mut Vec<String>) {
145 for family in metric_families(src) {
146 if family.contains('{') {
147 continue; }
149 if !known.iter().any(|k| k == &family) {
150 warnings.push(format!(
151 "{ctx}: `metric()` selector names family '{family}', which is \
152 not a built-in instrument family or a declared phase metric — \
153 an unregistered family reads 0.0 silently; check the spelling \
154 (predicate: {src})"
155 ));
156 }
157 }
158}
159
160fn metric_families(src: &str) -> Vec<String> {
165 let bytes = src.as_bytes();
166 let mut out = Vec::new();
167 let mut i = 0;
168 while let Some(rel) = src[i..].find("metric") {
169 let start = i + rel;
170 i = start + "metric".len();
171 if start > 0 {
173 let prev = bytes[start - 1] as char;
174 if prev.is_alphanumeric() || prev == '_' {
175 continue;
176 }
177 }
178 let mut j = i;
180 if src[j..].starts_with("_window") {
181 j += "_window".len();
182 }
183 let rest = src[j..].trim_start();
184 let Some(after_paren) = rest.strip_prefix('(') else {
185 continue;
186 };
187 let sel = after_paren.trim_start();
188 let Some(quote) = sel.chars().next().filter(|c| *c == '\'' || *c == '"') else {
189 continue; };
191 let body = &sel[1..];
192 let Some(end) = body.find(quote) else {
193 continue;
194 };
195 let selector = &body[..end];
196 let family = selector.split(',').next().unwrap_or("").trim().to_string();
197 if !family.is_empty() {
198 out.push(family);
199 }
200 }
201 out
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn extracts_families_from_both_call_forms() {
210 let src = "metric('cycles_total, phase=ingest', 'count') >= 5 \
211 && metric_window(\"result_failure\", 'rate') < 0.1";
212 assert_eq!(
213 metric_families(src),
214 vec!["cycles_total".to_string(), "result_failure".to_string()]
215 );
216 }
217
218 #[test]
219 fn ignores_non_literal_selectors_and_other_identifiers() {
220 assert!(metric_families("my_metric('x','count') + metric(fam, 'count')").is_empty());
221 }
222
223 #[test]
224 fn unknown_family_warns_known_family_does_not() {
225 let known: Vec<String> = ACTIVITY_FAMILIES.iter().map(|s| s.to_string()).collect();
226 let mut w = Vec::new();
227 lint_metric_families(
228 "metric('cycles_totl, phase=x', 'count') > 0",
229 &known,
230 "t",
231 &mut w,
232 );
233 assert_eq!(w.len(), 1, "typo'd family must warn: {w:?}");
234 w.clear();
235 lint_metric_families(
236 "metric('cycles_total, phase=x', 'count') > 0",
237 &known,
238 "t",
239 &mut w,
240 );
241 assert!(w.is_empty(), "known family must not warn: {w:?}");
242 }
243
244 #[test]
245 fn router_without_catch_all_warns_with_catch_all_does_not() {
246 let mut w = Vec::new();
247 lint_error_router("TimeoutError:retry,warn", "t", &mut w).unwrap();
248 assert_eq!(w.len(), 1, "no catch-all must warn: {w:?}");
249 w.clear();
250 lint_error_router("TimeoutError:retry,warn;.*:counter", "t", &mut w).unwrap();
251 assert!(w.is_empty(), "catch-all present must not warn: {w:?}");
252 }
253
254 #[test]
255 fn bad_router_spec_is_a_load_error_interpolated_is_deferred() {
256 let mut w = Vec::new();
257 let err = lint_error_router(".*:sotp", "phase 'p'", &mut w).unwrap_err();
258 assert!(err.contains("unknown error handler"), "got: {err}");
259 lint_error_router("{overload_policy}", "t", &mut w)
260 .expect("interpolated spec must be deferred, not parsed");
261 }
262}