use std::path::PathBuf;
use std::process::{Command, Stdio};
use tempfile::TempDir;
pub fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
pub const SUBCOMMANDS: &[&str] = &[
"scan",
"hook",
"detectors",
"explain",
"diff",
"calibrate",
"watch",
"completion",
"backend",
"doctor",
"update",
"repair",
"uninstall",
"scan-system",
"daemon",
"tui",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Profile {
Plain,
NoColor,
ClicolorForce,
DumbTerm,
EmptyTerm,
TinyCols,
HugeCols,
NoHome,
EmptyHome,
CLocale,
Utf8Locale,
BadTmpdir,
ReadOnlyCwd,
BogusBackend,
OneThread,
ManyThreads,
}
pub const ALL_PROFILES: &[Profile] = &[
Profile::Plain,
Profile::NoColor,
Profile::ClicolorForce,
Profile::DumbTerm,
Profile::EmptyTerm,
Profile::TinyCols,
Profile::HugeCols,
Profile::NoHome,
Profile::EmptyHome,
Profile::CLocale,
Profile::Utf8Locale,
Profile::BadTmpdir,
Profile::ReadOnlyCwd,
Profile::BogusBackend,
Profile::OneThread,
Profile::ManyThreads,
];
impl Profile {
pub fn forces_color(self) -> bool {
matches!(self, Profile::ClicolorForce)
}
pub fn label(self) -> &'static str {
match self {
Profile::Plain => "plain",
Profile::NoColor => "no_color",
Profile::ClicolorForce => "clicolor_force",
Profile::DumbTerm => "dumb_term",
Profile::EmptyTerm => "empty_term",
Profile::TinyCols => "tiny_cols",
Profile::HugeCols => "huge_cols",
Profile::NoHome => "no_home",
Profile::EmptyHome => "empty_home",
Profile::CLocale => "c_locale",
Profile::Utf8Locale => "utf8_locale",
Profile::BadTmpdir => "bad_tmpdir",
Profile::ReadOnlyCwd => "read_only_cwd",
Profile::BogusBackend => "bogus_backend",
Profile::OneThread => "one_thread",
Profile::ManyThreads => "many_threads",
}
}
}
pub struct Outcome {
pub code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub stdout_raw: Vec<u8>,
pub stderr_raw: Vec<u8>,
pub what: String,
}
impl Outcome {
pub fn combined(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
}
fn apply_profile(profile: Profile, cmd: &mut Command) -> Option<TempDir> {
cmd.env("KEYHOG_RELEASE_API_BASE", "http://127.0.0.1:9");
match profile {
Profile::Plain => {}
Profile::NoColor => {
cmd.env("NO_COLOR", "1");
}
Profile::ClicolorForce => {
cmd.env("CLICOLOR_FORCE", "1").env_remove("NO_COLOR");
}
Profile::DumbTerm => {
cmd.env("TERM", "dumb");
}
Profile::EmptyTerm => {
cmd.env("TERM", "");
}
Profile::TinyCols => {
cmd.env("COLUMNS", "1").env("LINES", "1");
}
Profile::HugeCols => {
cmd.env("COLUMNS", "100000").env("LINES", "100000");
}
Profile::NoHome => {
cmd.env_remove("HOME").env_remove("XDG_CONFIG_HOME");
}
Profile::EmptyHome => {
cmd.env("HOME", "");
}
Profile::CLocale => {
cmd.env("LANG", "C").env("LC_ALL", "C");
}
Profile::Utf8Locale => {
cmd.env("LANG", "en_US.UTF-8").env("LC_ALL", "en_US.UTF-8");
}
Profile::BadTmpdir => {
cmd.env("TMPDIR", "/keyhog-nonexistent-tmp-7f3a");
}
Profile::ReadOnlyCwd => {
let dir = TempDir::new().expect("tempdir for read-only cwd");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555));
}
cmd.current_dir(dir.path());
return Some(dir);
}
Profile::BogusBackend => {
cmd.env("KEYHOG_BACKEND", "__bogus_backend__");
}
Profile::OneThread => {
cmd.env("KEYHOG_THREADS", "1");
}
Profile::ManyThreads => {
cmd.env("KEYHOG_THREADS", "4096");
}
}
None
}
pub fn run_stdin(profile: Profile, args: &[&str], stdin: &[u8]) -> Outcome {
let mut cmd = Command::new(binary());
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let _guard = apply_profile(profile, &mut cmd);
let mut child = cmd
.spawn()
.unwrap_or_else(|e| panic!("spawn keyhog {args:?} [{}]: {e}", profile.label()));
use std::io::Write;
if let Some(mut sin) = child.stdin.take() {
let _ = sin.write_all(stdin);
}
let out = child
.wait_with_output()
.unwrap_or_else(|e| panic!("wait keyhog {args:?} [{}]: {e}", profile.label()));
Outcome {
code: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
stdout_raw: out.stdout,
stderr_raw: out.stderr,
what: format!("`keyhog {}` [{}]", args.join(" "), profile.label()),
}
}
pub fn run(profile: Profile, args: &[&str]) -> Outcome {
run_stdin(profile, args, b"")
}
pub fn assert_no_ansi(o: &Outcome) {
let leak = |buf: &[u8]| buf.contains(&0x1b);
assert!(
!leak(&o.stdout_raw),
"{}: raw ANSI escape leaked to stdout when piped:\n{:?}",
o.what,
o.stdout.chars().take(200).collect::<String>()
);
assert!(
!leak(&o.stderr_raw),
"{}: raw ANSI escape leaked to stderr when piped:\n{:?}",
o.what,
o.stderr.chars().take(200).collect::<String>()
);
}
pub fn assert_no_panic(o: &Outcome) {
let hay = o.combined();
for needle in [
"panicked at",
"RUST_BACKTRACE",
"note: run with `RUST_BACKTRACE",
"internal error: entered unreachable code",
"called `Option::unwrap()`",
"called `Result::unwrap()`",
"index out of bounds",
"attempt to subtract with overflow",
"attempt to add with overflow",
] {
assert!(
!hay.contains(needle),
"{}: panic/backtrace marker {needle:?} in output (exit {:?}):\n{}",
o.what,
o.code,
hay.chars().take(600).collect::<String>()
);
}
assert_ne!(
o.code,
Some(101),
"{}: process exited 101 (escaped panic)",
o.what
);
}
pub fn assert_clean_exit(o: &Outcome) {
assert!(
o.code.is_some(),
"{}: process terminated by a signal (no exit code) - a crash",
o.what
);
}
pub fn assert_documented_exit(o: &Outcome) {
const DOCUMENTED: &[i32] = &[0, 1, 2, 3, 4, 10, 11, 130];
if let Some(c) = o.code {
assert!(
DOCUMENTED.contains(&c),
"{}: undocumented exit code {c} (documented: {DOCUMENTED:?})\nstderr:\n{}",
o.what,
o.stderr.chars().take(400).collect::<String>()
);
}
}
pub fn assert_valid_json_if_nonempty(o: &Outcome) {
let s = o.stdout.trim();
if s.is_empty() {
return;
}
serde_json::from_str::<serde_json::Value>(s).unwrap_or_else(|e| {
panic!(
"{}: --format json produced non-JSON stdout: {e}\n{}",
o.what,
s.chars().take(400).collect::<String>()
)
});
}