use std::collections::BTreeSet;
use std::path::Path;
use nmbrs_workload::edit::Anchor;
use nmbrs_workload::report::ReportItem;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnchorFlag {
None,
AtRoot,
AtScenario(String),
AtPhase(String),
AtOp { phase: String, op: String },
ContextualAuto,
ContextualRoot,
ContextualScenario,
ContextualPhase,
ContextualOp,
}
impl AnchorFlag {
pub fn parse_at(value: &str) -> Result<AnchorFlag, String> {
match value {
"root" => Ok(AnchorFlag::AtRoot),
v if v.starts_with("scenario:") => {
let name = v.trim_start_matches("scenario:").trim();
if name.is_empty() {
Err("--at scenario: requires a scenario name".to_string())
} else {
Ok(AnchorFlag::AtScenario(name.to_string()))
}
}
v if v.starts_with("phase:") => {
let name = v.trim_start_matches("phase:").trim();
if name.is_empty() {
Err("--at phase: requires a phase name".to_string())
} else {
Ok(AnchorFlag::AtPhase(name.to_string()))
}
}
v if v.starts_with("op:") => {
let body = v.trim_start_matches("op:").trim();
let (phase, op) = body
.split_once('.')
.ok_or_else(|| format!("--at op:{body}: expected `op:<phase>.<op>`"))?;
if phase.is_empty() || op.is_empty() {
return Err(format!(
"--at op:{body}: phase and op names must be non-empty"
));
}
Ok(AnchorFlag::AtOp {
phase: phase.to_string(),
op: op.to_string(),
})
}
_ => Err(format!(
"--at value '{value}': expected one of \
`root`, `scenario:<name>`, `phase:<name>`, `op:<phase>.<op>`"
)),
}
}
pub fn parse_contextual(value: &str) -> Result<AnchorFlag, String> {
match value {
"auto" => Ok(AnchorFlag::ContextualAuto),
"root" => Ok(AnchorFlag::ContextualRoot),
"scenario" => Ok(AnchorFlag::ContextualScenario),
"phase" => Ok(AnchorFlag::ContextualPhase),
"op" => Ok(AnchorFlag::ContextualOp),
_ => Err(format!(
"--contextual value '{value}': expected one of \
`auto`, `root`, `scenario`, `phase`, `op`"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct AnchorResolution {
pub anchor: Anchor,
pub diagnostic: String,
}
pub fn resolve(
db_path: &Path,
item: &ReportItem,
flag: &AnchorFlag,
) -> Result<AnchorResolution, String> {
match flag {
AnchorFlag::None | AnchorFlag::AtRoot => {
return Ok(AnchorResolution {
anchor: Anchor::Root,
diagnostic: "anchor: workload root (default)".to_string(),
});
}
AnchorFlag::AtScenario(name) => {
return Ok(AnchorResolution {
anchor: Anchor::Scenario(name.clone()),
diagnostic: format!("anchor: scenario:{name} (explicit --at)"),
});
}
AnchorFlag::AtPhase(name) => {
return Ok(AnchorResolution {
anchor: Anchor::Phase(name.clone()),
diagnostic: format!("anchor: phase:{name} (explicit --at)"),
});
}
AnchorFlag::AtOp { phase, op } => {
return Ok(AnchorResolution {
anchor: Anchor::Op {
phase: phase.clone(),
op: op.clone(),
},
diagnostic: format!("anchor: op:{phase}.{op} (explicit --at)"),
});
}
_ => {}
}
let conn = rusqlite::Connection::open(db_path)
.map_err(|e| format!("open session db '{}': {e}", db_path.display(),))?;
let scenarios = scenarios_in_session(&conn)?;
let phases = phases_matching_filter(&conn, item)?;
match flag {
AnchorFlag::ContextualRoot => Ok(AnchorResolution {
anchor: Anchor::Root,
diagnostic: "anchor: workload root (--contextual root)".to_string(),
}),
AnchorFlag::ContextualScenario => match scenarios.len() {
0 => Err("no scenario recorded in session metadata; \
cannot anchor at scenario level"
.to_string()),
1 => {
let s = scenarios.iter().next().unwrap().clone();
Ok(AnchorResolution {
anchor: Anchor::Scenario(s.clone()),
diagnostic: format!(
"anchor: scenario:{s} (--contextual scenario; \
single scenario in session)"
),
})
}
_ => Err(format!(
"--contextual scenario: session spans multiple scenarios \
({:?}); pick one with `--at scenario:<name>` or use a \
broader `--contextual root`",
scenarios.iter().collect::<Vec<_>>(),
)),
},
AnchorFlag::ContextualPhase => match phases.len() {
0 => Err("no phases in session match the item's filter; \
cannot anchor at phase level"
.to_string()),
1 => {
let p = phases.iter().next().unwrap().clone();
Ok(AnchorResolution {
anchor: Anchor::Phase(p.clone()),
diagnostic: format!(
"anchor: phase:{p} (--contextual phase; \
single phase matched filter)"
),
})
}
_ => Err(format!(
"--contextual phase: filter matches multiple phases \
({phases:?}); add a `where phase=<name>` filter to \
narrow it, or use `--at phase:<name>` to pick one",
)),
},
AnchorFlag::ContextualOp => Err("--contextual op: op-template anchoring needs an \
`op_template` label key that the runtime doesn't \
emit yet; this is a planned schema extension. Use \
`--contextual phase` for now, or `--at op:<phase>.<op>` \
to anchor explicitly (the YAML edit primitive accepts \
the path)."
.to_string()),
AnchorFlag::ContextualAuto => {
match (scenarios.len(), phases.len()) {
(1, 1) => {
let p = phases.iter().next().unwrap().clone();
Ok(AnchorResolution {
anchor: Anchor::Phase(p.clone()),
diagnostic: format!(
"anchor: phase:{p} (--contextual auto; \
unique phase under one scenario)"
),
})
}
(1, _) => {
let s = scenarios.iter().next().unwrap().clone();
Ok(AnchorResolution {
anchor: Anchor::Scenario(s.clone()),
diagnostic: format!(
"anchor: scenario:{s} (--contextual auto; \
one scenario, multiple phases)"
),
})
}
_ => Ok(AnchorResolution {
anchor: Anchor::Root,
diagnostic: "anchor: workload root (--contextual auto; \
data spans multiple scenarios)"
.to_string(),
}),
}
}
_ => unreachable!("--at branches handled above"),
}
}
fn scenarios_in_session(conn: &rusqlite::Connection) -> Result<BTreeSet<String>, String> {
let mut out = BTreeSet::new();
if let Some(v) =
nmbrs_metrics::reporters::sqlite::latest_execution_metadata_value(conn, "scenario")
{
out.insert(v);
}
Ok(out)
}
fn phases_matching_filter(
conn: &rusqlite::Connection,
item: &ReportItem,
) -> Result<BTreeSet<String>, String> {
let _ = item; let mut out = BTreeSet::new();
let mut stmt = conn
.prepare("SELECT DISTINCT value FROM instance_label WHERE key = 'phase'")
.map_err(|e| format!("phase distinct query: {e}"))?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| format!("phase rows: {e}"))?;
for row in rows {
let v = row.map_err(|e| format!("phase row decode: {e}"))?;
out.insert(v);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use nmbrs_workload::report::{Kind, ReportItem};
fn item() -> ReportItem {
ReportItem {
kind: Kind::Plot,
name: "demo".to_string(),
body: "over cycle".to_string(),
..Default::default()
}
}
fn make_db(label: &str, scenarios: &[&str], phases: &[&str]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("nmbrs-anchor-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("metrics.db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
r#"
CREATE TABLE session_metadata (key TEXT, value TEXT);
CREATE TABLE instance_label (
instance_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL
);
"#,
)
.unwrap();
for s in scenarios {
conn.execute(
"INSERT INTO session_metadata (key, value) VALUES ('scenario', ?1)",
[s],
)
.unwrap();
}
for (i, p) in phases.iter().enumerate() {
let id = (i + 1) as i64;
conn.execute(
"INSERT INTO instance_label (instance_id, key, value) VALUES (?1, 'phase', ?2)",
rusqlite::params![id, p],
)
.unwrap();
}
path
}
#[test]
fn parse_at_recognises_all_scopes() {
assert_eq!(AnchorFlag::parse_at("root").unwrap(), AnchorFlag::AtRoot);
assert_eq!(
AnchorFlag::parse_at("scenario:foo").unwrap(),
AnchorFlag::AtScenario("foo".into())
);
assert_eq!(
AnchorFlag::parse_at("phase:setup").unwrap(),
AnchorFlag::AtPhase("setup".into())
);
assert_eq!(
AnchorFlag::parse_at("op:setup.step").unwrap(),
AnchorFlag::AtOp {
phase: "setup".into(),
op: "step".into()
}
);
}
#[test]
fn parse_at_rejects_malformed() {
assert!(AnchorFlag::parse_at("scenario:").is_err());
assert!(AnchorFlag::parse_at("op:setup").is_err());
assert!(AnchorFlag::parse_at("op:setup.").is_err());
assert!(AnchorFlag::parse_at("nonsense").is_err());
}
#[test]
fn parse_contextual_recognises_all_modes() {
assert_eq!(
AnchorFlag::parse_contextual("auto").unwrap(),
AnchorFlag::ContextualAuto
);
assert_eq!(
AnchorFlag::parse_contextual("root").unwrap(),
AnchorFlag::ContextualRoot
);
assert_eq!(
AnchorFlag::parse_contextual("phase").unwrap(),
AnchorFlag::ContextualPhase
);
assert!(AnchorFlag::parse_contextual("garbage").is_err());
}
#[test]
fn resolve_none_yields_root_without_db_lookup() {
let r = resolve(
std::path::Path::new("/nonexistent/db"),
&item(),
&AnchorFlag::None,
)
.unwrap();
assert!(matches!(r.anchor, Anchor::Root));
assert!(r.diagnostic.contains("default"));
}
#[test]
fn resolve_at_root_yields_root_without_db_lookup() {
let r = resolve(
std::path::Path::new("/nonexistent/db"),
&item(),
&AnchorFlag::AtRoot,
)
.unwrap();
assert!(matches!(r.anchor, Anchor::Root));
}
#[test]
fn resolve_at_scenario_yields_scenario_without_db_lookup() {
let r = resolve(
std::path::Path::new("/nonexistent/db"),
&item(),
&AnchorFlag::AtScenario("foo".into()),
)
.unwrap();
match r.anchor {
Anchor::Scenario(s) => assert_eq!(s, "foo"),
other => panic!("expected Scenario, got {other:?}"),
}
}
#[test]
fn resolve_contextual_auto_picks_phase_when_unique() {
let db = make_db("auto_phase", &["default"], &["setup"]);
let r = resolve(&db, &item(), &AnchorFlag::ContextualAuto).unwrap();
match r.anchor {
Anchor::Phase(p) => assert_eq!(p, "setup"),
other => panic!("expected Phase, got {other:?}"),
}
assert!(r.diagnostic.contains("phase:setup"));
}
#[test]
fn resolve_contextual_auto_picks_scenario_when_phases_branch() {
let db = make_db("auto_scenario", &["default"], &["a", "b"]);
let r = resolve(&db, &item(), &AnchorFlag::ContextualAuto).unwrap();
match r.anchor {
Anchor::Scenario(s) => assert_eq!(s, "default"),
other => panic!("expected Scenario, got {other:?}"),
}
}
#[test]
fn resolve_contextual_phase_errors_on_multiple_phases() {
let db = make_db("phase_multi", &["default"], &["a", "b"]);
let err = resolve(&db, &item(), &AnchorFlag::ContextualPhase).unwrap_err();
assert!(err.contains("multiple phases"), "got: {err}");
assert!(err.contains("--at phase:"), "should hint at fix: {err}");
}
#[test]
fn resolve_contextual_phase_succeeds_on_unique_phase() {
let db = make_db("phase_unique", &["default"], &["only"]);
let r = resolve(&db, &item(), &AnchorFlag::ContextualPhase).unwrap();
match r.anchor {
Anchor::Phase(p) => assert_eq!(p, "only"),
other => panic!("expected Phase, got {other:?}"),
}
}
#[test]
fn resolve_contextual_scenario_succeeds_on_single_scenario() {
let db = make_db("scen_unique", &["only_scenario"], &[]);
let r = resolve(&db, &item(), &AnchorFlag::ContextualScenario).unwrap();
match r.anchor {
Anchor::Scenario(s) => assert_eq!(s, "only_scenario"),
other => panic!("expected Scenario, got {other:?}"),
}
}
#[test]
fn resolve_contextual_op_errors_with_schema_gap_message() {
let db = make_db("op_unsupported", &["default"], &["a"]);
let err = resolve(&db, &item(), &AnchorFlag::ContextualOp).unwrap_err();
assert!(err.contains("op-template anchoring"));
assert!(
err.contains("schema extension"),
"should call out the schema gap: {err}"
);
assert!(
err.contains("--contextual phase"),
"should suggest the available alternative: {err}"
);
}
#[test]
fn resolve_contextual_root_skips_data_inspection_decisions() {
let db = make_db("ctx_root", &["default"], &["a", "b"]);
let r = resolve(&db, &item(), &AnchorFlag::ContextualRoot).unwrap();
assert!(matches!(r.anchor, Anchor::Root));
assert!(r.diagnostic.contains("--contextual root"));
}
}