Skip to main content

pxh/
lib.rs

1use std::{
2    collections::HashMap,
3    env,
4    fmt::Write as FmtWrite,
5    fs::File,
6    io,
7    io::{BufReader, BufWriter, Read, Write as IoWrite},
8    os::unix::{
9        ffi::{OsStrExt, OsStringExt},
10        fs::MetadataExt,
11    },
12    path::{Path, PathBuf},
13    str,
14    sync::Arc,
15    time::Duration,
16};
17
18use bstr::{BString, ByteSlice, io::BufReadExt};
19use chrono::prelude::{Local, TimeZone};
20use itertools::Itertools;
21use regex::bytes::Regex;
22use rusqlite::{Connection, Error, Result, Row, Transaction, functions::FunctionFlags};
23use serde::{Deserialize, Serialize};
24
25type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
26
27pub mod recall;
28pub mod secrets_patterns;
29
30pub fn get_setting(
31    conn: &Connection,
32    key: &str,
33) -> Result<Option<BString>, Box<dyn std::error::Error>> {
34    let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?")?;
35    let mut rows = stmt.query([key])?;
36
37    if let Some(row) = rows.next()? {
38        let value: Vec<u8> = row.get(0)?;
39        Ok(Some(BString::from(value)))
40    } else {
41        Ok(None)
42    }
43}
44
45pub fn set_setting(
46    conn: &Connection,
47    key: &str,
48    value: &BString,
49) -> Result<(), Box<dyn std::error::Error>> {
50    conn.execute(
51        "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
52        (key, value.as_bytes()),
53    )?;
54    Ok(())
55}
56
57const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
58
59pub fn get_hostname() -> BString {
60    let hostname =
61        env::var_os("PXH_HOSTNAME").unwrap_or_else(|| hostname::get().unwrap_or_default());
62
63    // Extract short hostname (before first dot). The shell integration already
64    // sets PXH_HOSTNAME via `hostname -s`, but when falling back to
65    // hostname::get() the result may be an FQDN, so strip the domain suffix.
66    let hostname_bytes = hostname.as_bytes();
67    if let Some(dot_pos) = hostname_bytes.iter().position(|&b| b == b'.') {
68        BString::from(&hostname_bytes[..dot_pos])
69    } else {
70        BString::from(hostname_bytes)
71    }
72}
73
74/// Resolve symlinks in a path, even if the final target doesn't exist yet.
75/// Walks components left-to-right: canonicalizes each prefix that exists on disk,
76/// follows symlinks whose targets don't exist yet, and appends remaining components.
77fn resolve_through_symlinks(path: &Path) -> PathBuf {
78    let mut resolved = PathBuf::new();
79    for component in path.components() {
80        resolved.push(component);
81        if let Ok(canonical) = std::fs::canonicalize(&resolved) {
82            resolved = canonical;
83        } else if let Ok(target) = std::fs::read_link(&resolved) {
84            // Symlink exists but target doesn't -- follow it anyway
85            if target.is_absolute() {
86                resolved = target;
87            } else {
88                resolved.pop();
89                resolved.push(target);
90            }
91        }
92    }
93    resolved
94}
95
96/// Resolve hostname: config > DB setting (legacy "original_hostname") > live hostname.
97pub fn resolve_hostname(config: &recall::config::Config, conn: &Connection) -> BString {
98    if let Some(ref h) = config.host.hostname {
99        return BString::from(h.as_bytes());
100    }
101    get_setting(conn, "original_hostname").ok().flatten().unwrap_or_else(get_hostname)
102}
103
104/// Build the set of hostnames that count as "this host" for recall filtering.
105/// Returns {current_hostname} ∪ {aliases}, deduped.
106pub fn effective_host_set(config: &recall::config::Config) -> Vec<BString> {
107    let current = get_hostname();
108    let mut hosts = vec![current];
109    for alias in &config.host.aliases {
110        let b = BString::from(alias.as_bytes());
111        if !hosts.contains(&b) {
112            hosts.push(b);
113        }
114    }
115    hosts
116}
117
118/// Migrate host settings from DB legacy storage to config file.
119/// Called from install and config commands, not on every connection.
120///
121/// - If config lacks hostname, read from DB (legacy "original_hostname"), then delete from DB.
122/// - If config hostname doesn't match live hostname, move old to aliases and update.
123/// - If config lacks machine_id, generate one.
124pub fn migrate_host_settings(conn: &Connection) {
125    let config = recall::config::Config::load();
126    let mut updates: Vec<(&str, toml_edit::Item)> = Vec::new();
127    let live_hostname = get_hostname();
128
129    // Step 1: Establish config hostname (from DB legacy or live)
130    let config_hostname = if let Some(ref h) = config.host.hostname {
131        BString::from(h.as_bytes())
132    } else if let Ok(Some(hostname)) = get_setting(conn, "original_hostname") {
133        updates.push(("host.hostname", toml_edit::value(hostname.to_string())));
134        hostname
135    } else {
136        updates.push(("host.hostname", toml_edit::value(live_hostname.to_string())));
137        live_hostname.clone()
138    };
139
140    // Step 2: If hostname changed, move old to aliases and update
141    if config_hostname != live_hostname {
142        let mut aliases = config.host.aliases.clone();
143        let old_str = config_hostname.to_string();
144        if !aliases.contains(&old_str) {
145            aliases.push(old_str);
146        }
147        let alias_array = toml_edit::Array::from_iter(aliases.iter().map(|s| s.as_str()));
148        updates.push(("host.aliases", toml_edit::value(alias_array)));
149        updates.push(("host.hostname", toml_edit::value(live_hostname.to_string())));
150    }
151
152    // Step 3: Generate machine_id if missing
153    if config.host.machine_id.is_none() {
154        let id = rand::random::<u64>();
155        // Mask to 63 bits so the value stays positive in TOML (signed i64).
156        // A negative i64 can't deserialize into Option<u64>, which would cause
157        // the entire config file to fail to parse.
158        let id = id & i64::MAX as u64;
159        updates.push(("host.machine_id", toml_edit::value(id as i64)));
160    }
161
162    if !updates.is_empty()
163        && let Err(e) = recall::config::Config::update_default_config(&updates)
164    {
165        log::warn!("Failed to migrate host settings to config: {e}");
166        return;
167    }
168
169    // Clear legacy original_hostname from DB after successful config write
170    if config.host.hostname.is_none() {
171        let _ = conn.execute("DELETE FROM settings WHERE key = 'original_hostname'", []);
172    }
173}
174/// Return the pxh data directory. Prefers XDG, falls back to `~/.pxh` if it exists.
175/// New installs default to `$XDG_DATA_HOME/pxh` (`~/.local/share/pxh`).
176pub fn pxh_data_dir() -> Option<PathBuf> {
177    let home = home::home_dir()?;
178    let xdg_data =
179        env::var("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|_| home.join(".local/share"));
180    let xdg_dir = xdg_data.join("pxh");
181    if xdg_dir.exists() {
182        return Some(xdg_dir);
183    }
184    let legacy = home.join(".pxh");
185    if legacy.exists() {
186        return Some(legacy);
187    }
188    Some(xdg_dir)
189}
190
191/// Return the pxh config directory. Prefers XDG, falls back to `~/.pxh` if it exists.
192/// New installs default to `$XDG_CONFIG_HOME/pxh` (`~/.config/pxh`).
193pub fn pxh_config_dir() -> Option<PathBuf> {
194    let home = home::home_dir()?;
195    let xdg_config =
196        env::var("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|_| home.join(".config"));
197    let xdg_dir = xdg_config.join("pxh");
198    if xdg_dir.exists() {
199        return Some(xdg_dir);
200    }
201    let legacy = home.join(".pxh");
202    if legacy.exists() {
203        return Some(legacy);
204    }
205    Some(xdg_dir)
206}
207
208/// Return the default database path (`pxh_data_dir()/pxh.db`).
209pub fn default_db_path() -> Option<PathBuf> {
210    Some(pxh_data_dir()?.join("pxh.db"))
211}
212
213/// Initialize base schema and register custom functions on a connection.
214/// Safe to call on foreign databases (scan/scrub --dir) -- all DDL is idempotent
215/// and the memdb ATTACH is per-connection only.
216pub fn initialize_base_schema(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
217    conn.execute_batch(include_str!("base_schema.sql"))?;
218    conn.create_scalar_function("regexp", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| {
219        assert_eq!(ctx.len(), 2, "called with unexpected number of arguments");
220        let regexp: Arc<Regex> = ctx
221            .get_or_create_aux(0, |vr| -> Result<_, BoxError> { Ok(Regex::new(vr.as_str()?)?) })?;
222        let is_match = {
223            let text = ctx.get_raw(1).as_bytes().map_err(|e| Error::UserFunctionError(e.into()))?;
224            regexp.is_match(text)
225        };
226        Ok(is_match)
227    })?;
228    Ok(())
229}
230
231/// Run versioned schema migrations tracked via PRAGMA user_version.
232pub fn run_schema_migrations(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
233    let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
234
235    if version < 1 {
236        // Column may already exist on databases created before version tracking.
237        match conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []) {
238            Ok(_) => {}
239            Err(e) if e.to_string().contains("duplicate column name") => {}
240            Err(e) => return Err(e.into()),
241        }
242        conn.pragma_update(None, "user_version", 1)?;
243    }
244
245    Ok(())
246}
247
248pub fn sqlite_connection(path: &Option<PathBuf>) -> Result<Connection, Box<dyn std::error::Error>> {
249    let path = path.as_ref().ok_or("Database not defined; use --db or PXH_DB_PATH")?;
250    if let Some(parent) = path.parent() {
251        // Follow symlinks so create_dir_all creates the real target directory
252        // rather than conflicting with an existing symlink entry.
253        let resolved = resolve_through_symlinks(parent);
254        std::fs::create_dir_all(resolved)?;
255    }
256    let conn = Connection::open(path)?;
257
258    // Ensure the database file is only readable by the owner
259    use std::os::unix::fs::PermissionsExt;
260    if let Ok(metadata) = std::fs::metadata(path) {
261        let mode = metadata.permissions().mode();
262        if mode & 0o077 != 0 {
263            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
264        }
265    }
266
267    conn.busy_timeout(Duration::from_millis(5000))?;
268    conn.pragma_update(None, "journal_mode", "WAL")?;
269    conn.pragma_update(None, "temp_store", "MEMORY")?;
270    conn.pragma_update(None, "cache_size", "16777216")?;
271    conn.pragma_update(None, "synchronous", "NORMAL")?;
272
273    initialize_base_schema(&conn)?;
274    run_schema_migrations(&conn)?;
275
276    Ok(conn)
277}
278
279#[derive(Debug, Default, Serialize, Deserialize)]
280pub struct Invocation {
281    pub command: BString,
282    pub shellname: String,
283    pub working_directory: Option<BString>,
284    pub hostname: Option<BString>,
285    pub username: Option<BString>,
286    pub exit_status: Option<i64>,
287    pub start_unix_timestamp: Option<i64>,
288    pub end_unix_timestamp: Option<i64>,
289    pub session_id: i64,
290    #[serde(default)]
291    pub machine_id: Option<u64>,
292}
293
294impl Invocation {
295    fn sameish(&self, other: &Self) -> bool {
296        self.command == other.command && self.start_unix_timestamp == other.start_unix_timestamp
297    }
298
299    pub fn insert(&self, tx: &Transaction) -> Result<(), Box<dyn std::error::Error>> {
300        tx.execute(
301            r#"
302INSERT OR IGNORE INTO command_history (
303    session_id,
304    full_command,
305    shellname,
306    hostname,
307    username,
308    working_directory,
309    exit_status,
310    start_unix_timestamp,
311    end_unix_timestamp,
312    machine_id
313)
314VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
315            (
316                self.session_id,
317                self.command.as_slice(),
318                self.shellname.clone(),
319                self.hostname.as_ref().map(|v| v.to_vec()),
320                self.username.as_ref().map(|v| v.to_vec()),
321                self.working_directory.as_ref().map(|v| v.to_vec()),
322                self.exit_status,
323                self.start_unix_timestamp,
324                self.end_unix_timestamp,
325                self.machine_id.map(|id| id as i64),
326            ),
327        )?;
328
329        Ok(())
330    }
331}
332
333// Try to generate a "stable" session id based on the file imported.
334// If that fails, just create a random one.
335fn generate_import_session_id(histfile: &Path) -> i64 {
336    if let Ok(st) = std::fs::metadata(histfile) {
337        ((st.ino() << 16) | st.dev()) as i64
338    } else {
339        (rand::random::<u64>() >> 1) as i64
340    }
341}
342
343pub fn import_zsh_history(
344    histfile: &Path,
345    hostname: Option<BString>,
346    username: Option<BString>,
347) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
348    let mut f = File::open(histfile)?;
349    let mut buf = Vec::new();
350    let _ = f.read_to_end(&mut buf)?;
351    let username = username
352        .or_else(|| uzers::get_current_username().map(|v| BString::from(v.into_vec())))
353        .unwrap_or_else(|| BString::from("unknown"));
354    let hostname = hostname.unwrap_or_else(get_hostname);
355    let buf_iter = buf.split(|&ch| ch == b'\n');
356
357    let mut ret = vec![];
358    let mut skipped = 0usize;
359    let session_id = generate_import_session_id(histfile);
360    for (line_num, line) in buf_iter.enumerate() {
361        let Some((fields, command)) = line.splitn(2, |&ch| ch == b';').collect_tuple() else {
362            continue;
363        };
364        let Some((_skip, start_time, duration_seconds)) =
365            fields.splitn(3, |&ch| ch == b':').collect_tuple()
366        else {
367            continue;
368        };
369        let start_unix_timestamp =
370            match str::from_utf8(&start_time[1..]).ok().and_then(|s| s.parse::<i64>().ok()) {
371                Some(ts) => ts,
372                None => {
373                    eprintln!(
374                        "warning: {}: skipping line {}: bad timestamp {:?}",
375                        histfile.display(),
376                        line_num + 1,
377                        BString::from(start_time),
378                    );
379                    skipped += 1;
380                    continue;
381                }
382            };
383        let duration =
384            match str::from_utf8(duration_seconds).ok().and_then(|s| s.parse::<i64>().ok()) {
385                Some(d) => d,
386                None => {
387                    eprintln!(
388                        "warning: {}: skipping line {}: bad duration {:?}",
389                        histfile.display(),
390                        line_num + 1,
391                        BString::from(duration_seconds),
392                    );
393                    skipped += 1;
394                    continue;
395                }
396            };
397        let invocation = Invocation {
398            command: BString::from(command),
399            shellname: "zsh".into(),
400            hostname: Some(BString::from(hostname.as_bytes())),
401            username: Some(BString::from(username.as_bytes())),
402            start_unix_timestamp: Some(start_unix_timestamp),
403            end_unix_timestamp: Some(start_unix_timestamp + duration),
404            session_id,
405            ..Default::default()
406        };
407
408        ret.push(invocation);
409    }
410
411    if skipped > 0 {
412        eprintln!("warning: {}: skipped {skipped} malformed line(s)", histfile.display());
413    }
414
415    Ok(dedup_invocations(ret))
416}
417
418pub fn import_bash_history(
419    histfile: &Path,
420    hostname: Option<BString>,
421    username: Option<BString>,
422) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
423    let mut f = File::open(histfile)?;
424    let mut buf = Vec::new();
425    let _ = f.read_to_end(&mut buf)?;
426    let username = username
427        .or_else(|| uzers::get_current_username().map(|v| BString::from(v.as_bytes())))
428        .unwrap_or_else(|| BString::from("unknown"));
429    let hostname = hostname.unwrap_or_else(get_hostname);
430    let buf_iter = buf.split(|&ch| ch == b'\n').filter(|l| !l.is_empty());
431
432    let mut ret = vec![];
433    let session_id = generate_import_session_id(histfile);
434    let mut last_ts = None;
435    for line in buf_iter {
436        if line[0] == b'#'
437            && let Ok(ts) = str::parse::<i64>(str::from_utf8(&line[1..]).unwrap_or("0"))
438        {
439            if ts > 0 {
440                last_ts = Some(ts);
441            }
442            continue;
443        }
444        let invocation = Invocation {
445            command: BString::from(line),
446            shellname: "bash".into(),
447            hostname: Some(BString::from(hostname.as_bytes())),
448            username: Some(BString::from(username.as_bytes())),
449            start_unix_timestamp: last_ts,
450            session_id,
451            ..Default::default()
452        };
453
454        ret.push(invocation);
455    }
456
457    Ok(dedup_invocations(ret))
458}
459
460pub fn import_json_history(histfile: &Path) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
461    let f = File::open(histfile)?;
462    let reader = BufReader::new(f);
463    Ok(serde_json::from_reader(reader)?)
464}
465
466fn dedup_invocations(invocations: Vec<Invocation>) -> Vec<Invocation> {
467    let mut it = invocations.into_iter();
468    let Some(first) = it.next() else { return vec![] };
469    let mut ret = vec![first];
470    for elem in it {
471        if !elem.sameish(ret.last().unwrap()) {
472            ret.push(elem);
473        }
474    }
475    ret
476}
477
478impl Invocation {
479    pub fn from_row(row: &Row) -> Result<Self, Error> {
480        Ok(Invocation {
481            session_id: row.get("session_id")?,
482            command: BString::from(row.get::<_, Vec<u8>>("full_command")?),
483            shellname: row.get("shellname")?,
484            working_directory: row
485                .get::<_, Option<Vec<u8>>>("working_directory")?
486                .map(BString::from),
487            hostname: row.get::<_, Option<Vec<u8>>>("hostname")?.map(BString::from),
488            username: row.get::<_, Option<Vec<u8>>>("username")?.map(BString::from),
489            exit_status: row.get("exit_status")?,
490            start_unix_timestamp: row.get("start_unix_timestamp")?,
491            end_unix_timestamp: row.get("end_unix_timestamp")?,
492            machine_id: row.get::<_, Option<i64>>("machine_id").ok().flatten().map(|v| v as u64),
493        })
494    }
495}
496
497// Create a pretty export string that gets serialized as an array of
498// bytes only if it isn't valid UTF-8; this makes the json export
499// prettier.
500#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)]
501#[serde(untagged)]
502enum PrettyExportString {
503    Readable(String),
504    Encoded(Vec<u8>),
505}
506
507impl From<&[u8]> for PrettyExportString {
508    fn from(bytes: &[u8]) -> Self {
509        match str::from_utf8(bytes) {
510            Ok(v) => Self::Readable(v.to_string()),
511            _ => Self::Encoded(bytes.to_vec()),
512        }
513    }
514}
515
516impl From<Option<&Vec<u8>>> for PrettyExportString {
517    fn from(bytes: Option<&Vec<u8>>) -> Self {
518        match bytes {
519            Some(v) => match str::from_utf8(v.as_slice()) {
520                Ok(s) => Self::Readable(s.to_string()),
521                _ => Self::Encoded(v.to_vec()),
522            },
523            None => Self::Readable(String::new()),
524        }
525    }
526}
527
528impl Invocation {
529    fn to_json_export(&self) -> serde_json::Value {
530        serde_json::json!({
531            "session_id": self.session_id,
532            "command": PrettyExportString::from(self.command.as_slice()),
533            "shellname": self.shellname,
534            "working_directory": self.working_directory.as_ref().map_or(
535                PrettyExportString::Readable(String::new()),
536                |b| PrettyExportString::from(b.as_slice())
537            ),
538            "hostname": self.hostname.as_ref().map_or(
539                PrettyExportString::Readable(String::new()),
540                |b| PrettyExportString::from(b.as_slice())
541            ),
542            "username": self.username.as_ref().map_or(
543                PrettyExportString::Readable(String::new()),
544                |b| PrettyExportString::from(b.as_slice())
545            ),
546            "exit_status": self.exit_status,
547            "start_unix_timestamp": self.start_unix_timestamp,
548            "end_unix_timestamp": self.end_unix_timestamp,
549        })
550    }
551}
552
553pub fn json_export(rows: &[Invocation]) -> Result<(), Box<dyn std::error::Error>> {
554    let json_values: Vec<serde_json::Value> = rows.iter().map(|r| r.to_json_export()).collect();
555    serde_json::to_writer(io::stdout(), &json_values)?;
556    Ok(())
557}
558
559// column list: command, start, host, shell, cwd, end, duratio, session, ...
560
561struct QueryResultColumnDisplayer {
562    header: &'static str,
563    header_style: &'static str,
564    displayer: Box<dyn Fn(&Invocation) -> prettytable::Cell>,
565}
566
567fn time_display_helper(t: Option<i64>) -> String {
568    // Chained if-let may make this unpacking of
569    // Option/Result/LocalResult cleaner.  Alternative is a closer
570    // using `?` chains but that's slightly uglier.
571    t.and_then(|t| Local.timestamp_opt(t, 0).single())
572        .map(|t| t.format(TIME_FORMAT).to_string())
573        .unwrap_or_else(|| "n/a".to_string())
574}
575
576fn binary_display_helper(v: &BString) -> String {
577    String::from_utf8_lossy(v.as_slice()).to_string()
578}
579
580fn displayers() -> HashMap<&'static str, QueryResultColumnDisplayer> {
581    let mut ret = HashMap::new();
582    ret.insert(
583        "command",
584        QueryResultColumnDisplayer {
585            header: "Command",
586            header_style: "Fw",
587            displayer: Box::new(|row| {
588                prettytable::Cell::new(&binary_display_helper(&row.command)).style_spec("Fw")
589            }),
590        },
591    );
592    ret.insert(
593        "start_time",
594        QueryResultColumnDisplayer {
595            header: "Start",
596            header_style: "Fg",
597            displayer: Box::new(|row| {
598                prettytable::Cell::new(&time_display_helper(row.start_unix_timestamp))
599                    .style_spec("Fg")
600            }),
601        },
602    );
603    ret.insert(
604        "end_time",
605        QueryResultColumnDisplayer {
606            header: "End",
607            header_style: "Fg",
608            displayer: Box::new(|row| {
609                prettytable::Cell::new(&time_display_helper(row.end_unix_timestamp))
610                    .style_spec("Fg")
611            }),
612        },
613    );
614    ret.insert(
615        "duration",
616        QueryResultColumnDisplayer {
617            header: "Duration",
618            header_style: "Fm",
619            displayer: Box::new(|row| {
620                let text = match (row.start_unix_timestamp, row.end_unix_timestamp) {
621                    (Some(start), Some(end)) => format!("{}s", end - start),
622                    _ => "n/a".into(),
623                };
624                prettytable::Cell::new(&text).style_spec("Fm")
625            }),
626        },
627    );
628    ret.insert(
629        "status",
630        QueryResultColumnDisplayer {
631            header: "Status",
632            header_style: "Fr",
633            displayer: Box::new(|row| match row.exit_status {
634                Some(0) => prettytable::Cell::new("0").style_spec("Fg"),
635                Some(s) => prettytable::Cell::new(&s.to_string()).style_spec("Fr"),
636                None => prettytable::Cell::new("n/a").style_spec("Fd"),
637            }),
638        },
639    );
640    // TODO: Make session similar to "context" and just print `.` when
641    // it is the current session.
642    ret.insert(
643        "session",
644        QueryResultColumnDisplayer {
645            header: "Session",
646            header_style: "Fc",
647            displayer: Box::new(|row| {
648                prettytable::Cell::new(&format!("{:x}", row.session_id)).style_spec("Fc")
649            }),
650        },
651    );
652    // Print context specially; the full output is $HOST:$PATH but if
653    // $HOST is the current host, the $HOST: is omitted.  If $PATH is
654    // the current working directory, it is replaced with `.`.
655    ret.insert(
656        "context",
657        QueryResultColumnDisplayer {
658            header: "Context",
659            header_style: "bFb",
660            displayer: Box::new(|row| {
661                let current_hostname = get_hostname();
662                let row_hostname = row.hostname.clone().unwrap_or_default();
663                let mut ret = String::new();
664                if current_hostname != row_hostname {
665                    write!(ret, "{row_hostname}:").unwrap_or_default();
666                }
667                let current_directory = env::current_dir().unwrap_or_default();
668                ret.push_str(&row.working_directory.as_ref().map_or_else(String::new, |v| {
669                    let v = String::from_utf8_lossy(v.as_slice()).to_string();
670                    if v == current_directory.to_string_lossy() { String::from(".") } else { v }
671                }));
672
673                prettytable::Cell::new(&ret).style_spec("bFb")
674            }),
675        },
676    );
677
678    ret
679}
680
681pub fn present_results_human_readable(
682    fields: &[&str],
683    rows: &[Invocation],
684    suppress_headers: bool,
685) -> Result<(), Box<dyn std::error::Error>> {
686    let displayers = displayers();
687    let mut table = prettytable::Table::new();
688    table.set_format(*prettytable::format::consts::FORMAT_CLEAN);
689
690    if !suppress_headers {
691        let mut title_row = prettytable::Row::empty();
692        for field in fields {
693            let Some(d) = displayers.get(field) else {
694                return Err(Box::from(format!("Invalid 'show' field: {field}")));
695            };
696
697            title_row.add_cell(prettytable::Cell::new(d.header).style_spec(d.header_style));
698        }
699        table.set_titles(title_row);
700    }
701
702    for row in rows.iter() {
703        let is_failed = matches!(row.exit_status, Some(s) if s != 0);
704        let mut display_row = prettytable::Row::empty();
705        for field in fields {
706            let cell = (displayers[field].displayer)(row);
707            if is_failed {
708                display_row.add_cell(prettytable::Cell::new(&cell.get_content()).style_spec("Fr"));
709            } else {
710                display_row.add_cell(cell);
711            }
712        }
713        table.add_row(display_row);
714    }
715    table.printstd();
716    Ok(())
717}
718
719// Rewrite a file with lines matching `contraband` removed.  utf-8
720// safe for the file (TODO: I guess make contraband a `BString` too)
721pub fn atomically_remove_lines_from_file(
722    input_filepath: &PathBuf,
723    contraband: &str,
724) -> Result<(), Box<dyn std::error::Error>> {
725    let original_perms = std::fs::metadata(input_filepath)?.permissions();
726    let input_file = File::open(input_filepath)?;
727    let mut input_reader = BufReader::new(input_file);
728
729    let parent = input_filepath.parent().unwrap_or(Path::new("."));
730    let temp_file = tempfile::NamedTempFile::new_in(parent)?;
731    let mut output_writer = BufWriter::new(&temp_file);
732
733    input_reader.for_byte_line_with_terminator(|line| {
734        if !line.contains_str(contraband) {
735            output_writer.write_all(line)?;
736        }
737        Ok(true)
738    })?;
739
740    output_writer.flush()?;
741    drop(output_writer);
742    temp_file.persist(input_filepath)?;
743    std::fs::set_permissions(input_filepath, original_perms)?;
744    Ok(())
745}
746
747/// Rewrite a file removing lines that exactly match any of the contraband items.
748/// Unlike `atomically_remove_lines_from_file`, this matches complete lines (after trimming).
749pub fn atomically_remove_matching_lines_from_file(
750    input_filepath: &Path,
751    contraband_items: &[&str],
752) -> Result<(), Box<dyn std::error::Error>> {
753    use std::collections::HashSet;
754
755    let original_perms = std::fs::metadata(input_filepath)?.permissions();
756    let contraband_set: HashSet<&str> = contraband_items.iter().copied().collect();
757
758    let input_file = File::open(input_filepath)?;
759    let mut input_reader = BufReader::new(input_file);
760
761    let parent = input_filepath.parent().unwrap_or(Path::new("."));
762    let temp_file = tempfile::NamedTempFile::new_in(parent)?;
763    let mut output_writer = BufWriter::new(&temp_file);
764
765    input_reader.for_byte_line_with_terminator(|line| {
766        let line_str = line.to_str_lossy();
767        let trimmed = line_str.trim();
768        if !contraband_set.contains(trimmed) {
769            output_writer.write_all(line)?;
770        }
771        Ok(true)
772    })?;
773
774    output_writer.flush()?;
775    drop(output_writer);
776    temp_file.persist(input_filepath)?;
777    std::fs::set_permissions(input_filepath, original_perms)?;
778    Ok(())
779}
780
781// Helper functions for command parsing and path resolution
782pub mod helpers {
783    use std::path::{Path, PathBuf};
784
785    /// Parse an SSH command string into command and arguments, handling quotes and spaces.
786    /// Similar to how rsync and other tools parse the -e option.
787    pub fn parse_ssh_command(ssh_cmd: &str) -> (String, Vec<String>) {
788        // If it's a simple command without spaces, just return it
789        if !ssh_cmd.contains(char::is_whitespace) {
790            return (ssh_cmd.to_string(), vec![]);
791        }
792
793        // Otherwise, we need to parse it properly
794        let mut cmd = String::new();
795        let mut args = Vec::new();
796        let mut current = String::new();
797        let mut in_quotes = false;
798        let mut quote_char = '\0';
799        let mut is_first = true;
800        let mut chars = ssh_cmd.chars().peekable();
801
802        while let Some(ch) = chars.next() {
803            match ch {
804                '"' | '\'' if !in_quotes => {
805                    in_quotes = true;
806                    quote_char = ch;
807                }
808                '"' | '\'' if in_quotes && ch == quote_char => {
809                    in_quotes = false;
810                    quote_char = '\0';
811                }
812                ' ' | '\t' if !in_quotes => {
813                    if !current.is_empty() {
814                        if is_first {
815                            cmd = current.clone();
816                            is_first = false;
817                        } else {
818                            args.push(current.clone());
819                        }
820                        current.clear();
821                    }
822                }
823                '\\' if chars.peek().is_some() => {
824                    // Handle escaped characters
825                    if let Some(next_ch) = chars.next() {
826                        current.push(next_ch);
827                    }
828                }
829                _ => {
830                    current.push(ch);
831                }
832            }
833        }
834
835        // Don't forget the last token
836        if !current.is_empty() {
837            if is_first {
838                cmd = current;
839            } else {
840                args.push(current);
841            }
842        }
843
844        (cmd, args)
845    }
846
847    /// Build a list of candidate paths to search for pxh on the remote host.
848    /// The first candidate that exists and is executable will be used.
849    fn remote_pxh_candidates(configured_path: &str) -> Vec<String> {
850        let mut candidates = Vec::new();
851
852        if configured_path != "pxh" {
853            candidates.push(configured_path.to_string());
854            return candidates;
855        }
856
857        // Try the same relative-to-home path as the local binary
858        if let Some(rel) = get_relative_path_from_home(None, None)
859            && rel != "pxh"
860        {
861            candidates.push(format!("$HOME/{rel}"));
862        }
863
864        // Common installation locations
865        for p in [
866            "$HOME/.cargo/bin/pxh",
867            "$HOME/bin/pxh",
868            "$HOME/.local/bin/pxh",
869            "/usr/local/bin/pxh",
870            "/usr/bin/pxh",
871        ] {
872            if !candidates.contains(&p.to_string()) {
873                candidates.push(p.to_string());
874            }
875        }
876
877        candidates
878    }
879
880    /// Build a shell command that finds and executes pxh on the remote host.
881    /// When a single explicit path is configured, uses it directly.
882    /// Otherwise, probes a prioritized list of candidate locations.
883    pub fn build_remote_pxh_command(configured_path: &str, args: &str) -> String {
884        let candidates = remote_pxh_candidates(configured_path);
885
886        if candidates.len() == 1 {
887            return format!("{} {args}", candidates[0]);
888        }
889
890        // Build a shell snippet that tries each candidate in order
891        let checks: Vec<String> =
892            candidates.iter().map(|p| format!("[ -x \"{p}\" ] && exec \"{p}\" {args}")).collect();
893        format!(
894            "sh -c '{}; echo \"pxh: not found on remote host\" >&2; exit 127'",
895            checks.join("; ")
896        )
897    }
898
899    /// Gets the relative path from home directory if the current executable is within it.
900    /// Returns None if the executable is not in the home directory.
901    /// Takes optional overrides for testing.
902    pub fn get_relative_path_from_home(
903        exe_override: Option<&Path>,
904        home_override: Option<&Path>,
905    ) -> Option<String> {
906        let exe = match exe_override {
907            Some(path) => path.to_path_buf(),
908            None => std::env::current_exe().ok()?,
909        };
910
911        let home = match home_override {
912            Some(path) => path.to_path_buf(),
913            None => home::home_dir()?,
914        };
915
916        exe.strip_prefix(&home).ok().map(|path| path.to_string_lossy().to_string())
917    }
918
919    /// Return a shell expression that resolves the pxh database path on a remote host.
920    /// Checks XDG path first, falls back to legacy ~/.pxh.
921    pub fn default_remote_db_expr() -> String {
922        r#"$(if [ -d "${XDG_DATA_HOME:-$HOME/.local/share}/pxh" ]; then echo "${XDG_DATA_HOME:-$HOME/.local/share}/pxh/pxh.db"; elif [ -d "$HOME/.pxh" ]; then echo "$HOME/.pxh/pxh.db"; else echo "${XDG_DATA_HOME:-$HOME/.local/share}/pxh/pxh.db"; fi)"#.to_string()
923    }
924
925    /// Determine if the executable is being invoked as pxhs (shorthand for pxh show)
926    pub fn determine_is_pxhs(args: &[String]) -> bool {
927        args.first()
928            .and_then(|arg| {
929                PathBuf::from(arg).file_name().map(|name| name.to_string_lossy().contains("pxhs"))
930            })
931            .unwrap_or(false)
932    }
933}
934
935/// Test utilities - only intended for use in tests
936#[doc(hidden)]
937pub mod test_utils {
938    use std::{
939        env,
940        path::{Path, PathBuf},
941        process::Command,
942    };
943
944    use rand::{RngExt, distr::Alphanumeric};
945    use tempfile::TempDir;
946
947    pub fn pxh_path() -> PathBuf {
948        let mut path = std::env::current_exe().unwrap();
949        path.pop(); // Remove test binary name
950        path.pop(); // Remove 'deps'
951        path.push("pxh");
952        assert!(path.exists(), "pxh binary not found at {:?}", path);
953        path
954    }
955
956    fn generate_random_string(length: usize) -> String {
957        rand::rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect()
958    }
959
960    fn get_standard_path() -> String {
961        // Use getconf to get the standard PATH
962        Command::new("getconf")
963            .arg("PATH")
964            .output()
965            .ok()
966            .and_then(|output| {
967                if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }
968            })
969            .map(|s| s.trim().to_string())
970            .unwrap_or_else(|| {
971                // Fallback to a reasonable default if getconf fails
972                "/usr/bin:/bin:/usr/sbin:/sbin".to_string()
973            })
974    }
975
976    /// Unified test helper for invoking pxh with proper isolation and environment setup
977    pub struct PxhTestHelper {
978        _tmpdir: TempDir,
979        pub hostname: String,
980        pub username: String,
981        home_dir: PathBuf,
982        db_path: PathBuf,
983    }
984
985    impl PxhTestHelper {
986        pub fn new() -> Self {
987            let tmpdir = TempDir::new().unwrap();
988            let home_dir = tmpdir.path().to_path_buf();
989            let db_path = home_dir.join(".pxh/pxh.db");
990
991            // Create .pxh dir and a config with empty ignore_patterns so tests
992            // aren't affected by default ignore patterns filtering trivial commands.
993            let pxh_dir = home_dir.join(".pxh");
994            std::fs::create_dir_all(&pxh_dir).unwrap();
995            std::fs::write(pxh_dir.join("config.toml"), "[history]\nignore_patterns = []\n")
996                .unwrap();
997
998            PxhTestHelper {
999                _tmpdir: tmpdir,
1000                hostname: generate_random_string(12),
1001                username: "testuser".to_string(),
1002                home_dir,
1003                db_path,
1004            }
1005        }
1006
1007        pub fn with_custom_db_path(mut self, db_path: impl AsRef<Path>) -> Self {
1008            self.db_path = self.home_dir.join(db_path);
1009            self
1010        }
1011
1012        /// Get the temporary directory path
1013        pub fn home_dir(&self) -> &Path {
1014            &self.home_dir
1015        }
1016
1017        /// Get the database path
1018        pub fn db_path(&self) -> &Path {
1019            &self.db_path
1020        }
1021
1022        /// Get the full PATH including pxh binary directory
1023        pub fn get_full_path(&self) -> String {
1024            format!("{}:{}", pxh_path().parent().unwrap().display(), get_standard_path())
1025        }
1026
1027        /// Create a pxh command with all environment properly set up
1028        pub fn command(&self) -> Command {
1029            let mut cmd = Command::new(pxh_path());
1030
1031            // Clear environment to ensure isolation
1032            cmd.env_clear();
1033
1034            // Set consistent test environment
1035            cmd.env("HOME", &self.home_dir);
1036            cmd.env("PXH_DB_PATH", &self.db_path);
1037            cmd.env("PXH_HOSTNAME", &self.hostname);
1038            cmd.env("USER", &self.username);
1039            cmd.env("PATH", self.get_full_path());
1040
1041            // Propagate coverage environment variables if they exist
1042            if let Ok(profile_file) = env::var("LLVM_PROFILE_FILE") {
1043                cmd.env("LLVM_PROFILE_FILE", profile_file);
1044            }
1045            if let Ok(llvm_cov) = env::var("CARGO_LLVM_COV") {
1046                cmd.env("CARGO_LLVM_COV", llvm_cov);
1047            }
1048
1049            cmd
1050        }
1051
1052        /// Convenience method to create a command with arguments
1053        pub fn command_with_args(&self, args: &[&str]) -> Command {
1054            let mut cmd = self.command();
1055            cmd.args(args);
1056            cmd
1057        }
1058
1059        /// Create a shell command (bash/zsh) with proper environment for interactive testing
1060        pub fn shell_command(&self, shell: &str) -> Command {
1061            let mut cmd = Command::new(shell);
1062
1063            // Force interactive mode - use login shell to ensure rc files are loaded
1064            cmd.arg("-i");
1065            cmd.env_clear();
1066
1067            // Set consistent environment variables
1068            cmd.env("HOME", &self.home_dir);
1069            cmd.env("PXH_DB_PATH", &self.db_path);
1070            cmd.env("PXH_HOSTNAME", &self.hostname);
1071            cmd.env("PATH", self.get_full_path());
1072
1073            // Set a clean environment for testing
1074            cmd.env("USER", &self.username);
1075            cmd.env("SHELL", shell);
1076
1077            // For bash to properly load rc files in a minimal environment
1078            cmd.env("BASH_ENV", self.home_dir.join(".bashrc"));
1079
1080            cmd
1081        }
1082    }
1083
1084    impl Default for PxhTestHelper {
1085        fn default() -> Self {
1086            Self::new()
1087        }
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094
1095    /// Create an in-memory database with full schema and migrations applied.
1096    fn test_connection() -> Connection {
1097        let conn = Connection::open_in_memory().unwrap();
1098        initialize_base_schema(&conn).unwrap();
1099        run_schema_migrations(&conn).unwrap();
1100        conn
1101    }
1102
1103    #[test]
1104    fn test_resolve_hostname_from_config() {
1105        let conn = test_connection();
1106
1107        let mut config = recall::config::Config::default();
1108        config.host.hostname = Some("from-config".to_string());
1109        set_setting(&conn, "original_hostname", &BString::from("from-db")).unwrap();
1110
1111        let result = resolve_hostname(&config, &conn);
1112        assert_eq!(result, BString::from("from-config"));
1113    }
1114
1115    #[test]
1116    fn test_resolve_hostname_from_db() {
1117        let conn = test_connection();
1118
1119        let config = recall::config::Config::default();
1120        set_setting(&conn, "original_hostname", &BString::from("from-db")).unwrap();
1121
1122        let result = resolve_hostname(&config, &conn);
1123        assert_eq!(result, BString::from("from-db"));
1124    }
1125
1126    #[test]
1127    fn test_resolve_hostname_live_fallback() {
1128        let conn = test_connection();
1129
1130        let config = recall::config::Config::default();
1131        let result = resolve_hostname(&config, &conn);
1132        assert_eq!(result, get_hostname());
1133    }
1134
1135    #[test]
1136    fn test_effective_host_set_no_aliases() {
1137        let config = recall::config::Config::default();
1138        let hosts = effective_host_set(&config);
1139        assert_eq!(hosts, vec![get_hostname()]);
1140    }
1141
1142    #[test]
1143    fn test_effective_host_set_with_aliases() {
1144        let mut config = recall::config::Config::default();
1145        config.host.aliases = vec!["old-host".to_string(), "other-host".to_string()];
1146        let hosts = effective_host_set(&config);
1147        assert_eq!(hosts.len(), 3);
1148        assert_eq!(hosts[0], get_hostname());
1149        assert_eq!(hosts[1], BString::from("old-host"));
1150        assert_eq!(hosts[2], BString::from("other-host"));
1151    }
1152
1153    #[test]
1154    fn test_effective_host_set_dedup() {
1155        let mut config = recall::config::Config::default();
1156        let current = get_hostname().to_string();
1157        config.host.aliases = vec![current, "other".to_string()];
1158        let hosts = effective_host_set(&config);
1159        assert_eq!(hosts.len(), 2);
1160    }
1161
1162    #[test]
1163    fn test_migration_fresh_database() {
1164        let conn = Connection::open_in_memory().unwrap();
1165        conn.execute_batch(include_str!("base_schema.sql")).unwrap();
1166
1167        let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1168        assert_eq!(version, 0);
1169
1170        run_schema_migrations(&conn).unwrap();
1171
1172        let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1173        assert_eq!(version, 1);
1174
1175        // machine_id column should exist
1176        conn.execute(
1177            "INSERT INTO command_history (session_id, full_command, shellname, machine_id) VALUES (1, X'6C73', 'bash', 42)",
1178            [],
1179        )
1180        .unwrap();
1181    }
1182
1183    #[test]
1184    fn test_migration_idempotent() {
1185        let conn = test_connection();
1186        // Run migrations again -- should be a no-op
1187        run_schema_migrations(&conn).unwrap();
1188
1189        let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1190        assert_eq!(version, 1);
1191    }
1192
1193    #[test]
1194    fn test_migration_legacy_database_with_machine_id() {
1195        // Simulate a database that was used with post-machine_id pxh but has no version tracking
1196        let conn = Connection::open_in_memory().unwrap();
1197        conn.execute_batch(include_str!("base_schema.sql")).unwrap();
1198        conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []).unwrap();
1199
1200        let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1201        assert_eq!(version, 0);
1202
1203        // Should handle the duplicate column gracefully and bump version
1204        run_schema_migrations(&conn).unwrap();
1205
1206        let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1207        assert_eq!(version, 1);
1208    }
1209}