use crate::persona::Persona;
use crate::scenario::{Registry, Scenario, ScenarioContext, ScenarioError, ScenarioResult};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct GridCell {
pub persona_id: String,
pub scenario_name: &'static str,
pub outcome: CellOutcome,
pub duration: Duration,
}
#[derive(Debug, Clone)]
pub enum CellOutcome {
Result(ScenarioResult),
RunnerError(String),
}
impl CellOutcome {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Result(r) => r.label(),
Self::RunnerError(_) => "ERROR",
}
}
#[must_use]
pub fn reason(&self) -> &str {
match self {
Self::Result(r) => r.reason(),
Self::RunnerError(msg) => msg,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Json,
Markdown,
}
#[derive(Debug, Clone)]
pub struct GridConfig {
pub work_root: PathBuf,
pub firmware_root: PathBuf,
pub stick: PathBuf,
pub dry_run: bool,
}
#[must_use]
pub fn compute_grid(personas: &[Persona], registry: &Registry, cfg: &GridConfig) -> Vec<GridCell> {
let mut cells = Vec::with_capacity(personas.len() * registry.len());
for persona in personas {
for (scenario_name, _) in registry.iter() {
let Some(scenario) = registry.find(scenario_name) else {
cells.push(GridCell {
persona_id: persona.id.clone(),
scenario_name,
outcome: CellOutcome::RunnerError(
"internal: registry iter/find disagreement".to_string(),
),
duration: Duration::ZERO,
});
continue;
};
let cell = run_one_cell(persona, scenario, cfg);
cells.push(cell);
}
}
cells
}
fn run_one_cell(persona: &Persona, scenario: &dyn Scenario, cfg: &GridConfig) -> GridCell {
let cell_work_dir = cfg
.work_root
.join(format!("{}__{}", persona.id, scenario.name()));
if cfg.dry_run {
return GridCell {
persona_id: persona.id.clone(),
scenario_name: scenario.name(),
outcome: CellOutcome::Result(ScenarioResult::Skip {
reason: "dry-run".to_string(),
}),
duration: Duration::ZERO,
};
}
let ctx = ScenarioContext {
persona: persona.clone(),
stick: cfg.stick.clone(),
work_dir: cell_work_dir,
firmware_root: cfg.firmware_root.clone(),
};
let start = Instant::now();
let outcome = match scenario.run(&ctx) {
Ok(r) => CellOutcome::Result(r),
Err(e) => CellOutcome::RunnerError(format_runner_error(&e)),
};
let duration = start.elapsed();
GridCell {
persona_id: persona.id.clone(),
scenario_name: scenario.name(),
outcome,
duration,
}
}
fn format_runner_error(e: &ScenarioError) -> String {
format!("runner error: {e}")
}
#[must_use]
pub fn render(cells: &[GridCell], registry: &Registry, format: OutputFormat) -> String {
match format {
OutputFormat::Json => render_json(cells),
OutputFormat::Markdown => render_markdown(cells, registry),
}
}
fn render_json(cells: &[GridCell]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(cells.len() * 200);
out.push_str("{\n");
out.push_str(" \"schema_version\": 1,\n");
out.push_str(" \"tool\": \"aegis-hwsim\",\n");
let _ = writeln!(
out,
" \"tool_version\": \"{}\",",
env!("CARGO_PKG_VERSION")
);
out.push_str(" \"cells\": [\n");
let last = cells.len().saturating_sub(1);
for (i, c) in cells.iter().enumerate() {
let comma = if i == last { "" } else { "," };
out.push_str(" {\n");
let _ = writeln!(
out,
" \"persona_id\": \"{}\",",
crate::json::escape(&c.persona_id)
);
let _ = writeln!(
out,
" \"scenario_name\": \"{}\",",
crate::json::escape(c.scenario_name)
);
let _ = writeln!(out, " \"result\": \"{}\",", c.outcome.label());
let _ = writeln!(
out,
" \"reason\": \"{}\",",
crate::json::escape(c.outcome.reason())
);
let _ = writeln!(out, " \"duration_ms\": {}", c.duration.as_millis());
let _ = writeln!(out, " }}{comma}");
}
out.push_str(" ]\n");
out.push_str("}\n");
out
}
fn render_markdown(cells: &[GridCell], registry: &Registry) -> String {
use std::fmt::Write as _;
let scenarios: Vec<&'static str> = registry.iter().map(|(n, _)| n).collect();
let mut personas: Vec<String> = cells.iter().map(|c| c.persona_id.clone()).collect();
personas.sort();
personas.dedup();
let mut index: HashMap<(&str, &str), &'static str> = HashMap::with_capacity(cells.len());
for c in cells {
index.insert((&c.persona_id, c.scenario_name), c.outcome.label());
}
let mut out = String::with_capacity(cells.len() * 80);
out.push_str("# aegis-hwsim coverage grid\n\n");
let _ = writeln!(
out,
"{} persona(s) × {} scenario(s) = {} cell(s).\n",
personas.len(),
scenarios.len(),
cells.len()
);
out.push_str("| persona |");
for s in &scenarios {
let _ = write!(out, " {s} |");
}
out.push('\n');
out.push_str("|---|");
for _ in &scenarios {
out.push_str("---|");
}
out.push('\n');
for p in &personas {
let _ = write!(out, "| {p} |");
for s in &scenarios {
let label = index.get(&(p.as_str(), *s)).copied().unwrap_or("?");
let _ = write!(out, " {label} |");
}
out.push('\n');
}
out.push('\n');
out.push_str("## Cell details\n\n");
for c in cells {
let r = c.outcome.reason();
if r.is_empty() {
continue;
}
let _ = writeln!(
out,
"- `{}` × `{}` — **{}**: {}",
c.persona_id,
c.scenario_name,
c.outcome.label(),
r
);
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn fake_persona(id: &str) -> Persona {
let yaml = format!(
"
schema_version: 1
id: {id}
vendor: QEMU
display_name: {id}
source:
kind: vendor_docs
ref_: test
dmi:
sys_vendor: QEMU
product_name: Standard PC
bios_vendor: EDK II
bios_version: stable
bios_date: 01/01/2024
secure_boot:
ovmf_variant: ms_enrolled
tpm:
version: none
"
);
serde_yaml_ng::from_str(&yaml).unwrap()
}
struct CannedScenario {
name: &'static str,
result: ScenarioResult,
}
impl Scenario for CannedScenario {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"test"
}
fn run(&self, _ctx: &ScenarioContext) -> Result<ScenarioResult, ScenarioError> {
Ok(self.result.clone())
}
}
fn fake_cfg() -> GridConfig {
GridConfig {
work_root: tempfile::tempdir().unwrap().path().to_path_buf(),
firmware_root: PathBuf::from("/usr/share/OVMF"),
stick: PathBuf::from("/no/such/stick"),
dry_run: false,
}
}
#[test]
fn dry_run_skips_every_cell_without_invoking_scenarios() {
let mut r = Registry::empty();
r.register(Box::new(CannedScenario {
name: "x",
result: ScenarioResult::Pass,
}));
let p = vec![fake_persona("a"), fake_persona("b")];
let mut cfg = fake_cfg();
cfg.dry_run = true;
let cells = compute_grid(&p, &r, &cfg);
assert_eq!(cells.len(), 2);
for cell in &cells {
assert_eq!(cell.outcome.label(), "SKIP");
assert_eq!(cell.outcome.reason(), "dry-run");
assert_eq!(cell.duration, Duration::ZERO);
}
}
#[test]
fn live_grid_runs_each_combination() {
let mut r = Registry::empty();
r.register(Box::new(CannedScenario {
name: "alpha",
result: ScenarioResult::Pass,
}));
r.register(Box::new(CannedScenario {
name: "beta",
result: ScenarioResult::Fail {
reason: "test-fail".into(),
},
}));
let p = vec![fake_persona("p1"), fake_persona("p2")];
let cells = compute_grid(&p, &r, &fake_cfg());
assert_eq!(cells.len(), 4);
assert_eq!(cells[0].outcome.label(), "PASS");
assert_eq!(cells[1].outcome.label(), "FAIL");
assert_eq!(cells[1].outcome.reason(), "test-fail");
assert_eq!(cells[2].outcome.label(), "PASS");
assert_eq!(cells[3].outcome.label(), "FAIL");
}
#[test]
fn render_json_emits_schema_version_envelope() {
let cells = vec![GridCell {
persona_id: "p1".into(),
scenario_name: "scenario-x",
outcome: CellOutcome::Result(ScenarioResult::Pass),
duration: Duration::from_millis(1234),
}];
let json = render_json(&cells);
assert!(json.contains("\"schema_version\": 1"));
assert!(json.contains("\"tool\": \"aegis-hwsim\""));
assert!(json.contains("\"persona_id\": \"p1\""));
assert!(json.contains("\"scenario_name\": \"scenario-x\""));
assert!(json.contains("\"result\": \"PASS\""));
assert!(json.contains("\"duration_ms\": 1234"));
}
#[test]
fn render_markdown_includes_table_and_reasons_section() {
let mut r = Registry::empty();
r.register(Box::new(CannedScenario {
name: "smoke",
result: ScenarioResult::Skip {
reason: "missing dep".into(),
},
}));
let cells = vec![GridCell {
persona_id: "alpha".into(),
scenario_name: "smoke",
outcome: CellOutcome::Result(ScenarioResult::Skip {
reason: "missing dep".into(),
}),
duration: Duration::from_millis(5),
}];
let md = render_markdown(&cells, &r);
assert!(md.contains("# aegis-hwsim coverage grid"));
assert!(md.contains("| persona |"));
assert!(md.contains("| smoke |"));
assert!(md.contains("| alpha |"));
assert!(md.contains("SKIP"));
assert!(md.contains("missing dep"));
}
#[test]
fn runner_error_renders_as_synthetic_error_cell() {
struct ErroringScenario;
impl Scenario for ErroringScenario {
fn name(&self) -> &'static str {
"broken"
}
fn description(&self) -> &'static str {
"broken"
}
fn run(&self, _ctx: &ScenarioContext) -> Result<ScenarioResult, ScenarioError> {
Err(ScenarioError::UnsupportedPersona {
scenario: "broken",
persona: "any".into(),
reason: "test".into(),
})
}
}
let mut r = Registry::empty();
r.register(Box::new(ErroringScenario));
let cells = compute_grid(&[fake_persona("p")], &r, &fake_cfg());
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].outcome.label(), "ERROR");
assert!(cells[0].outcome.reason().contains("runner error"));
}
}