1use std::collections::BTreeSet;
40use std::path::Path;
41
42use nmbrs_workload::edit::Anchor;
43use nmbrs_workload::report::ReportItem;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum AnchorFlag {
49 None,
51 AtRoot,
53 AtScenario(String),
55 AtPhase(String),
57 AtOp { phase: String, op: String },
59 ContextualAuto,
62 ContextualRoot,
66 ContextualScenario,
69 ContextualPhase,
72 ContextualOp,
74}
75
76impl AnchorFlag {
77 pub fn parse_at(value: &str) -> Result<AnchorFlag, String> {
80 match value {
81 "root" => Ok(AnchorFlag::AtRoot),
82 v if v.starts_with("scenario:") => {
83 let name = v.trim_start_matches("scenario:").trim();
84 if name.is_empty() {
85 Err("--at scenario: requires a scenario name".to_string())
86 } else {
87 Ok(AnchorFlag::AtScenario(name.to_string()))
88 }
89 }
90 v if v.starts_with("phase:") => {
91 let name = v.trim_start_matches("phase:").trim();
92 if name.is_empty() {
93 Err("--at phase: requires a phase name".to_string())
94 } else {
95 Ok(AnchorFlag::AtPhase(name.to_string()))
96 }
97 }
98 v if v.starts_with("op:") => {
99 let body = v.trim_start_matches("op:").trim();
100 let (phase, op) = body
101 .split_once('.')
102 .ok_or_else(|| format!("--at op:{body}: expected `op:<phase>.<op>`"))?;
103 if phase.is_empty() || op.is_empty() {
104 return Err(format!(
105 "--at op:{body}: phase and op names must be non-empty"
106 ));
107 }
108 Ok(AnchorFlag::AtOp {
109 phase: phase.to_string(),
110 op: op.to_string(),
111 })
112 }
113 _ => Err(format!(
114 "--at value '{value}': expected one of \
115 `root`, `scenario:<name>`, `phase:<name>`, `op:<phase>.<op>`"
116 )),
117 }
118 }
119
120 pub fn parse_contextual(value: &str) -> Result<AnchorFlag, String> {
122 match value {
123 "auto" => Ok(AnchorFlag::ContextualAuto),
124 "root" => Ok(AnchorFlag::ContextualRoot),
125 "scenario" => Ok(AnchorFlag::ContextualScenario),
126 "phase" => Ok(AnchorFlag::ContextualPhase),
127 "op" => Ok(AnchorFlag::ContextualOp),
128 _ => Err(format!(
129 "--contextual value '{value}': expected one of \
130 `auto`, `root`, `scenario`, `phase`, `op`"
131 )),
132 }
133 }
134}
135
136#[derive(Debug, Clone)]
139pub struct AnchorResolution {
140 pub anchor: Anchor,
141 pub diagnostic: String,
142}
143
144pub fn resolve(
148 db_path: &Path,
149 item: &ReportItem,
150 flag: &AnchorFlag,
151) -> Result<AnchorResolution, String> {
152 match flag {
157 AnchorFlag::None | AnchorFlag::AtRoot => {
158 return Ok(AnchorResolution {
159 anchor: Anchor::Root,
160 diagnostic: "anchor: workload root (default)".to_string(),
161 });
162 }
163 AnchorFlag::AtScenario(name) => {
164 return Ok(AnchorResolution {
165 anchor: Anchor::Scenario(name.clone()),
166 diagnostic: format!("anchor: scenario:{name} (explicit --at)"),
167 });
168 }
169 AnchorFlag::AtPhase(name) => {
170 return Ok(AnchorResolution {
171 anchor: Anchor::Phase(name.clone()),
172 diagnostic: format!("anchor: phase:{name} (explicit --at)"),
173 });
174 }
175 AnchorFlag::AtOp { phase, op } => {
176 return Ok(AnchorResolution {
177 anchor: Anchor::Op {
178 phase: phase.clone(),
179 op: op.clone(),
180 },
181 diagnostic: format!("anchor: op:{phase}.{op} (explicit --at)"),
182 });
183 }
184 _ => {}
185 }
186
187 let conn = rusqlite::Connection::open(db_path)
191 .map_err(|e| format!("open session db '{}': {e}", db_path.display(),))?;
192
193 let scenarios = scenarios_in_session(&conn)?;
194 let phases = phases_matching_filter(&conn, item)?;
195
196 match flag {
197 AnchorFlag::ContextualRoot => Ok(AnchorResolution {
198 anchor: Anchor::Root,
199 diagnostic: "anchor: workload root (--contextual root)".to_string(),
200 }),
201
202 AnchorFlag::ContextualScenario => match scenarios.len() {
203 0 => Err("no scenario recorded in session metadata; \
204 cannot anchor at scenario level"
205 .to_string()),
206 1 => {
207 let s = scenarios.iter().next().unwrap().clone();
208 Ok(AnchorResolution {
209 anchor: Anchor::Scenario(s.clone()),
210 diagnostic: format!(
211 "anchor: scenario:{s} (--contextual scenario; \
212 single scenario in session)"
213 ),
214 })
215 }
216 _ => Err(format!(
217 "--contextual scenario: session spans multiple scenarios \
218 ({:?}); pick one with `--at scenario:<name>` or use a \
219 broader `--contextual root`",
220 scenarios.iter().collect::<Vec<_>>(),
221 )),
222 },
223
224 AnchorFlag::ContextualPhase => match phases.len() {
225 0 => Err("no phases in session match the item's filter; \
226 cannot anchor at phase level"
227 .to_string()),
228 1 => {
229 let p = phases.iter().next().unwrap().clone();
230 Ok(AnchorResolution {
231 anchor: Anchor::Phase(p.clone()),
232 diagnostic: format!(
233 "anchor: phase:{p} (--contextual phase; \
234 single phase matched filter)"
235 ),
236 })
237 }
238 _ => Err(format!(
239 "--contextual phase: filter matches multiple phases \
240 ({phases:?}); add a `where phase=<name>` filter to \
241 narrow it, or use `--at phase:<name>` to pick one",
242 )),
243 },
244
245 AnchorFlag::ContextualOp => Err("--contextual op: op-template anchoring needs an \
246 `op_template` label key that the runtime doesn't \
247 emit yet; this is a planned schema extension. Use \
248 `--contextual phase` for now, or `--at op:<phase>.<op>` \
249 to anchor explicitly (the YAML edit primitive accepts \
250 the path)."
251 .to_string()),
252
253 AnchorFlag::ContextualAuto => {
254 match (scenarios.len(), phases.len()) {
259 (1, 1) => {
260 let p = phases.iter().next().unwrap().clone();
261 Ok(AnchorResolution {
262 anchor: Anchor::Phase(p.clone()),
263 diagnostic: format!(
264 "anchor: phase:{p} (--contextual auto; \
265 unique phase under one scenario)"
266 ),
267 })
268 }
269 (1, _) => {
270 let s = scenarios.iter().next().unwrap().clone();
271 Ok(AnchorResolution {
272 anchor: Anchor::Scenario(s.clone()),
273 diagnostic: format!(
274 "anchor: scenario:{s} (--contextual auto; \
275 one scenario, multiple phases)"
276 ),
277 })
278 }
279 _ => Ok(AnchorResolution {
280 anchor: Anchor::Root,
281 diagnostic: "anchor: workload root (--contextual auto; \
282 data spans multiple scenarios)"
283 .to_string(),
284 }),
285 }
286 }
287
288 _ => unreachable!("--at branches handled above"),
289 }
290}
291
292fn scenarios_in_session(conn: &rusqlite::Connection) -> Result<BTreeSet<String>, String> {
296 let mut out = BTreeSet::new();
299 if let Some(v) =
300 nmbrs_metrics::reporters::sqlite::latest_execution_metadata_value(conn, "scenario")
301 {
302 out.insert(v);
303 }
304 Ok(out)
305}
306
307fn phases_matching_filter(
318 conn: &rusqlite::Connection,
319 item: &ReportItem,
320) -> Result<BTreeSet<String>, String> {
321 let _ = item; let mut out = BTreeSet::new();
323 let mut stmt = conn
324 .prepare("SELECT DISTINCT value FROM instance_label WHERE key = 'phase'")
325 .map_err(|e| format!("phase distinct query: {e}"))?;
326 let rows = stmt
327 .query_map([], |r| r.get::<_, String>(0))
328 .map_err(|e| format!("phase rows: {e}"))?;
329 for row in rows {
330 let v = row.map_err(|e| format!("phase row decode: {e}"))?;
331 out.insert(v);
332 }
333 Ok(out)
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use nmbrs_workload::report::{Kind, ReportItem};
340
341 fn item() -> ReportItem {
342 ReportItem {
343 kind: Kind::Plot,
344 name: "demo".to_string(),
345 body: "over cycle".to_string(),
346 ..Default::default()
347 }
348 }
349
350 fn make_db(label: &str, scenarios: &[&str], phases: &[&str]) -> std::path::PathBuf {
351 let dir = std::env::temp_dir().join(format!("nmbrs-anchor-{label}-{}", std::process::id()));
352 let _ = std::fs::remove_dir_all(&dir);
353 std::fs::create_dir_all(&dir).unwrap();
354 let path = dir.join("metrics.db");
355
356 let conn = rusqlite::Connection::open(&path).unwrap();
357 conn.execute_batch(
362 r#"
363 CREATE TABLE session_metadata (key TEXT, value TEXT);
364 CREATE TABLE instance_label (
365 instance_id INTEGER NOT NULL,
366 key TEXT NOT NULL,
367 value TEXT NOT NULL
368 );
369 "#,
370 )
371 .unwrap();
372 for s in scenarios {
373 conn.execute(
374 "INSERT INTO session_metadata (key, value) VALUES ('scenario', ?1)",
375 [s],
376 )
377 .unwrap();
378 }
379 for (i, p) in phases.iter().enumerate() {
380 let id = (i + 1) as i64;
381 conn.execute(
382 "INSERT INTO instance_label (instance_id, key, value) VALUES (?1, 'phase', ?2)",
383 rusqlite::params![id, p],
384 )
385 .unwrap();
386 }
387 path
388 }
389
390 #[test]
391 fn parse_at_recognises_all_scopes() {
392 assert_eq!(AnchorFlag::parse_at("root").unwrap(), AnchorFlag::AtRoot);
393 assert_eq!(
394 AnchorFlag::parse_at("scenario:foo").unwrap(),
395 AnchorFlag::AtScenario("foo".into())
396 );
397 assert_eq!(
398 AnchorFlag::parse_at("phase:setup").unwrap(),
399 AnchorFlag::AtPhase("setup".into())
400 );
401 assert_eq!(
402 AnchorFlag::parse_at("op:setup.step").unwrap(),
403 AnchorFlag::AtOp {
404 phase: "setup".into(),
405 op: "step".into()
406 }
407 );
408 }
409
410 #[test]
411 fn parse_at_rejects_malformed() {
412 assert!(AnchorFlag::parse_at("scenario:").is_err());
413 assert!(AnchorFlag::parse_at("op:setup").is_err());
414 assert!(AnchorFlag::parse_at("op:setup.").is_err());
415 assert!(AnchorFlag::parse_at("nonsense").is_err());
416 }
417
418 #[test]
419 fn parse_contextual_recognises_all_modes() {
420 assert_eq!(
421 AnchorFlag::parse_contextual("auto").unwrap(),
422 AnchorFlag::ContextualAuto
423 );
424 assert_eq!(
425 AnchorFlag::parse_contextual("root").unwrap(),
426 AnchorFlag::ContextualRoot
427 );
428 assert_eq!(
429 AnchorFlag::parse_contextual("phase").unwrap(),
430 AnchorFlag::ContextualPhase
431 );
432 assert!(AnchorFlag::parse_contextual("garbage").is_err());
433 }
434
435 #[test]
436 fn resolve_none_yields_root_without_db_lookup() {
437 let r = resolve(
439 std::path::Path::new("/nonexistent/db"),
440 &item(),
441 &AnchorFlag::None,
442 )
443 .unwrap();
444 assert!(matches!(r.anchor, Anchor::Root));
445 assert!(r.diagnostic.contains("default"));
446 }
447
448 #[test]
449 fn resolve_at_root_yields_root_without_db_lookup() {
450 let r = resolve(
451 std::path::Path::new("/nonexistent/db"),
452 &item(),
453 &AnchorFlag::AtRoot,
454 )
455 .unwrap();
456 assert!(matches!(r.anchor, Anchor::Root));
457 }
458
459 #[test]
460 fn resolve_at_scenario_yields_scenario_without_db_lookup() {
461 let r = resolve(
462 std::path::Path::new("/nonexistent/db"),
463 &item(),
464 &AnchorFlag::AtScenario("foo".into()),
465 )
466 .unwrap();
467 match r.anchor {
468 Anchor::Scenario(s) => assert_eq!(s, "foo"),
469 other => panic!("expected Scenario, got {other:?}"),
470 }
471 }
472
473 #[test]
474 fn resolve_contextual_auto_picks_phase_when_unique() {
475 let db = make_db("auto_phase", &["default"], &["setup"]);
476 let r = resolve(&db, &item(), &AnchorFlag::ContextualAuto).unwrap();
477 match r.anchor {
478 Anchor::Phase(p) => assert_eq!(p, "setup"),
479 other => panic!("expected Phase, got {other:?}"),
480 }
481 assert!(r.diagnostic.contains("phase:setup"));
482 }
483
484 #[test]
485 fn resolve_contextual_auto_picks_scenario_when_phases_branch() {
486 let db = make_db("auto_scenario", &["default"], &["a", "b"]);
487 let r = resolve(&db, &item(), &AnchorFlag::ContextualAuto).unwrap();
488 match r.anchor {
489 Anchor::Scenario(s) => assert_eq!(s, "default"),
490 other => panic!("expected Scenario, got {other:?}"),
491 }
492 }
493
494 #[test]
495 fn resolve_contextual_phase_errors_on_multiple_phases() {
496 let db = make_db("phase_multi", &["default"], &["a", "b"]);
497 let err = resolve(&db, &item(), &AnchorFlag::ContextualPhase).unwrap_err();
498 assert!(err.contains("multiple phases"), "got: {err}");
499 assert!(err.contains("--at phase:"), "should hint at fix: {err}");
500 }
501
502 #[test]
503 fn resolve_contextual_phase_succeeds_on_unique_phase() {
504 let db = make_db("phase_unique", &["default"], &["only"]);
505 let r = resolve(&db, &item(), &AnchorFlag::ContextualPhase).unwrap();
506 match r.anchor {
507 Anchor::Phase(p) => assert_eq!(p, "only"),
508 other => panic!("expected Phase, got {other:?}"),
509 }
510 }
511
512 #[test]
513 fn resolve_contextual_scenario_succeeds_on_single_scenario() {
514 let db = make_db("scen_unique", &["only_scenario"], &[]);
515 let r = resolve(&db, &item(), &AnchorFlag::ContextualScenario).unwrap();
516 match r.anchor {
517 Anchor::Scenario(s) => assert_eq!(s, "only_scenario"),
518 other => panic!("expected Scenario, got {other:?}"),
519 }
520 }
521
522 #[test]
523 fn resolve_contextual_op_errors_with_schema_gap_message() {
524 let db = make_db("op_unsupported", &["default"], &["a"]);
525 let err = resolve(&db, &item(), &AnchorFlag::ContextualOp).unwrap_err();
526 assert!(err.contains("op-template anchoring"));
527 assert!(
528 err.contains("schema extension"),
529 "should call out the schema gap: {err}"
530 );
531 assert!(
532 err.contains("--contextual phase"),
533 "should suggest the available alternative: {err}"
534 );
535 }
536
537 #[test]
538 fn resolve_contextual_root_skips_data_inspection_decisions() {
539 let db = make_db("ctx_root", &["default"], &["a", "b"]);
540 let r = resolve(&db, &item(), &AnchorFlag::ContextualRoot).unwrap();
541 assert!(matches!(r.anchor, Anchor::Root));
542 assert!(r.diagnostic.contains("--contextual root"));
543 }
544}