use assert_cmd::Command;
use predicates::prelude::*;
fn ktstr() -> Command {
Command::cargo_bin("ktstr").unwrap()
}
#[test]
fn help_lists_subcommands() {
ktstr()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("topo"))
.stdout(predicate::str::contains("kernel"))
.stdout(predicate::str::contains("shell"))
.stdout(predicate::str::contains("ctprof"))
.stdout(predicate::str::contains("completions"))
.stdout(predicate::str::contains("locks"));
}
#[test]
fn help_shell() {
ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--kernel"))
.stdout(predicate::str::contains("--topology"))
.stdout(predicate::str::contains("--memory-mb"));
}
#[test]
fn help_shell_shows_exec() {
ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--exec"));
}
#[test]
fn help_shell_shows_dmesg() {
ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--dmesg"));
}
#[test]
fn help_shell_shows_include_files() {
ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--include-files"));
}
#[test]
fn help_shell_shows_no_perf_mode() {
ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--no-perf-mode"));
}
#[test]
fn help_kernel() {
ktstr()
.args(["kernel", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("list"))
.stdout(predicate::str::contains("build"))
.stdout(predicate::str::contains("clean"));
}
#[test]
fn help_kernel_list() {
ktstr()
.args(["kernel", "list", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--json"));
}
#[test]
fn help_kernel_build() {
ktstr()
.args(["kernel", "build", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--source"))
.stdout(predicate::str::contains("--git"))
.stdout(predicate::str::contains("--ref"))
.stdout(predicate::str::contains("--force"))
.stdout(predicate::str::contains("--clean"))
.stdout(predicate::str::contains("--skip-sha256"));
}
#[test]
fn help_kernel_clean() {
ktstr()
.args(["kernel", "clean", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--keep"))
.stdout(predicate::str::contains("--force"));
}
#[test]
fn no_subcommand_fails() {
ktstr().assert().failure();
}
#[test]
fn include_files_nonexistent_path() {
ktstr()
.args(["shell", "-i", "/nonexistent/path/to/file"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn shell_invalid_topology() {
ktstr()
.args(["shell", "--topology", "abc"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid topology"));
}
#[test]
fn shell_zero_topology() {
ktstr()
.args(["shell", "--topology", "0,1,1,1"])
.assert()
.failure()
.stderr(predicate::str::contains("must be >= 1"));
}
#[test]
fn completions_bash() {
ktstr()
.args(["completions", "bash"])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn completions_zsh() {
ktstr()
.args(["completions", "zsh"])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn include_files_empty_dir_warns() {
let tmp = tempfile::TempDir::new().unwrap();
let result = ktstr::cli::resolve_include_files(&[tmp.path().to_path_buf()]);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn include_files_dir_walks_recursively() {
let tmp = tempfile::TempDir::new().unwrap();
let sub = tmp.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("file.txt"), "hello").unwrap();
std::fs::write(tmp.path().join("root.txt"), "world").unwrap();
let result = ktstr::cli::resolve_include_files(&[tmp.path().to_path_buf()]).unwrap();
assert_eq!(result.len(), 2);
let paths: Vec<&str> = result.iter().map(|(a, _)| a.as_str()).collect();
assert!(paths.iter().any(|p| p.contains("root.txt")));
assert!(paths.iter().any(|p| p.contains("sub/file.txt")));
}
#[test]
fn shell_exec_echo() {
if !std::path::Path::new("/dev/kvm").exists() {
eprintln!("skipping shell_exec_echo: /dev/kvm not found");
return;
}
if ktstr::find_kernel().ok().flatten().is_none() {
eprintln!("skipping shell_exec_echo: no cached kernel");
return;
}
let output = ktstr()
.args(["shell", "--exec", "echo hello-from-guest"])
.timeout(std::time::Duration::from_secs(120))
.output()
.expect("failed to run ktstr shell");
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("LLC slots busy") || stderr.contains("CPU") && stderr.contains("busy") {
eprintln!("skipping shell_exec_echo: host resource contention");
return;
}
assert!(
output.status.success(),
"Unexpected failure.\ncode={}\nstderr=```\n{stderr}```",
output.status.code().unwrap_or(-1)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("hello-from-guest"),
"stdout missing greeting: {stdout}"
);
}
#[test]
fn include_files_duplicate_archive_path_errors() {
let tmp1 = tempfile::TempDir::new().unwrap();
let tmp2 = tempfile::TempDir::new().unwrap();
let dir1 = tmp1.path().join("data");
let dir2 = tmp2.path().join("data");
std::fs::create_dir(&dir1).unwrap();
std::fs::create_dir(&dir2).unwrap();
std::fs::write(dir1.join("file.txt"), "a").unwrap();
std::fs::write(dir2.join("file.txt"), "b").unwrap();
let result = ktstr::cli::resolve_include_files(&[dir1, dir2]);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("duplicate"), "{err}");
}
#[test]
fn topo_shows_cpus() {
ktstr()
.arg("topo")
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn completions_fish() {
ktstr()
.args(["completions", "fish"])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn completions_invalid_shell() {
ktstr().args(["completions", "noshell"]).assert().failure();
}
#[test]
fn kernel_list_runs() {
let tmp = tempfile::TempDir::new().unwrap();
ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args(["kernel", "list"])
.assert()
.success()
.stdout(predicate::str::contains("no cached kernels"))
.stderr(predicate::str::contains("cache:"));
}
#[test]
fn kernel_list_json() {
ktstr()
.args(["kernel", "list", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("entries"));
}
fn write_valid_entry(dir: &std::path::Path, ktstr_kconfig_hash: Option<&str>) {
std::fs::create_dir_all(dir).expect("create fixture entry dir");
let kconfig = match ktstr_kconfig_hash {
Some(h) => format!("\"{h}\""),
None => "null".to_string(),
};
let metadata = format!(
"{{\
\"version\":\"6.99.0\",\
\"source\":{{\"type\":\"tarball\"}},\
\"arch\":\"x86_64\",\
\"image_name\":\"Image\",\
\"config_hash\":null,\
\"built_at\":\"2025-01-01T00:00:00Z\",\
\"ktstr_kconfig_hash\":{kconfig},\
\"extra_kconfig_hash\":null,\
\"has_vmlinux\":false,\
\"vmlinux_stripped\":false\
}}",
);
std::fs::write(dir.join("metadata.json"), metadata.as_bytes()).expect("write metadata.json");
std::fs::write(dir.join("Image"), b"").expect("write Image");
}
fn write_corrupt_entry(dir: &std::path::Path) {
std::fs::create_dir_all(dir).expect("create fixture corrupt dir");
std::fs::write(dir.join("metadata.json"), b"{").expect("write malformed metadata.json");
}
fn build_legend_fixture_cache() -> tempfile::TempDir {
let tmp = tempfile::TempDir::new().expect("tempdir for fixture cache");
let root = tmp.path();
write_valid_entry(&root.join("valid-untracked"), None);
write_valid_entry(&root.join("valid-stale"), Some("deadbe7"));
write_corrupt_entry(&root.join("corrupt-malformed"));
tmp
}
#[test]
fn kernel_list_legends_emit_on_stderr() {
let cache = build_legend_fixture_cache();
let out = ktstr()
.env("KTSTR_CACHE_DIR", cache.path())
.args(["kernel", "list"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(out.stdout).expect("stdout utf-8");
let stderr = String::from_utf8(out.stderr).expect("stderr utf-8");
for needle in [
"(untracked kconfig) marks entries",
"warning: entries marked (stale kconfig)",
"warning: entries marked (corrupt)",
] {
assert!(
stderr.contains(needle),
"stderr must contain legend fragment {needle:?}; got:\n{stderr}",
);
assert!(
!stdout.contains(needle),
"stdout must NOT contain legend fragment {needle:?}; got:\n{stdout}",
);
}
}
#[test]
fn kernel_list_legend_ordering_pins_untracked_stale_corrupt() {
let cache = build_legend_fixture_cache();
let out = ktstr()
.env("KTSTR_CACHE_DIR", cache.path())
.args(["kernel", "list"])
.assert()
.success()
.get_output()
.clone();
let stderr = String::from_utf8(out.stderr).expect("stderr utf-8");
let i_untracked = stderr
.find("(untracked kconfig) marks entries")
.expect("untracked legend must appear in stderr");
let i_stale = stderr
.find("warning: entries marked (stale kconfig)")
.expect("stale legend must appear in stderr");
let i_corrupt = stderr
.find("warning: entries marked (corrupt)")
.expect("corrupt footer must appear in stderr");
assert!(
i_untracked < i_stale,
"untracked legend must precede stale legend in stderr — \
kconfig-tag rebuild recipes are kept adjacent so operators \
see both remediation shapes together. \
untracked at byte {i_untracked}, stale at {i_stale}:\n{stderr}",
);
assert!(
i_stale < i_corrupt,
"stale legend must precede corrupt footer — informational \
trio (EOL/untracked/stale) comes before the operationally-\
disruptive corrupt entry per the emission block comment in \
cli.rs. stale at byte {i_stale}, corrupt at {i_corrupt}:\n{stderr}",
);
if let Some(i_eol) = stderr.find("(EOL) marks entries") {
assert!(
i_eol < i_untracked,
"EOL legend must precede untracked legend — EOL is \
informational-first (upstream-release state, not a \
cache pathology) per the emission block comment. \
eol at byte {i_eol}, untracked at {i_untracked}:\n{stderr}",
);
}
}
#[test]
fn ktstr_shell_cpu_cap_with_bypass_errors() {
let tmp = tempfile::TempDir::new().unwrap();
ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.env("KTSTR_BYPASS_LLC_LOCKS", "1")
.args(["shell", "--no-perf-mode", "--cpu-cap", "2"])
.assert()
.failure()
.stderr(predicate::str::contains("resource contract"));
}
#[test]
fn ktstr_kernel_build_cpu_cap_with_bypass_errors() {
let tmp = tempfile::TempDir::new().unwrap();
ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.env("KTSTR_BYPASS_LLC_LOCKS", "1")
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-ktstr-cpu-cap-bypass-test",
"--cpu-cap",
"2",
])
.assert()
.failure()
.stderr(predicate::str::contains("resource contract"));
}
#[test]
fn ktstr_library_cpu_cap_env_with_bypass_errors() {
let tmp = tempfile::TempDir::new().unwrap();
ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.env("KTSTR_CPU_CAP", "2")
.env("KTSTR_BYPASS_LLC_LOCKS", "1")
.args(["shell", "--no-perf-mode"])
.assert()
.failure()
.stderr(predicate::str::contains("resource contract"));
}
#[test]
fn help_ctprof_compare_lists_all_flags() {
ktstr()
.args(["ctprof", "compare", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--group-by"))
.stdout(predicate::str::contains("--cgroup-flatten"))
.stdout(predicate::str::contains("--no-thread-normalize"))
.stdout(predicate::str::contains("--no-cg-normalize"))
.stdout(predicate::str::contains("--sort-by"));
}
#[test]
fn help_ctprof_compare_sort_by_uses_metric_terminology() {
ktstr()
.args(["ctprof", "compare", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("metric"))
.stdout(predicate::str::contains("asc"))
.stdout(predicate::str::contains("desc"))
.stdout(predicate::str::contains("[CTPROF_METRICS]").not());
}
#[test]
fn help_ctprof_compare_sort_by_no_legacy_word() {
ktstr()
.args(["ctprof", "compare", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("legacy").not());
}
#[test]
fn ctprof_compare_invalid_sort_by_metric_errors() {
ktstr()
.args([
"ctprof",
"compare",
"/dev/null",
"/dev/null",
"--sort-by",
"not_a_real_metric",
])
.assert()
.failure()
.stderr(predicate::str::contains("not_a_real_metric"))
.stderr(predicate::str::contains("must be one of"));
}
#[test]
fn ctprof_compare_invalid_sort_by_direction_errors() {
ktstr()
.args([
"ctprof",
"compare",
"/dev/null",
"/dev/null",
"--sort-by",
"wait_sum:bogus",
])
.assert()
.failure()
.stderr(predicate::str::contains("invalid direction"))
.stderr(predicate::str::contains("bogus"));
}