use std::path::Path;
use anyhow::{Result, anyhow, bail};
use crate::stats::{ComparisonPolicy, RowFilter};
pub fn print_stats_report() -> Option<String> {
let dir = match std::env::var("KTSTR_SIDECAR_DIR") {
Ok(d) if !d.is_empty() => Some(std::path::PathBuf::from(d)),
_ => crate::test_support::newest_run_dir(),
};
let report = dir
.as_deref()
.map(|d| crate::test_support::analyze_sidecars(Some(d)))
.filter(|r| !r.is_empty());
if report.is_none() {
eprintln!("cargo ktstr: no sidecar data found (skipped)");
}
report
}
pub fn list_runs() -> Result<()> {
crate::stats::list_runs()
}
pub fn list_metrics(json: bool) -> Result<String> {
crate::stats::list_metrics(json)
}
pub fn list_values(json: bool, dir: Option<&Path>) -> Result<String> {
crate::stats::list_values(json, dir)
}
pub fn compare_partitions(
filter_a: &RowFilter,
filter_b: &RowFilter,
filter: Option<&str>,
policy: &ComparisonPolicy,
dir: Option<&Path>,
no_average: bool,
) -> Result<i32> {
crate::stats::compare_partitions(filter_a, filter_b, filter, policy, dir, no_average)
}
pub fn show_host() -> String {
crate::host_context::collect_host_context().format_human()
}
pub(super) fn suggest_closest_run_key(query: &str, root: &Path) -> Option<String> {
let threshold = std::cmp::max(3, query.len() / 3);
let entries = std::fs::read_dir(root).ok()?;
let mut best: Option<(usize, String)> = None;
for entry in entries.flatten() {
if !crate::test_support::is_run_directory(&entry) {
continue;
}
let name = match entry.file_name().to_str() {
Some(s) => s.to_string(),
None => continue,
};
let d = strsim::levenshtein(query, &name);
if d > threshold {
continue;
}
match best {
Some((best_d, _)) if best_d <= d => continue,
_ => best = Some((d, name)),
}
}
best.map(|(_, name)| name)
}
pub fn show_run_host(run: &str, dir: Option<&Path>) -> Result<String> {
let root: std::path::PathBuf = match dir {
Some(d) => d.to_path_buf(),
None => crate::test_support::runs_root(),
};
let run_dir = root.join(run);
if !run_dir.exists() {
let suggestion = suggest_closest_run_key(run, &root)
.map(|name| format!(" Did you mean `{name}`?"))
.unwrap_or_default();
bail!(
"run '{run}' not found under {}.{suggestion} \
Run `cargo ktstr stats list` to enumerate available run keys.",
root.display(),
);
}
let sidecars = crate::test_support::collect_sidecars(&run_dir);
if sidecars.is_empty() {
bail!("run '{run}' has no sidecar data");
}
let host = sidecars
.iter()
.find_map(|sc| sc.host.as_ref())
.ok_or_else(|| {
anyhow!(
"run '{run}' has {} sidecar(s) but none carries a populated \
host context; this usually means the run predates host-context \
enrichment. Re-run the test to produce a sidecar with the \
current schema.",
sidecars.len(),
)
})?;
Ok(host.format_human())
}
pub(super) fn suggest_closest_test_name(query: &str) -> Option<&'static str> {
let threshold = std::cmp::max(3, query.len() / 3);
let mut best: Option<(usize, &'static str)> = None;
for entry in crate::test_support::KTSTR_TESTS.iter() {
let d = strsim::levenshtein(query, entry.name);
if d > threshold {
continue;
}
match best {
Some((best_d, _)) if best_d <= d => continue,
_ => best = Some((d, entry.name)),
}
}
best.map(|(_, name)| name)
}
pub fn show_thresholds(test_name: &str) -> Result<String> {
let entry = crate::test_support::find_test(test_name).ok_or_else(|| {
let suggestion = suggest_closest_test_name(test_name)
.map(|s| format!(" Did you mean `{s}`?"))
.unwrap_or_default();
anyhow!(
"no registered ktstr test named '{test_name}'.{suggestion} \
Run `cargo nextest list` to see the available test names \
— then pass just the function-name component to \
`show-thresholds`, not the `<binary>::` prefix that \
nextest prepends to each line."
)
})?;
let merged = crate::assert::Assert::default_checks()
.merge(&entry.scheduler.assert)
.merge(&entry.assert);
let mut out = format!("Test: {}\n", entry.name);
out.push_str(&format!("Scheduler: {}\n", entry.scheduler.name,));
out.push_str("Resolved assertion thresholds:\n");
out.push_str(&merged.format_human());
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn show_host_returns_populated_report() {
let out = show_host();
assert!(!out.is_empty(), "show_host must return non-empty output");
assert!(
out.ends_with('\n'),
"show_host output must end with a newline for print! use: {out:?}",
);
assert!(
out.contains("kernel_name"),
"show_host must surface the kernel_name field: {out}",
);
}
#[test]
fn show_run_host_missing_run_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let err = show_run_host("nonexistent-run", Some(tmp.path())).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("run 'nonexistent-run' not found"),
"missing-run error must name the run: {msg}",
);
assert!(
msg.contains("cargo ktstr stats list"),
"missing-run error must name the `stats list` discovery \
command so operators can enumerate available run keys \
without extra lookups: {msg}",
);
}
#[test]
fn show_run_host_empty_run_returns_error() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("run-empty")).unwrap();
let err = show_run_host("run-empty", Some(tmp.path())).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("no sidecar data"),
"empty-run error must name the condition: {msg}",
);
}
#[test]
fn show_run_host_all_host_none_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let run_dir = tmp.path().join("run-no-host");
std::fs::create_dir(&run_dir).unwrap();
let sc = crate::test_support::SidecarResult::test_fixture();
let json = serde_json::to_string(&sc).unwrap();
std::fs::write(run_dir.join("t-0000000000000000.ktstr.json"), json).unwrap();
let err = show_run_host("run-no-host", Some(tmp.path())).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("no sidecar with a populated host")
|| msg.contains("none carries a populated host context"),
"all-host-None error must name the pre-enrichment likely cause: {msg}",
);
}
#[test]
fn show_run_host_populated_sidecar_returns_format_human() {
let tmp = tempfile::tempdir().unwrap();
let run_dir = tmp.path().join("run-with-host");
std::fs::create_dir(&run_dir).unwrap();
let mut sc = crate::test_support::SidecarResult::test_fixture();
sc.host = Some(crate::host_context::HostContext::test_fixture());
let json = serde_json::to_string(&sc).unwrap();
std::fs::write(run_dir.join("t-0000000000000000.ktstr.json"), json).unwrap();
let out = show_run_host("run-with-host", Some(tmp.path())).unwrap();
assert!(
out.contains("kernel_name"),
"populated host output must include the kernel_name row: {out}",
);
assert!(
out.ends_with('\n'),
"output must end with newline for print!: {out:?}",
);
}
#[test]
fn show_run_host_forward_scans_past_none_sidecars() {
let tmp = tempfile::tempdir().unwrap();
let run_dir = tmp.path().join("run-mixed");
std::fs::create_dir(&run_dir).unwrap();
let sc_none = crate::test_support::SidecarResult::test_fixture();
std::fs::write(
run_dir.join("a-0000000000000000.ktstr.json"),
serde_json::to_string(&sc_none).unwrap(),
)
.unwrap();
let mut sc_host = crate::test_support::SidecarResult::test_fixture();
sc_host.host = Some(crate::host_context::HostContext::test_fixture());
std::fs::write(
run_dir.join("b-0000000000000000.ktstr.json"),
serde_json::to_string(&sc_host).unwrap(),
)
.unwrap();
let out = show_run_host("run-mixed", Some(tmp.path()))
.expect("forward scan must find the populated sidecar");
assert!(
out.contains("kernel_name"),
"output from populated sidecar must include kernel_name: {out}",
);
}
#[test]
fn show_thresholds_known_test_returns_populated_report() {
let Some(entry) = crate::test_support::KTSTR_TESTS.iter().next() else {
eprintln!(
"ktstr: SKIP: show_thresholds_known_test_returns_populated_report — \
no entries in KTSTR_TESTS",
);
return;
};
let out = show_thresholds(entry.name).expect("show_thresholds must resolve known test");
assert!(
out.contains("Test:"),
"output missing `Test:` header: {out}"
);
assert!(
out.contains("Scheduler:"),
"output missing `Scheduler:` header: {out}"
);
assert!(
out.contains("Resolved assertion thresholds:"),
"output missing thresholds section: {out}",
);
let test_idx = out.find("Test:").unwrap();
let thresholds_idx = out.find("Resolved assertion thresholds:").unwrap();
assert!(
test_idx < thresholds_idx,
"`Test:` header must precede threshold dump",
);
}
#[test]
fn show_thresholds_unknown_test_returns_actionable_error() {
let err = show_thresholds("definitely_not_a_registered_test_xyz123").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("no registered ktstr test named"),
"error must name the missing-test condition: {msg}",
);
assert!(
msg.contains("cargo nextest list"),
"error must point at the discovery command: {msg}",
);
assert!(
msg.contains("function-name component"),
"error must flag the nextest binary:: prefix caveat: {msg}",
);
}
#[test]
fn suggest_closest_test_name_finds_near_match() {
let Some(entry) = crate::test_support::KTSTR_TESTS.iter().find(|e| {
e.name.len() >= 10 && !(e.name.starts_with("__unit_test_") && e.name.ends_with("__"))
}) else {
skip!(
"no registered non-sentinel test with name >= 10 chars \
— cannot construct a positive strsim probe"
);
};
let mut mutated: Vec<u8> = entry.name.bytes().collect();
mutated[0] = if mutated[0] == b'z' { b'a' } else { b'z' };
let query = std::str::from_utf8(&mutated).expect("ASCII mutation stays UTF-8");
let suggestion = suggest_closest_test_name(query)
.expect("distance-1 typo on a registered name must yield a suggestion");
assert_eq!(
suggestion, entry.name,
"a single-byte typo must suggest the exact name it was derived from",
);
}
#[test]
fn suggest_closest_test_name_returns_none_for_unrelated_query() {
let unrelated = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
assert_eq!(
suggest_closest_test_name(unrelated),
None,
"a query with no lexical relationship to any registered \
test name must yield no suggestion (not an over-reach)",
);
}
#[test]
fn suggest_closest_run_key_finds_near_match() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("6.14-abc1234")).expect("plant run dir");
let suggestion = suggest_closest_run_key("6.14-abc1235", tmp.path())
.expect("distance-1 typo on a planted run dir must yield a suggestion");
assert_eq!(suggestion, "6.14-abc1234");
}
#[test]
fn suggest_closest_run_key_returns_none_for_distant_query() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("6.14-abc1234")).expect("plant run dir");
assert_eq!(suggest_closest_run_key("xxxxxxxxxxxxx", tmp.path()), None,);
}
#[test]
fn suggest_closest_run_key_returns_none_for_empty_root() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
suggest_closest_run_key("6.14-abc1234", tmp.path()),
None,
"empty root must yield None — no candidates to match against",
);
}
#[test]
fn suggest_closest_run_key_skips_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("6.14-abc1234"), b"not a dir").expect("plant file");
std::fs::create_dir(tmp.path().join("6.14-abc1235")).expect("plant dir");
let suggestion = suggest_closest_run_key("6.14-abc1234", tmp.path())
.expect("the planted directory must yield a suggestion despite the same-name file");
assert_eq!(
suggestion, "6.14-abc1235",
"a regression that drops the is_dir() filter would surface \
here as `Some(\"6.14-abc1234\")` (the file at distance 0) \
instead of `Some(\"6.14-abc1235\")` (the dir at distance 1)",
);
}
}