use assert_cmd::Command;
use predicates::prelude::*;
fn cargo_ktstr() -> Command {
let mut cmd = Command::cargo_bin("cargo-ktstr").unwrap();
cmd.arg("ktstr");
cmd
}
#[test]
fn help_lists_subcommands() {
cargo_ktstr()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("test"))
.stdout(predicate::str::contains("shell"))
.stdout(predicate::str::contains("kernel"))
.stdout(predicate::str::contains("verifier"))
.stdout(predicate::str::contains("completions"))
.stdout(predicate::str::contains(" llvm-cov"))
.stdout(predicate::str::contains("[aliases: nextest]"));
}
#[test]
fn help_test() {
cargo_ktstr()
.args(["test", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--kernel"))
.stdout(predicate::str::contains("--no-perf-mode"))
.stdout(predicate::str::contains("cargo nextest"));
}
#[test]
fn help_nextest_alias() {
cargo_ktstr()
.args(["nextest", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--kernel"))
.stdout(predicate::str::contains("--no-perf-mode"));
}
#[test]
fn help_llvm_cov() {
cargo_ktstr()
.args(["llvm-cov", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--kernel"))
.stdout(predicate::str::contains("--no-perf-mode"))
.stdout(predicate::str::contains("cargo llvm-cov"));
}
#[test]
fn help_shell() {
cargo_ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--kernel"))
.stdout(predicate::str::contains("--topology"))
.stdout(predicate::str::contains("--memory-mb"))
.stdout(predicate::str::contains("--no-perf-mode"));
}
#[test]
fn help_export() {
cargo_ktstr()
.args(["export", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--output"))
.stdout(predicate::str::contains("--package"))
.stdout(predicate::str::contains("--release"))
.stdout(predicate::str::contains("<TEST>"));
}
#[test]
#[ignore = "runs cargo build --tests over the full workspace; minutes of compile time"]
fn export_unknown_test_errors() {
cargo_ktstr()
.args(["export", "definitely_not_a_real_ktstr_test_xyzzy_987"])
.assert()
.failure()
.stderr(
predicate::str::contains("not found in any workspace test binary").or(
predicate::str::contains("definitely_not_a_real_ktstr_test_xyzzy_987"),
),
);
}
#[test]
fn help_kernel() {
cargo_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() {
cargo_ktstr()
.args(["kernel", "list", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--json"));
}
#[test]
fn help_kernel_build() {
cargo_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("--extra-kconfig"))
.stdout(predicate::str::contains("olddefconfig"))
.stdout(predicate::str::contains("--skip-sha256"));
}
#[test]
fn kernel_build_extra_kconfig_nonexistent_path_errors() {
let tmp = tempfile::TempDir::new().unwrap();
cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-extra-kconfig-source-test",
"--extra-kconfig",
"/definitely/not/a/real/file/ktstr-extra-kconfig-test.kconfig",
])
.assert()
.failure()
.stderr(predicate::str::contains("--extra-kconfig"))
.stderr(predicate::str::contains(
"/definitely/not/a/real/file/ktstr-extra-kconfig-test.kconfig",
));
}
#[test]
fn kernel_build_extra_kconfig_directory_errors() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("not-a-file");
std::fs::create_dir(&dir).unwrap();
cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-source-test-dir-arg",
"--extra-kconfig",
])
.arg(&dir)
.assert()
.failure()
.stderr(predicate::str::contains("--extra-kconfig"))
.stderr(predicate::str::contains("is a directory"));
}
#[test]
fn kernel_build_extra_kconfig_invalid_utf8_errors() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("invalid.kconfig");
std::fs::write(&path, [0xffu8]).unwrap();
cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-source-test-utf8-arg",
"--extra-kconfig",
])
.arg(&path)
.assert()
.failure()
.stderr(predicate::str::contains("--extra-kconfig"))
.stderr(predicate::str::contains("not valid UTF-8"));
}
#[test]
fn kernel_build_extra_kconfig_empty_file_warns_but_proceeds() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("empty.kconfig");
std::fs::write(&path, b"").unwrap();
cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.env("RUST_LOG", "warn")
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-source-test-empty-arg",
"--extra-kconfig",
])
.arg(&path)
.assert()
.failure()
.stderr(predicate::str::contains("--extra-kconfig file is empty"));
}
#[test]
fn kernel_build_extra_kconfig_symlink_chain_resolves() {
let tmp = tempfile::TempDir::new().unwrap();
let real = tmp.path().join("real.kconfig");
std::fs::write(&real, b"CONFIG_KTSTR_SYMLINK_TEST=y\n").unwrap();
let link1 = tmp.path().join("link1.kconfig");
let link2 = tmp.path().join("link2.kconfig");
std::os::unix::fs::symlink(&real, &link1).unwrap();
std::os::unix::fs::symlink(&link1, &link2).unwrap();
let assert = cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-source-symlink-test",
"--extra-kconfig",
])
.arg(&link2)
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).into_owned();
assert!(
!stderr.contains("--extra-kconfig"),
"symlink chain must resolve transparently — read_extra_kconfig \
should not surface a `--extra-kconfig` error when the chain \
resolves to a readable file. stderr={stderr:?}"
);
}
#[test]
fn kernel_build_extra_kconfig_validation_fires_before_source_acquire() {
let tmp = tempfile::TempDir::new().unwrap();
cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-source-precedence-test",
"--extra-kconfig",
"/nonexistent/ktstr-extra-precedence-test.kconfig",
])
.assert()
.failure()
.stderr(predicate::str::contains("--extra-kconfig"))
.stderr(predicate::str::contains(
"/nonexistent/ktstr-extra-precedence-test.kconfig",
));
}
#[test]
fn help_kernel_clean() {
cargo_ktstr()
.args(["kernel", "clean", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--keep"))
.stdout(predicate::str::contains("--force"));
}
#[test]
fn help_verifier() {
cargo_ktstr()
.args(["verifier", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--kernel"))
.stdout(predicate::str::contains("--raw"));
}
#[test]
fn help_completions() {
cargo_ktstr()
.args(["completions", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("<SHELL>"))
.stdout(predicate::str::contains("possible values: bash"));
}
#[test]
fn no_subcommand_fails() {
cargo_ktstr().assert().failure();
}
#[test]
fn completions_bash_produces_output() {
cargo_ktstr()
.args(["completions", "bash"])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn completions_zsh_produces_output() {
cargo_ktstr()
.args(["completions", "zsh"])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn completions_fish_produces_output() {
cargo_ktstr()
.args(["completions", "fish"])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn completions_invalid_shell() {
cargo_ktstr()
.args(["completions", "noshell"])
.assert()
.failure();
}
#[test]
fn help_shell_shows_exec() {
cargo_ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--exec"));
}
#[test]
fn help_shell_shows_dmesg() {
cargo_ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--dmesg"));
}
#[test]
fn help_shell_shows_include_files() {
cargo_ktstr()
.args(["shell", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--include-files"));
}
#[test]
fn include_files_nonexistent_path() {
cargo_ktstr()
.args(["shell", "-i", "/nonexistent/path/to/file"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn shell_invalid_topology() {
cargo_ktstr()
.args(["shell", "--topology", "abc"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid topology"));
}
#[test]
fn stats_no_data() {
let tmp = tempfile::tempdir().unwrap();
cargo_ktstr()
.env("KTSTR_SIDECAR_DIR", tmp.path())
.args(["stats"])
.assert()
.success()
.stderr(predicate::str::contains("no sidecar data found"))
.stdout(predicate::str::is_empty());
}
#[test]
fn kernel_list_runs() {
let tmp = tempfile::TempDir::new().unwrap();
cargo_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() {
cargo_ktstr()
.args(["kernel", "list", "--json"])
.assert()
.success()
.stdout(predicate::str::contains("entries"));
}
#[test]
fn cargo_ktstr_shell_cpu_cap_with_bypass_errors() {
let tmp = tempfile::TempDir::new().unwrap();
cargo_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 cargo_ktstr_kernel_build_cpu_cap_with_bypass_errors() {
let tmp = tempfile::TempDir::new().unwrap();
cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.env("KTSTR_BYPASS_LLC_LOCKS", "1")
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-cargo-ktstr-cpu-cap-bypass-test",
"--cpu-cap",
"2",
])
.assert()
.failure()
.stderr(predicate::str::contains("resource contract"));
}
#[test]
fn extra_kconfig_cache_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let entry_dir = tmp.path().join("test-extras-roundtrip-bbbb1111");
std::fs::create_dir_all(&entry_dir).unwrap();
std::fs::write(entry_dir.join("bzImage"), b"fake kernel image").unwrap();
let metadata_json = serde_json::json!({
"version": "6.14.2",
"source": {"type": "tarball"},
"arch": "x86_64",
"image_name": "bzImage",
"config_hash": null,
"built_at": "2026-04-22T00:00:00Z",
"ktstr_kconfig_hash": null,
"extra_kconfig_hash": "f00d1234",
"has_vmlinux": false,
"vmlinux_stripped": false,
});
std::fs::write(
entry_dir.join("metadata.json"),
serde_json::to_string_pretty(&metadata_json).unwrap(),
)
.unwrap();
let run = |label: &str| {
let output = cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args(["kernel", "list", "--json"])
.output()
.unwrap_or_else(|e| panic!("{label}: kernel list --json must run: {e}"));
assert!(
output.status.success(),
"{label}: kernel list --json must succeed; stderr={:?}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
};
let stdout_a = run("first run");
let stdout_b = run("second run");
let parse_hash = |stdout: &str| -> String {
let parsed: serde_json::Value = serde_json::from_str(stdout).unwrap();
parsed["entries"]
.as_array()
.unwrap()
.iter()
.find(|e| e["key"].as_str() == Some("test-extras-roundtrip-bbbb1111"))
.expect("planted extras entry must appear in both runs")["extra_kconfig_hash"]
.as_str()
.expect("extra_kconfig_hash must be present")
.to_string()
};
let hash_a = parse_hash(&stdout_a);
let hash_b = parse_hash(&stdout_b);
assert_eq!(
hash_a, hash_b,
"same fixture must surface the same extra_kconfig_hash across runs — \
cache-roundtrip identity"
);
assert_eq!(
hash_a, "f00d1234",
"hash must round-trip the planted value verbatim"
);
}
#[test]
fn extra_kconfig_cache_miss_on_different_content() {
let tmp = tempfile::TempDir::new().unwrap();
let plant = |key: &str, extras_hash: serde_json::Value, built_at: &str| {
let dir = tmp.path().join(key);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("bzImage"), b"fake kernel image").unwrap();
let meta = serde_json::json!({
"version": "6.14.2",
"source": {"type": "tarball"},
"arch": "x86_64",
"image_name": "bzImage",
"config_hash": null,
"built_at": built_at,
"ktstr_kconfig_hash": null,
"extra_kconfig_hash": extras_hash,
"has_vmlinux": false,
"vmlinux_stripped": false,
});
std::fs::write(
dir.join("metadata.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
};
plant(
"test-extras-miss-AAAA-bbbb2222",
serde_json::json!("aaaaaaaa"),
"2026-04-22T00:00:00Z",
);
plant(
"test-extras-miss-BBBB-bbbb3333",
serde_json::json!("bbbbbbbb"),
"2026-04-23T00:00:00Z",
);
let output = cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args(["kernel", "list", "--json"])
.output()
.expect("kernel list --json must run");
assert!(output.status.success());
let parsed: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.stdout).unwrap()).unwrap();
let entries = parsed["entries"].as_array().expect("entries array");
let entry_a = entries
.iter()
.find(|e| e["key"].as_str() == Some("test-extras-miss-AAAA-bbbb2222"))
.expect("entry A must appear");
let entry_b = entries
.iter()
.find(|e| e["key"].as_str() == Some("test-extras-miss-BBBB-bbbb3333"))
.expect("entry B must appear");
assert_eq!(entry_a["extra_kconfig_hash"].as_str(), Some("aaaaaaaa"));
assert_eq!(entry_b["extra_kconfig_hash"].as_str(), Some("bbbbbbbb"));
assert_ne!(
entry_a["extra_kconfig_hash"], entry_b["extra_kconfig_hash"],
"different extras content must produce distinct cache slots — \
a build with extras=B must not be served entry A's cached kernel"
);
}
#[test]
fn extra_kconfig_range_expansion() {
cargo_ktstr()
.args(["kernel", "build", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--extra-kconfig"));
let tmp = tempfile::TempDir::new().unwrap();
let frag = tmp.path().join("extras.kconfig");
std::fs::write(&frag, "CONFIG_FOO=y\n").unwrap();
let assert_result = cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.env("KTSTR_BYPASS_LLC_LOCKS", "1")
.args([
"kernel",
"build",
"--source",
"/nonexistent/ktstr-extras-range-test",
"--extra-kconfig",
frag.to_str().unwrap(),
])
.assert()
.failure();
let stderr = String::from_utf8(assert_result.get_output().stderr.clone()).unwrap();
assert!(
!stderr.contains("error: the argument") && !stderr.contains("cannot be used with"),
"clap must accept `--extra-kconfig` alongside the build dispatch \
(the range loop reuses this same flag set for every version); \
got stderr: {stderr}"
);
}
#[test]
fn extra_kconfig_kernel_list_shows_hash() {
let tmp = tempfile::TempDir::new().unwrap();
let bare_dir = tmp.path().join("test-list-shows-hash-bare-bbbb4444");
std::fs::create_dir_all(&bare_dir).unwrap();
std::fs::write(bare_dir.join("bzImage"), b"bare kernel").unwrap();
let bare_meta = serde_json::json!({
"version": "6.14.2",
"source": {"type": "tarball"},
"arch": "x86_64",
"image_name": "bzImage",
"config_hash": null,
"built_at": "2026-04-22T00:00:00Z",
"ktstr_kconfig_hash": null,
"extra_kconfig_hash": null,
"has_vmlinux": false,
"vmlinux_stripped": false,
});
std::fs::write(
bare_dir.join("metadata.json"),
serde_json::to_string_pretty(&bare_meta).unwrap(),
)
.unwrap();
let extras_dir = tmp.path().join("test-list-shows-hash-extras-bbbb5555");
std::fs::create_dir_all(&extras_dir).unwrap();
std::fs::write(extras_dir.join("bzImage"), b"extras kernel").unwrap();
let extras_meta = serde_json::json!({
"version": "6.14.2",
"source": {"type": "tarball"},
"arch": "x86_64",
"image_name": "bzImage",
"config_hash": null,
"built_at": "2026-04-23T00:00:00Z",
"ktstr_kconfig_hash": null,
"extra_kconfig_hash": "cafef00d",
"has_vmlinux": false,
"vmlinux_stripped": false,
});
std::fs::write(
extras_dir.join("metadata.json"),
serde_json::to_string_pretty(&extras_meta).unwrap(),
)
.unwrap();
let output = cargo_ktstr()
.env("KTSTR_CACHE_DIR", tmp.path())
.args(["kernel", "list", "--json"])
.output()
.expect("kernel list --json must run");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let entries = parsed["entries"].as_array().expect("entries array");
let bare = entries
.iter()
.find(|e| e["key"].as_str() == Some("test-list-shows-hash-bare-bbbb4444"))
.expect("bare entry must appear");
let extras = entries
.iter()
.find(|e| e["key"].as_str() == Some("test-list-shows-hash-extras-bbbb5555"))
.expect("extras entry must appear");
assert!(
bare.get("extra_kconfig_hash").is_some(),
"bare entry must surface the `extra_kconfig_hash` JSON key (= null) \
so consumers can distinguish 'no extras' from 'field missing'"
);
assert!(bare["extra_kconfig_hash"].is_null());
assert_eq!(
extras["extra_kconfig_hash"].as_str(),
Some("cafef00d"),
"extras entry must surface the planted hash verbatim"
);
}