use std::{
env,
fs::{self, File},
path::PathBuf,
};
use assert_cmd::Command;
use bstr::BString;
use rand::RngExt;
use rusqlite::Connection;
use tempfile::TempDir;
mod common;
use common::{PxhCaller, PxhTestHelper};
fn count_lines(bytes: &[u8]) -> usize {
bytes.iter().filter(|&ch| *ch == b'\n').count()
}
#[test]
fn trivial_invocation() {
let mut naked_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
naked_cmd.env("PXH_DB_PATH", ":memory:").assert().success();
let mut show_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
show_cmd
.env_clear()
.env("PXH_DB_PATH", ":memory:")
.arg("show")
.arg("--suppress-headers")
.assert()
.success();
let pc = PxhCaller::new();
pc.call("insert --shellname zsh --hostname testhost --username testuser --session-id 12345678 test_command_1")
.assert()
.success();
pc.call("insert --shellname zsh --hostname testhost --username testuser --session-id 12345678 test_command_2")
.assert()
.success();
pc.call("export").assert().success();
let output = pc.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&output.stdout), 2);
let output = pc.call("show --suppress-headers non-matching-regex").output().unwrap();
assert_eq!(count_lines(&output.stdout), 0);
let output = pc.call("show --suppress-headers test").output().unwrap();
assert_eq!(count_lines(&output.stdout), 2);
let output = pc.call("show --suppress-headers command_1 command_2").output().unwrap();
assert_eq!(count_lines(&output.stdout), 0);
}
#[test]
fn show_with_here() {
let mut naked_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
naked_cmd.env("PXH_DB_PATH", ":memory:").assert().success();
let mut show_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
show_cmd
.env_clear()
.env("PXH_DB_PATH", ":memory:")
.arg("show")
.arg("--suppress-headers")
.assert()
.success();
let pc = PxhCaller::new();
for i in 1..=3 {
let cmd = format!(
"insert --shellname s --hostname h --username u --session-id 1 --working-directory /dir{i} test_command_{i}"
);
pc.call(cmd).assert().success();
}
let cmd = format!(
"insert --shellname s --hostname h --username u --session-id 1 --working-directory {} test_command_cwd",
env::current_dir().unwrap_or_default().to_string_lossy()
);
pc.call(cmd).assert().success();
let output = pc.call("show --suppress-headers --here").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
for i in 1..=3 {
let cmd =
format!("show --suppress-headers --here --working-directory /dir{i} test_command_{i}");
let output = pc.call(cmd).output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
}
}
#[test]
fn show_with_loosen() {
let mut naked_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
naked_cmd.env("PXH_DB_PATH", ":memory:").assert().success();
let mut show_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
show_cmd.env_clear().env("PXH_DB_PATH", ":memory:").arg("show").assert().success();
let pc = PxhCaller::new();
for i in 1..=3 {
let cmd = format!(
"insert --shellname s --hostname h --username u --session-id {i} test_command_{i} xyz"
);
pc.call(cmd).assert().success();
}
let output = pc.call("show --suppress-headers test xyz").output().unwrap();
assert_eq!(count_lines(&output.stdout), 3);
let output = pc.call("show --suppress-headers xyz test").output().unwrap();
assert_eq!(count_lines(&output.stdout), 0);
let output = pc.call("show --suppress-headers --loosen xyz test").output().unwrap();
assert_eq!(count_lines(&output.stdout), 3);
}
#[test]
fn show_with_session_id() {
let mut naked_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
naked_cmd.env("PXH_DB_PATH", ":memory:").assert().success();
let mut show_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
show_cmd.env_clear().env("PXH_DB_PATH", ":memory:").arg("show").assert().success();
let pc = PxhCaller::new();
for i in 1..=3 {
let cmd = format!(
"insert --shellname s --hostname h --username u --session-id {i} test_command_{i}"
);
pc.call(cmd).assert().success();
}
let cmd = "insert --shellname s --hostname h --username u --session-id 1 test_command_4";
pc.call(cmd).assert().success();
let output = pc.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&output.stdout), 4);
let output = pc.call("show --suppress-headers --session 1").output().unwrap();
assert_eq!(count_lines(&output.stdout), 2);
for i in 2..=3 {
let cmd = format!("show --suppress-headers --session {i}");
let output = pc.call(cmd).output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
}
}
#[test]
fn show_with_session_current() {
let pc = PxhCaller::new();
pc.call("insert --shellname s --hostname h --username u --session-id 123456789 cmd_session_a")
.assert()
.success();
pc.call("insert --shellname s --hostname h --username u --session-id 987654321 cmd_session_b")
.assert()
.success();
let mut cmd = pc.call("show --suppress-headers --session current");
cmd.env("PXH_SESSION_ID", "123456789");
let output = cmd.output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
assert!(String::from_utf8_lossy(&output.stdout).contains("cmd_session_a"));
}
#[test]
fn show_with_limit() {
let mut naked_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
naked_cmd.env("PXH_DB_PATH", ":memory:").assert().success();
let mut show_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
show_cmd.env_clear().env("PXH_DB_PATH", ":memory:").arg("show").assert().success();
let pc = PxhCaller::new();
for i in 1..=100 {
let cmd = format!(
"insert --shellname s --hostname h --username u --session-id {i} test_command_{i}"
);
pc.call(cmd).assert().success();
}
let output = pc.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&output.stdout), 50);
let output = pc.call("show --suppress-headers --limit 0").output().unwrap();
assert_eq!(count_lines(&output.stdout), 100);
}
#[test]
fn show_loosen_honors_limit() {
let helper = PxhTestHelper::new();
for i in 1..=10 {
helper
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
"h",
"--username",
"u",
"--session-id",
&i.to_string(),
"--",
&format!("echo test_{i}"),
])
.output()
.unwrap();
}
let output = helper
.command_with_args(&["show", "--suppress-headers", "--loosen", "--limit", "3", "echo"])
.output()
.unwrap();
assert_eq!(
count_lines(&output.stdout),
3,
"--loosen --limit 3 should show 3 results, got: {}",
String::from_utf8_lossy(&output.stdout)
);
}
#[test]
fn show_session_conflicts_with_here() {
let helper = PxhTestHelper::new();
let output =
helper.command_with_args(&["show", "--session", "current", "--here"]).output().unwrap();
assert!(!output.status.success(), "--session and --here should conflict");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot be used with") || stderr.contains("conflict"),
"error should mention conflict: {stderr}"
);
}
#[test]
fn show_with_case_insensitive() {
let mut naked_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
naked_cmd.env("PXH_DB_PATH", ":memory:").assert().success();
let mut show_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
show_cmd.env_clear().env("PXH_DB_PATH", ":memory:").arg("show").assert().success();
let pc = PxhCaller::new();
for i in 1..=3 {
let cmd = format!(
"insert --shellname s --hostname h --username u --session-id {i} TEST_command_{i}"
);
pc.call(cmd).assert().success();
}
let output = pc.call("show --suppress-headers test_command").output().unwrap();
assert_eq!(count_lines(&output.stdout), 0);
let output = pc.call("show --suppress-headers --ignore-case test_command").output().unwrap();
assert_eq!(count_lines(&output.stdout), 3);
let output = pc.call("show --suppress-headers --ignore-case TEST_COMMAND").output().unwrap();
assert_eq!(count_lines(&output.stdout), 3);
let output = pc.call("show --suppress-headers --ignore-case TEST_COMMAND_1").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
let output = pc.call("show --suppress-headers --ignore-case test_command_1").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
let output = pc.call("show --suppress-headers TEST").output().unwrap();
assert_eq!(count_lines(&output.stdout), 3);
}
#[test]
fn show_multi_pattern_regex_precedence() {
let helper = PxhTestHelper::new();
let insert = |sid: &str, cmd: &str| {
helper
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
"h",
"--username",
"u",
"--session-id",
sid,
"--",
cmd,
])
.output()
.unwrap();
};
insert("1", "git pull origin main");
insert("2", "git push origin main");
insert("3", "docker push myimage");
let output = helper
.command_with_args(&["show", "--suppress-headers", "git", "pull|push"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("git pull"), "should match git pull");
assert!(stdout.contains("git push"), "should match git push");
assert!(
!stdout.contains("docker push"),
"should NOT match docker push (regex precedence bug), got: {stdout}"
);
}
#[test]
fn show_ignore_case_preserves_regex_escapes() {
let helper = PxhTestHelper::new();
let insert = |cmd: &str| {
helper
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
"h",
"--username",
"u",
"--session-id",
"1",
cmd,
])
.output()
.unwrap();
};
insert("XtokenY"); insert(" token ");
let output = helper
.command_with_args(&["show", "--suppress-headers", "-i", r"\Stoken\S"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("XtokenY"),
"\\S should match non-whitespace around 'token', got: {stdout}"
);
assert!(!stdout.contains(" token "), "\\S should NOT match whitespace around 'token'");
}
#[test]
fn show_ignore_case_applies_to_all_patterns() {
let helper = PxhTestHelper::new();
helper
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
"h",
"--username",
"u",
"--session-id",
"1",
"Foo Bar Baz",
])
.output()
.unwrap();
let output = helper
.command_with_args(&["show", "--suppress-headers", "-i", "--loosen", "foo", "baz"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Foo Bar Baz"),
"-i should apply to all patterns including extra_filter_step, got: {stdout}"
);
}
#[test]
fn export_includes_machine_id() {
let helper = PxhTestHelper::new();
helper
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
"h",
"--username",
"u",
"--session-id",
"1",
"--start-unix-timestamp",
"1000000",
"echo hello",
])
.output()
.unwrap();
let conn = rusqlite::Connection::open(helper.db_path()).unwrap();
conn.execute("UPDATE command_history SET machine_id = 42 WHERE session_id = 1", []).unwrap();
drop(conn);
let output = helper.command_with_args(&["export"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("machine_id"),
"JSON export should include machine_id field, got: {stdout}"
);
assert!(stdout.contains("42"), "machine_id should be 42 in export, got: {stdout}");
}
#[test]
fn insert_seal_roundtrip() {
let pc = PxhCaller::new();
let commands = vec!["df", "sleep 1", "uptime"];
for command in &commands {
pc.call(format!(
"insert --shellname zsh --hostname testhost --username testuser --session-id 12345678 --start-unix-timestamp 1653573011 {command}"
))
.assert()
.success();
pc.call("seal --session-id 12345678 --exit-status 0 --end-unix-timestamp 1653573011")
.assert()
.success();
}
let output = pc.call("show --suppress-headers").output().unwrap();
assert!(!output.stdout.is_empty());
assert_eq!(count_lines(&output.stdout), commands.len());
let output = pc.call("show --suppress-headers u....Z?e").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1,);
let json_output = pc.call("export").output().unwrap();
let invocations: Vec<pxh::Invocation> =
serde_json::from_slice(json_output.stdout.as_slice()).unwrap();
assert_eq!(invocations.len(), commands.len());
for (idx, val) in invocations.iter().enumerate() {
assert_eq!(val.command, commands[idx]);
}
}
fn matches_expected_history(invocations: &[pxh::Invocation]) {
let expected = [
BString::from(r#"echo $'this "is" \'a\' \\n test\n\nboo'"#.to_string()),
BString::from("fd zsh".to_string()),
BString::from(
[101, 99, 104, 111, 32, 0xf0, 0xce, 0xb1, 0xce, 0xa5, 0xef, 0xbd, 0xa9].to_vec(),
),
];
assert_eq!(invocations.len(), expected.len());
for (idx, val) in invocations.iter().enumerate() {
assert_eq!(expected[idx], val.command);
}
}
#[test]
fn zsh_import_roundtrip() {
let resources = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/resources");
let pc = PxhCaller::new();
pc.call(format!(
"import --shellname zsh --histfile {}",
resources.join("zsh_histfile").to_string_lossy()
))
.assert()
.success();
let output = pc.call("show --suppress-headers").output().unwrap();
assert!(!output.stdout.is_empty());
assert_eq!(count_lines(&output.stdout), 3);
let json_output = pc.call("export").output().unwrap();
let invocations: Vec<pxh::Invocation> =
serde_json::from_slice(json_output.stdout.as_slice()).unwrap();
matches_expected_history(&invocations);
}
#[test]
fn zsh_import_multiline_commands() {
let resources = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/resources");
let helper = PxhTestHelper::new();
let output = helper
.command_with_args(&[
"import",
"--shellname",
"zsh",
"--histfile",
resources.join("zsh_histfile_multiline").to_str().unwrap(),
])
.output()
.unwrap();
assert!(output.status.success(), "import failed: {}", String::from_utf8_lossy(&output.stderr));
let json_output = helper.command_with_args(&["export"]).output().unwrap();
let invocations: Vec<pxh::Invocation> =
serde_json::from_slice(json_output.stdout.as_slice()).unwrap();
assert_eq!(invocations.len(), 3, "should import 3 commands (not 6 lines)");
assert_eq!(invocations[0].command, "echo simple");
let git_cmd = invocations[1].command.to_string();
assert!(
git_cmd.contains("git commit") && git_cmd.contains("-m"),
"multi-line git commit should be joined, got: {git_cmd}"
);
let curl_cmd = invocations[2].command.to_string();
assert!(
curl_cmd.contains("curl") && curl_cmd.contains("-H") && curl_cmd.contains("-d"),
"multi-line curl should be joined, got: {curl_cmd}"
);
}
#[test]
fn zsh_import_handles_malformed_timestamps() {
let resources = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/resources");
let helper = PxhTestHelper::new();
let output = helper
.command_with_args(&[
"import",
"--shellname",
"zsh",
"--histfile",
resources.join("zsh_histfile_malformed").to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"import should not panic on malformed timestamps, got: {}",
String::from_utf8_lossy(&output.stderr)
);
let export = helper.command_with_args(&["export"]).output().unwrap();
let stdout = String::from_utf8_lossy(&export.stdout);
assert!(stdout.contains("normal_command"), "valid commands should still be imported");
}
#[test]
fn bash_import_roundtrip() {
let resources = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/resources");
let pc = PxhCaller::new();
pc.call(format!(
"import --shellname bash --histfile {}",
resources.join("simple_bash_histfile").to_string_lossy()
))
.assert()
.success();
let output = pc.call("show --suppress-headers").output().unwrap();
assert!(!output.stdout.is_empty());
assert_eq!(count_lines(&output.stdout), 3);
let json_output = pc.call("export").output().unwrap();
let invocations: Vec<pxh::Invocation> =
serde_json::from_slice(json_output.stdout.as_slice()).unwrap();
matches_expected_history(&invocations);
}
#[test]
fn timestamped_bash_import_roundtrip() {
let resources = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/resources");
let pc = PxhCaller::new();
pc.call(format!(
"import --shellname bash --histfile {}",
resources.join("timestamped_bash_histfile").to_string_lossy()
))
.assert()
.success();
let output = pc.call("show --suppress-headers").output().unwrap();
assert!(!output.stdout.is_empty());
assert_eq!(count_lines(&output.stdout), 3);
let json_output = pc.call("export").output().unwrap();
let invocations: Vec<pxh::Invocation> =
serde_json::from_slice(json_output.stdout.as_slice()).unwrap();
matches_expected_history(&invocations);
}
#[test]
fn import_dry_run() {
let resources = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/resources");
let pc = PxhCaller::new();
let output = pc
.call(format!(
"import --shellname zsh --dry-run --histfile {}",
resources.join("zsh_histfile").to_string_lossy()
))
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("3 entries found"), "got: {stdout}");
assert!(stdout.contains("3 new"), "got: {stdout}");
let json_output = pc.call("export").output().unwrap();
let invocations: Vec<pxh::Invocation> =
serde_json::from_slice(json_output.stdout.as_slice()).unwrap();
assert_eq!(invocations.len(), 0);
pc.call(format!(
"import --shellname zsh --histfile {}",
resources.join("zsh_histfile").to_string_lossy()
))
.assert()
.success();
let output = pc
.call(format!(
"import --shellname zsh --dry-run --histfile {}",
resources.join("zsh_histfile").to_string_lossy()
))
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("0 new"), "got: {stdout}");
assert!(stdout.contains("3 duplicates"), "got: {stdout}");
}
#[test]
fn install_command() {
let tmpdir = TempDir::new().unwrap();
let home = tmpdir.path();
let zshrc = home.join(".zshrc");
let bashrc = home.join(".bashrc");
File::create(&zshrc).unwrap();
File::create(&bashrc).unwrap();
let output = Command::new(assert_cmd::cargo::cargo_bin!("pxh"))
.env_clear()
.env("HOME", home)
.args(["install", "zsh"])
.output()
.unwrap();
assert!(output.status.success());
let zshrc_content = fs::read_to_string(&zshrc).unwrap();
assert!(zshrc_content.contains("pxh shell-config zsh"));
let output = Command::new(assert_cmd::cargo::cargo_bin!("pxh"))
.env_clear()
.env("HOME", home)
.args(["install", "bash"])
.output()
.unwrap();
assert!(output.status.success());
let bashrc_content = fs::read_to_string(&bashrc).unwrap();
assert!(bashrc_content.contains("pxh shell-config bash"));
let output = Command::new(assert_cmd::cargo::cargo_bin!("pxh"))
.env_clear()
.env("HOME", home)
.args(["install", "invalid"])
.output()
.unwrap();
assert!(!output.status.success());
}
#[test]
fn shell_config_command() {
let output = Command::new(assert_cmd::cargo::cargo_bin!("pxh"))
.env_clear()
.args(["shell-config", "zsh"])
.output()
.unwrap();
assert!(output.status.success());
assert!(!output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stdout).contains("_pxh_addhistory"));
assert!(String::from_utf8_lossy(&output.stdout).contains("add-zsh-hook"));
let output = Command::new(assert_cmd::cargo::cargo_bin!("pxh"))
.env_clear()
.args(["shell-config", "bash"])
.output()
.unwrap();
assert!(output.status.success());
assert!(!output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stdout).contains("preexec()"));
assert!(String::from_utf8_lossy(&output.stdout).contains("bash-preexec.sh"));
let output = Command::new(assert_cmd::cargo::cargo_bin!("pxh"))
.env_clear()
.args(["shell-config", "invalid"])
.output()
.unwrap();
assert!(!output.status.success());
}
#[test]
fn scrub_command() {
let mut naked_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
naked_cmd.env("PXH_DB_PATH", ":memory:").assert().success();
let mut show_cmd = Command::new(assert_cmd::cargo::cargo_bin!("pxh"));
show_cmd.env_clear().env("PXH_DB_PATH", ":memory:").arg("show").assert().success();
let pc = PxhCaller::new();
for i in 1..=10 {
let cmd = format!(
"insert --shellname s --hostname h --username u --session-id {i} test_command_{i}"
);
pc.call(cmd).assert().success();
}
let output = pc.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&output.stdout), 10);
let _output = pc.call("scrub --yes test_command_10").output().unwrap();
let output = pc.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&output.stdout), 9);
let _output = pc.call("scrub --yes test_command_").output().unwrap();
let output = pc.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&output.stdout), 0);
}
#[test]
fn symlink_pxhs_behavior() {
let tempdir = TempDir::new().unwrap();
let pxh_symlink_path = tempdir.path().join("pxh");
let pxhs_path = tempdir.path().join("pxhs");
let bin_path = assert_cmd::cargo::cargo_bin!("pxh");
std::os::unix::fs::symlink(&bin_path, &pxh_symlink_path).unwrap();
std::os::unix::fs::symlink(&pxh_symlink_path, &pxhs_path).unwrap();
let pc = PxhCaller::new();
pc.call("insert --shellname zsh --hostname testhost --username testuser --session-id 12345678 test_command_1")
.assert()
.success();
pc.call("seal --session-id 12345678 --exit-status 0 --end-unix-timestamp 1600000000")
.assert()
.success();
let base_output = pc.call("show --suppress-headers").output().unwrap();
assert!(base_output.status.success());
assert!(String::from_utf8_lossy(&base_output.stdout).contains("test_command_1"));
let shorthand_output = Command::new(&pxhs_path)
.env("PXH_DB_PATH", pc.tmpdir().join("test"))
.env("PXH_HOSTNAME", "testhost")
.args(["test_command"])
.output()
.unwrap();
assert!(shorthand_output.status.success());
let shorthand_str = String::from_utf8_lossy(&shorthand_output.stdout);
assert!(
shorthand_str.contains("test_command_1"),
"The shorthand form pxhs should act like pxh show"
);
let help_output = Command::new(&pxhs_path)
.env("PXH_DB_PATH", pc.tmpdir().join("test"))
.args(["--help"])
.output()
.unwrap();
assert!(help_output.status.success());
let help_str = String::from_utf8_lossy(&help_output.stdout);
assert!(
help_str.contains("search for and display history entries"),
"Help output should include the show command description"
);
}
#[test]
fn sync_roundtrip() {
let pc_even = PxhCaller::new();
let pc_odd = PxhCaller::new();
for i in 1..=40 {
let cmd = format!(
"insert --shellname s --hostname h --username u --working-directory d --start-unix-timestamp 1 --session-id {i} test_command_{i}",
);
if i % 2 == 0 {
pc_even.call(cmd).assert().success();
} else {
pc_odd.call(cmd).assert().success();
}
}
let sync_dir = TempDir::new().unwrap();
let sync_cmd = format!("sync {}", sync_dir.path().to_string_lossy());
pc_even.call(&sync_cmd).assert().success();
pc_odd.call(&sync_cmd).assert().success();
let even_output = pc_even.call("show --suppress-headers").output().unwrap();
let even_odd_output = pc_odd.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&even_output.stdout), 20);
assert_eq!(count_lines(&even_odd_output.stdout), 40);
let pc_merged = PxhCaller::new();
pc_merged.call(&sync_cmd).assert().success();
let merged_output = pc_merged.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&merged_output.stdout), 40);
}
#[test]
fn test_maintenance() {
let pc = PxhCaller::new();
let db_path = pc.tmpdir().join("test");
{
let mut conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
"
PRAGMA synchronous = OFF;
PRAGMA journal_mode = MEMORY;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = 10000;
",
)
.unwrap();
conn.execute_batch(include_str!("../src/base_schema.sql")).unwrap();
let tx = conn.transaction().unwrap();
let num_commands = 3000; let mut rng = rand::rng();
let working_dirs = ["/home/user", "/var/log", "/etc", "/tmp"];
for i in 1..=num_commands {
let session_id = i / 10 + 1;
let shell = if i % 3 == 0 { "zsh" } else { "bash" };
let hostname = format!("host{}", i % 3 + 1);
let username = format!("user{}", i % 2 + 1);
let command = match i % 8 {
0 => "git commit",
1 => "ls",
2 => "cd etc",
3 => "cd home",
4 => "cat file",
5 => "uptime",
6 => "history",
_ => "command",
};
let working_dir = working_dirs[rng.random_range(0..working_dirs.len())];
tx.execute(
"INSERT INTO command_history (
session_id, full_command, shellname, hostname, username,
working_directory, exit_status, start_unix_timestamp, end_unix_timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
session_id,
command,
shell,
hostname,
username,
working_dir,
if i % 5 == 0 { None } else { Some(if i % 17 == 0 { 1 } else { 0 }) },
1600000000 + i,
if i % 5 == 0 { None } else { Some(1600000010 + i) },
),
)
.unwrap();
}
tx.commit().unwrap();
conn.execute("DELETE FROM command_history WHERE rowid % 3 = 0", []).unwrap();
conn.execute("PRAGMA page_size = 4096", []).unwrap();
conn.execute("PRAGMA incremental_vacuum(5)", []).unwrap();
}
let conn = Connection::open(&db_path).unwrap();
let initial_size: i64 = conn
.query_row(
"SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()",
[],
|r| r.get(0),
)
.unwrap();
let remaining_rows: i64 =
conn.query_row("SELECT COUNT(*) FROM command_history", [], |r| r.get(0)).unwrap();
println!("Database has {} rows before maintenance", remaining_rows);
assert!(remaining_rows > 100, "Should have enough rows for testing");
println!("Running maintenance command...");
pc.call("maintenance").assert().success();
let conn_after = Connection::open(&db_path).unwrap();
let after_size: i64 = conn_after
.query_row(
"SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()",
[],
|r| r.get(0),
)
.unwrap();
println!("Database size before: {} bytes, after: {} bytes", initial_size, after_size);
let freelist_count_after: i64 =
conn_after.query_row("PRAGMA freelist_count", [], |r| r.get(0)).unwrap();
assert_eq!(freelist_count_after, 0, "Freelist should be empty after VACUUM");
let stat_table_exists: i64 = conn_after
.query_row("SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'", [], |r| {
r.get(0)
})
.unwrap();
assert!(stat_table_exists > 0, "ANALYZE should create the sqlite_stat1 table");
let stat_entries: i64 =
conn_after.query_row("SELECT COUNT(*) FROM sqlite_stat1", [], |r| r.get(0)).unwrap_or(0);
println!("Database has {} statistic entries after ANALYZE", stat_entries);
assert!(stat_entries > 0, "sqlite_stat1 should have entries after ANALYZE");
}
#[test]
fn test_maintenance_multiple_files() {
let pc_maint = PxhCaller::new();
let db_path1 = pc_maint.tmpdir().join("test1.db");
let db_path2 = pc_maint.tmpdir().join("test2.db");
fn setup_test_db(path: &PathBuf, num_rows: usize, command_prefix: &str) -> (Connection, i64) {
let mut conn = Connection::open(path).unwrap();
conn.execute_batch(
"
PRAGMA synchronous = OFF;
PRAGMA journal_mode = MEMORY;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = 10000;
",
)
.unwrap();
conn.execute_batch(include_str!("../src/base_schema.sql")).unwrap();
let tx = conn.transaction().unwrap();
for i in 1..=num_rows {
let session_id = (i / 10 + 1) as i64;
let shellname = if i % 2 == 0 { "zsh" } else { "bash" };
let hostname = format!("host{}", i % 2 + 1);
let username = format!("user{}", i % 2 + 1);
let command = format!("{}_{}", command_prefix, i);
tx.execute(
"INSERT INTO command_history (
session_id, full_command, shellname, hostname, username,
working_directory, exit_status, start_unix_timestamp, end_unix_timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
session_id,
command,
shellname,
hostname,
username,
"/tmp",
Some(0),
1600000000 + i as i64,
Some(1600000010 + i as i64),
),
)
.unwrap();
}
tx.commit().unwrap();
conn.execute("DELETE FROM command_history WHERE rowid % 3 = 0", []).unwrap();
conn.execute("PRAGMA page_size = 4096", []).unwrap();
conn.execute("PRAGMA incremental_vacuum(5)", []).unwrap();
let row_count = conn
.query_row("SELECT COUNT(*) FROM command_history", [], |r| r.get::<_, i64>(0))
.unwrap();
(conn, row_count)
}
let (_conn1, rows_before1) = setup_test_db(&db_path1, 300, "command_db1");
let (_conn2, rows_before2) = setup_test_db(&db_path2, 300, "command_db2");
println!("Database 1: {} rows, Database 2: {} rows", rows_before1, rows_before2);
let maintenance_cmd =
format!("maintenance {} {}", db_path1.to_string_lossy(), db_path2.to_string_lossy());
pc_maint.call(&maintenance_cmd).assert().success();
let conn1_after = Connection::open(&db_path1).unwrap();
let conn2_after = Connection::open(&db_path2).unwrap();
let rows_after1: i64 =
conn1_after.query_row("SELECT COUNT(*) FROM command_history", [], |r| r.get(0)).unwrap();
let rows_after2: i64 =
conn2_after.query_row("SELECT COUNT(*) FROM command_history", [], |r| r.get(0)).unwrap();
println!(
"After maintenance - Database 1: {} rows, Database 2: {} rows",
rows_after1, rows_after2
);
assert_eq!(rows_before1, rows_after1, "Row count should be the same after maintenance for DB1");
assert_eq!(rows_before2, rows_after2, "Row count should be the same after maintenance for DB2");
let freelist_count1_after: i64 =
conn1_after.query_row("PRAGMA freelist_count", [], |r| r.get(0)).unwrap();
let freelist_count2_after: i64 =
conn2_after.query_row("PRAGMA freelist_count", [], |r| r.get(0)).unwrap();
assert_eq!(freelist_count1_after, 0, "Freelist should be empty in DB1 after VACUUM");
assert_eq!(freelist_count2_after, 0, "Freelist should be empty in DB2 after VACUUM");
let stat_table_exists1: i64 = conn1_after
.query_row("SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'", [], |r| {
r.get(0)
})
.unwrap();
let stat_table_exists2: i64 = conn2_after
.query_row("SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'", [], |r| {
r.get(0)
})
.unwrap();
assert!(stat_table_exists1 > 0, "ANALYZE should create the sqlite_stat1 table in DB1");
assert!(stat_table_exists2 > 0, "ANALYZE should create the sqlite_stat1 table in DB2");
let stat_entries1: i64 =
conn1_after.query_row("SELECT COUNT(*) FROM sqlite_stat1", [], |r| r.get(0)).unwrap_or(0);
let stat_entries2: i64 =
conn2_after.query_row("SELECT COUNT(*) FROM sqlite_stat1", [], |r| r.get(0)).unwrap_or(0);
assert!(stat_entries1 > 0, "sqlite_stat1 should have entries in DB1 after ANALYZE");
assert!(stat_entries2 > 0, "sqlite_stat1 should have entries in DB2 after ANALYZE");
}
#[test]
fn test_maintenance_clean_nonstandard_tables() {
let pc = PxhCaller::new();
let db_path = pc.tmpdir().join("test");
let mut conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
"
PRAGMA synchronous = OFF;
PRAGMA journal_mode = MEMORY;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = 10000;
",
)
.unwrap();
conn.execute_batch(include_str!("../src/base_schema.sql")).unwrap();
let tx = conn.transaction().unwrap();
for i in 1..=3 {
tx.execute(
"INSERT INTO command_history (
session_id, full_command, shellname, hostname, username,
working_directory, exit_status, start_unix_timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
i,
format!("command{}", i),
"bash",
"host1",
"user1",
"/tmp",
Some(0),
1600000000 + i as i64,
),
)
.unwrap();
}
tx.commit().unwrap();
println!("Creating non-standard tables and indexes for testing...");
conn.execute_batch(
"
-- Create non-standard tables that should be removed
CREATE TABLE temp_table1 (id INTEGER PRIMARY KEY, data TEXT);
CREATE TABLE custom_data (id INTEGER PRIMARY KEY, name TEXT, value TEXT);
CREATE INDEX idx_custom_data_name ON custom_data (name);
-- Create tables with KEEP_ prefix that should be preserved
CREATE TABLE KEEP_important_data (id INTEGER PRIMARY KEY, data TEXT);
CREATE INDEX KEEP_idx_important ON KEEP_important_data (data);
-- Insert some data in all the tables with a transaction
BEGIN TRANSACTION;
INSERT INTO temp_table1 (id, data) VALUES (1, 'temp data');
INSERT INTO custom_data (id, name, value) VALUES
(1, 'setting1', 'value1'),
(2, 'setting2', 'value2');
INSERT INTO KEEP_important_data (id, data) VALUES (1, 'important data');
COMMIT;
",
)
.unwrap();
let table_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
table_count >= 5,
"Should have at least 5 tables (command_history, settings, temp_table1, custom_data, KEEP_important_data)"
);
println!("Running maintenance command...");
pc.call("maintenance").assert().success();
let conn_after = Connection::open(&db_path).unwrap();
let tables_after: Vec<(String, String)> = {
let mut stmt = conn_after
.prepare(
"
SELECT name, type FROM sqlite_master
WHERE type IN ('table', 'index')
AND name NOT LIKE 'sqlite_%'
ORDER BY type, name
",
)
.unwrap();
let rows = stmt
.query_map([], |row| {
let name: String = row.get(0)?;
let type_: String = row.get(1)?;
Ok((name, type_))
})
.unwrap();
rows.collect::<Result<Vec<(String, String)>, _>>().unwrap()
};
println!("Database objects after maintenance:");
for (name, type_) in &tables_after {
println!(" {} ({})", name, type_);
}
let object_exists =
|name: &str| -> bool { tables_after.iter().any(|(obj_name, _)| obj_name == name) };
assert!(!object_exists("temp_table1"), "temp_table1 should have been removed");
assert!(!object_exists("custom_data"), "custom_data should have been removed");
assert!(
!object_exists("idx_custom_data_name"),
"idx_custom_data_name should have been removed"
);
assert!(object_exists("KEEP_important_data"), "KEEP_important_data should have been preserved");
assert!(object_exists("KEEP_idx_important"), "KEEP_idx_important should have been preserved");
let keep_data_count: i64 =
conn_after.query_row("SELECT COUNT(*) FROM KEEP_important_data", [], |r| r.get(0)).unwrap();
assert_eq!(keep_data_count, 1, "Data in KEEP_ table should be preserved");
assert!(object_exists("command_history"), "command_history should still exist");
assert!(
object_exists("idx_command_history_unique"),
"idx_command_history_unique should still exist"
);
}
#[test]
fn test_autosuggest() {
let pc = PxhCaller::new();
let insert = |cmd: &str, ts: u64| {
pc.call(format!(
"insert --shellname zsh --hostname h --username u --session-id 1 --start-unix-timestamp {ts} -- {cmd}"
))
.assert()
.success();
};
insert("git status", 100);
insert("git commit", 200);
insert("grep foo", 300);
insert("cargo build", 400);
let autosuggest = |prefix: &str| {
let mut cmd = pc.call("autosuggest");
cmd.arg("--").arg(prefix);
cmd.output().unwrap().stdout
};
assert_eq!(autosuggest("git"), b"git commit");
assert_eq!(autosuggest("git s"), b"git status");
assert_eq!(autosuggest("nonexistent"), b"");
assert_eq!(autosuggest(""), b"");
insert("g_t special", 500);
assert_eq!(autosuggest("g_"), b"g_t special");
}
#[test]
fn show_with_failed_flag() {
let pc = PxhCaller::new();
pc.call("insert --shellname zsh --hostname h --username u --session-id 1 --exit-status 0 success_cmd")
.assert()
.success();
pc.call("insert --shellname zsh --hostname h --username u --session-id 1 --exit-status 1 failed_cmd")
.assert()
.success();
pc.call("insert --shellname zsh --hostname h --username u --session-id 1 unsealed_cmd")
.assert()
.success();
let output = pc.call("show --suppress-headers").output().unwrap();
assert_eq!(count_lines(&output.stdout), 3);
let output = pc.call("show --suppress-headers --failed").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
assert!(String::from_utf8_lossy(&output.stdout).contains("failed_cmd"));
let output = pc.call("show --suppress-headers -F").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
assert!(String::from_utf8_lossy(&output.stdout).contains("failed_cmd"));
}
#[test]
fn show_with_short_here_flag() {
let pc = PxhCaller::new();
let cwd = env::current_dir().unwrap_or_default();
pc.call(format!(
"insert --shellname s --hostname h --username u --session-id 1 --working-directory {} here_cmd",
cwd.to_string_lossy()
))
.assert()
.success();
pc.call("insert --shellname s --hostname h --username u --session-id 1 --working-directory /other other_cmd")
.assert()
.success();
let output = pc.call("show --suppress-headers -H").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
assert!(String::from_utf8_lossy(&output.stdout).contains("here_cmd"));
}
#[test]
fn show_working_directory_implies_here() {
let pc = PxhCaller::new();
pc.call("insert --shellname s --hostname h --username u --session-id 1 --working-directory /mydir wd_cmd")
.assert()
.success();
pc.call("insert --shellname s --hostname h --username u --session-id 1 --working-directory /other other_cmd")
.assert()
.success();
let output = pc.call("show --suppress-headers --working-directory /mydir").output().unwrap();
assert_eq!(count_lines(&output.stdout), 1);
assert!(String::from_utf8_lossy(&output.stdout).contains("wd_cmd"));
let output =
pc.call("show --suppress-headers --working-directory /nonexistent").output().unwrap();
assert_eq!(count_lines(&output.stdout), 0);
}
fn count_commands(helper: &PxhTestHelper) -> usize {
let output = helper.command_with_args(&["show", "--suppress-headers"]).output().unwrap();
if !output.status.success() || output.stdout.is_empty() {
return 0;
}
String::from_utf8_lossy(&output.stdout).lines().count()
}
#[test]
fn insert_ignores_configured_patterns() {
let caller = PxhTestHelper::new();
let config_path = caller.home_dir().join(".pxh/config.toml");
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(
&config_path,
r#"
[history]
ignore_patterns = ["^ls$", "^cd( .)?$", "^pwd$"]
"#,
)
.unwrap();
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let output = caller
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
&caller.hostname,
"--username",
"testuser",
"--session-id",
"12345",
"--start-unix-timestamp",
&now.to_string(),
"--working-directory",
"/tmp",
"ls",
])
.output()
.unwrap();
assert!(output.status.success());
let output = caller
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
&caller.hostname,
"--username",
"testuser",
"--session-id",
"12345",
"--start-unix-timestamp",
&(now + 1).to_string(),
"--working-directory",
"/tmp",
"--",
"ls",
"-la",
])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(count_commands(&caller), 1);
let output = caller.command_with_args(&["show", "-l", "0"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("ls -la"), "Non-ignored command should be present");
}
#[test]
fn insert_filters_with_default_patterns() {
let caller = PxhTestHelper::new();
let _ = std::fs::remove_file(caller.home_dir().join(".pxh/config.toml"));
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let output = caller
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
&caller.hostname,
"--username",
"testuser",
"--session-id",
"12345",
"--start-unix-timestamp",
&now.to_string(),
"--working-directory",
"/tmp",
"ls",
])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(count_commands(&caller), 0, "ls should be filtered by default patterns");
}
#[test]
fn insert_records_when_ignore_patterns_empty() {
let caller = PxhTestHelper::new();
let config_dir = caller.home_dir().join(".pxh");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("config.toml"), "[history]\nignore_patterns = []\n").unwrap();
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let output = caller
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
&caller.hostname,
"--username",
"testuser",
"--session-id",
"12345",
"--start-unix-timestamp",
&now.to_string(),
"--working-directory",
"/tmp",
"ls",
])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(count_commands(&caller), 1, "ls should be recorded with empty ignore_patterns");
}
#[test]
fn stats_command() {
let caller = PxhTestHelper::new();
let output = caller.command_with_args(&["stats"]).output().unwrap();
assert!(output.status.success(), "stats should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.is_empty(), "stats should produce output");
}
#[test]
fn completions_command_bash() {
let caller = PxhTestHelper::new();
let output = caller.command_with_args(&["completions", "bash"]).output().unwrap();
assert!(output.status.success(), "completions bash should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.is_empty(), "bash completions should produce output");
}
#[test]
fn completions_command_zsh() {
let caller = PxhTestHelper::new();
let output = caller.command_with_args(&["completions", "zsh"]).output().unwrap();
assert!(output.status.success(), "completions zsh should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.is_empty(), "zsh completions should produce output");
}
#[test]
fn config_command_prints_path() {
let caller = PxhTestHelper::new();
let output = caller.command_with_args(&["config", "--path"]).output().unwrap();
assert!(output.status.success(), "config --path should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("config.toml"), "should print config path");
}
#[test]
fn maintenance_rejects_non_pxh_database() {
let helper = PxhTestHelper::new();
let non_pxh_db = helper.home_dir().join("not_pxh.db");
let conn = rusqlite::Connection::open(&non_pxh_db).unwrap();
conn.execute_batch(
"CREATE TABLE bookmarks (id INTEGER PRIMARY KEY, url TEXT);
INSERT INTO bookmarks VALUES (1, 'https://example.com');",
)
.unwrap();
drop(conn);
let output =
helper.command_with_args(&["maintenance", non_pxh_db.to_str().unwrap()]).output().unwrap();
assert!(!output.status.success(), "maintenance should fail on non-pxh database");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("does not look like a pxh database"),
"should explain why it refused, got: {combined}"
);
let conn = rusqlite::Connection::open(&non_pxh_db).unwrap();
let url: String =
conn.query_row("SELECT url FROM bookmarks WHERE id = 1", [], |r| r.get(0)).unwrap();
assert_eq!(url, "https://example.com", "non-pxh database should be untouched");
}
#[test]
fn insert_accepts_hyphen_prefixed_commands() {
let helper = PxhTestHelper::new();
let output = helper
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
"h",
"--username",
"u",
"--session-id",
"1",
"--start-unix-timestamp",
"1000000",
"--",
"-la",
])
.output()
.unwrap();
assert!(
output.status.success(),
"insert should accept -la as a command, got: {}",
String::from_utf8_lossy(&output.stderr)
);
let output = helper
.command_with_args(&[
"insert",
"--shellname",
"bash",
"--hostname",
"h",
"--username",
"u",
"--session-id",
"2",
"--start-unix-timestamp",
"1000001",
"-rf",
])
.output()
.unwrap();
assert!(
output.status.success(),
"-rf should be accepted as a command without --, got: {}",
String::from_utf8_lossy(&output.stderr)
);
let output =
helper.command_with_args(&["show", "--suppress-headers", "--limit", "0"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("-la"), "command '-la' should be in history");
assert!(stdout.contains("-rf"), "command '-rf' should be in history");
}