//! End-to-end tests against the built binary; `XDG_DATA_HOME` is redirected //! to a temp dir so the real history is never touched. use std::path::{Path, PathBuf}; use std::process::{Command, Output}; const BIN: &str = env!("CARGO_BIN_EXE_diethist"); struct Sandbox { dir: PathBuf, } impl Sandbox { fn new(name: &str) -> Self { let dir = std::env::temp_dir().join(format!("diethist-test-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); Self { dir } } fn run(&self, args: &[&str]) -> Output { Command::new(BIN) .args(args) .env("XDG_DATA_HOME", &self.dir) .env_remove("HISTFILE") .output() .expect("failed to run diethist") } fn list(&self) -> Vec { let out = self.run(&["list"]); assert!(out.status.success(), "list failed: {out:?}"); String::from_utf8_lossy(&out.stdout) .lines() .map(str::to_string) .collect() } fn write(&self, name: &str, contents: &str) -> PathBuf { let path = self.dir.join(name); std::fs::write(&path, contents).unwrap(); path } } impl Drop for Sandbox { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.dir); } } /// `ts \t exit \t cmd`, with ts as `YYYY-MM-DD HH:MM`. fn split_row(row: &str) -> (&str, &str, &str) { let mut parts = row.splitn(3, '\t'); let ts = parts.next().unwrap(); let exit = parts.next().unwrap(); let cmd = parts.next().unwrap(); (ts, exit, cmd) } #[test] fn add_then_list_round_trips() { let sb = Sandbox::new("roundtrip"); sb.run(&["add", "--exit", "7", "--", "git", "status"]); let rows = sb.list(); assert_eq!(rows.len(), 1); let (ts, exit, cmd) = split_row(&rows[0]); assert_eq!(cmd, "git status"); assert_eq!(exit, "7"); assert_eq!(ts.len(), "2026-01-02 15:04".len(), "unexpected ts: {ts:?}"); } #[test] fn rerunning_a_command_bumps_it_instead_of_duplicating() { let sb = Sandbox::new("dedupe"); sb.run(&["add", "--exit", "0", "--", "ls"]); sb.run(&["add", "--exit", "1", "--", "ls"]); sb.run(&["add", "--exit", "0", "--", "cd"]); let rows = sb.list(); assert_eq!( rows.len(), 2, "expected one row per distinct command: {rows:?}" ); // the exit code of the most recent run wins. let ls = rows.iter().find(|r| split_row(r).2 == "ls").unwrap(); assert_eq!(split_row(ls).1, "1"); } #[test] fn blank_commands_are_ignored() { let sb = Sandbox::new("blank"); sb.run(&["add", "--exit", "0", "--", " "]); sb.run(&["add", "--exit", "0", "--cmd", ""]); assert!(sb.list().is_empty()); } #[test] fn trailing_whitespace_is_trimmed_before_storing() { let sb = Sandbox::new("trim"); sb.run(&["add", "--exit", "0", "--cmd", "echo hi "]); sb.run(&["add", "--exit", "0", "--cmd", "echo hi"]); let rows = sb.list(); assert_eq!(rows.len(), 1); assert_eq!(split_row(&rows[0]).2, "echo hi"); } #[test] fn imports_fish_history() { let sb = Sandbox::new("import-fish"); let hist = sb.write( "fish_history", "\ - cmd: hx dev when: 1787234415 paths: - dev - cmd: xattr -rd com.apple.quarantine Parallels\\\\ Desktop.dmg when: 1787234416 - cmd: cd when: 1787234417 ", ); let out = sb.run(&["import", "--file", hist.to_str().unwrap()]); assert!(out.status.success()); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("imported 3 commands"), "stderr: {stderr}"); assert!(stderr.contains("fish format"), "stderr: {stderr}"); let cmds: Vec = sb .list() .iter() .map(|r| split_row(r).2.to_string()) .collect(); assert!(cmds.contains(&"hx dev".to_string())); assert!(cmds.contains(&"cd".to_string())); // the `\\` escape decodes to a single backslash. assert!( cmds.contains(&r"xattr -rd com.apple.quarantine Parallels\ Desktop.dmg".to_string()), "{cmds:?}" ); } #[test] fn imports_bash_history_with_epoch_comments() { let sb = Sandbox::new("import-bash"); let hist = sb.write( "bash_history", "#1700000001\nls -la\n#1700000002\ncd /tmp\n", ); let out = sb.run(&["import", "--file", hist.to_str().unwrap()]); assert!(out.status.success()); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("imported 2 commands"), "stderr: {stderr}"); assert!(stderr.contains("bash format"), "stderr: {stderr}"); let rows = sb.list(); assert_eq!(rows.len(), 2); // 1700000001 is 2023-11-14 in UTC; only assert the parts that are tz-stable. assert!(rows.iter().all(|r| split_row(r).0.starts_with("2023-11-1"))); } #[test] fn imports_zsh_history_with_metadata_and_multiline() { let sb = Sandbox::new("import-zsh"); let hist = sb.write( "zsh_history", ": 1700000001:0;ls -la\n: 1700000002:3;for i in 1 2\\\ndo\\\ndone\nplain zsh line\n", ); let out = sb.run(&["import", "--file", hist.to_str().unwrap()]); assert!(out.status.success()); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("imported 3 commands"), "stderr: {stderr}"); assert!(stderr.contains("zsh format"), "stderr: {stderr}"); let rows = sb.list(); // newest first; the multiline entry spans 3 physical lines in `list` output assert_eq!(rows.len(), 5); assert_eq!(split_row(&rows[0]).2, "plain zsh line"); assert!( !split_row(&rows[0]).0.starts_with("2023-11-1"), "no metadata → ts falls back to import time" ); assert_eq!(split_row(&rows[1]).2, "for i in 1 2"); assert_eq!(rows[2], "do"); assert_eq!(rows[3], "done"); assert!(split_row(&rows[1]).0.starts_with("2023-11-1")); assert_eq!(split_row(&rows[4]).2, "ls -la"); assert!(split_row(&rows[4]).0.starts_with("2023-11-1")); } #[test] fn import_reimports_are_idempotent() { let sb = Sandbox::new("import-twice"); let hist = sb.write("bash_history", "#1700000001\nls -la\ncd /tmp\n"); let file = hist.to_str().unwrap(); sb.run(&["import", "--file", file]); sb.run(&["import", "--file", file]); assert_eq!(sb.list().len(), 2); } #[test] fn import_skips_consecutive_duplicates() { let sb = Sandbox::new("import-dupes"); let hist = sb.write("bash_history", "ls\nls\nls\ncd\nls\n"); let out = sb.run(&["import", "--file", hist.to_str().unwrap()]); let stderr = String::from_utf8_lossy(&out.stderr); // three runs of ls collapse to one each: ls, cd, ls assert!(stderr.contains("imported 3 commands"), "stderr: {stderr}"); assert_eq!(sb.list().len(), 2); } #[test] fn init_prints_each_shell_script() { let sb = Sandbox::new("init"); for (shell, needle) in [ ("bash", "bind -x"), ("fish", "fish_postexec"), ("zsh", "bindkey"), ] { let out = sb.run(&["init", shell]); assert!(out.status.success(), "init {shell} failed"); let script = String::from_utf8_lossy(&out.stdout); assert!(script.contains(needle), "init {shell}: {script}"); } // no argument means bash assert_eq!(sb.run(&["init"]).stdout, sb.run(&["init", "bash"]).stdout); assert!(!sb.run(&["init", "nonsense"]).status.success()); } #[test] fn the_database_lands_in_the_expected_place() { let sb = Sandbox::new("dbpath"); sb.run(&["add", "--exit", "0", "--", "ls"]); assert!(Path::new(&sb.dir).join("diethist/history.db").exists()); }