pxh 0.9.9

pxh is a fast, cross-shell history mining tool with interactive fuzzy search, secret scanning, and bidirectional sync across machines. It indexes bash and zsh history in SQLite with rich metadata for powerful recall.
Documentation
use std::fs;
use std::path::PathBuf;

use serde::Deserialize;
use toml_edit::DocumentMut;

/// Main configuration struct
#[derive(Debug, Deserialize, Default)]
pub struct Config {
    #[serde(default)]
    pub host: HostConfig,
    #[serde(default)]
    pub recall: RecallConfig,
    #[serde(default)]
    pub shell: ShellConfig,
}

/// Configuration for host identity
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct HostConfig {
    pub hostname: Option<String>,
    pub machine_id: Option<u64>,
    pub aliases: Vec<String>,
}

/// Configuration for shell integration
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct ShellConfig {
    /// Disable Ctrl-R binding (keep shell's default behavior)
    pub disable_ctrl_r: bool,
}

/// Configuration for the recall TUI
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct RecallConfig {
    /// Keymap mode: "emacs" or "vim"
    pub keymap: String,
    /// Whether to show the preview pane
    pub show_preview: bool,
    /// Maximum number of results to load
    pub result_limit: usize,
    /// Preview pane configuration
    pub preview: PreviewConfig,
}

impl Default for RecallConfig {
    fn default() -> Self {
        RecallConfig {
            keymap: "emacs".to_string(),
            show_preview: true,
            result_limit: 5000,
            preview: PreviewConfig::default(),
        }
    }
}

/// Configuration for what to show in the preview pane
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PreviewConfig {
    pub show_directory: bool,
    pub show_timestamp: bool,
    pub show_exit_status: bool,
    pub show_hostname: bool,
    pub show_duration: bool,
}

impl Default for PreviewConfig {
    fn default() -> Self {
        PreviewConfig {
            show_directory: true,
            show_timestamp: true,
            show_exit_status: true,
            show_hostname: false,
            show_duration: true,
        }
    }
}

/// Keymap mode for navigation
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KeymapMode {
    #[default]
    Emacs,
    VimInsert,
    VimNormal,
}

impl RecallConfig {
    /// Get the initial keymap mode from config
    pub fn initial_keymap_mode(&self) -> KeymapMode {
        match self.keymap.to_lowercase().as_str() {
            "vim" => KeymapMode::VimInsert,
            _ => KeymapMode::Emacs,
        }
    }
}

impl Config {
    /// Load configuration from the default path (~/.pxh/config.toml)
    pub fn load() -> Self {
        Self::load_from_default_path().unwrap_or_default()
    }

    fn load_from_default_path() -> Option<Self> {
        let config_path = Self::default_config_path()?;
        Self::load_from_path(&config_path)
    }

    fn default_config_path() -> Option<PathBuf> {
        let home = home::home_dir()?;
        Some(home.join(".pxh").join("config.toml"))
    }

    pub fn load_from_path(path: &PathBuf) -> Option<Self> {
        let content = fs::read_to_string(path).ok()?;
        toml::from_str(&content).ok()
    }

    /// Update the config file at the default path, preserving existing content.
    /// Each update is a (dotted_key, value) pair, e.g. ("host.hostname", value).
    pub fn update_default_config(
        updates: &[(&str, toml_edit::Item)],
    ) -> Result<(), Box<dyn std::error::Error>> {
        let path = Self::default_config_path().ok_or("Could not determine config path")?;
        Self::update_config_at_path(&path, updates)
    }

    pub fn update_config_at_path(
        path: &PathBuf,
        updates: &[(&str, toml_edit::Item)],
    ) -> Result<(), Box<dyn std::error::Error>> {
        let content = fs::read_to_string(path).unwrap_or_default();
        let mut doc: DocumentMut = content.parse()?;

        for (dotted_key, item) in updates {
            let parts: Vec<&str> = dotted_key.split('.').collect();
            match parts.as_slice() {
                [section, key] => {
                    if !doc.contains_table(section) {
                        doc[section] = toml_edit::Item::Table(toml_edit::Table::new());
                    }
                    doc[section][key] = item.clone();
                }
                [key] => {
                    doc[key] = item.clone();
                }
                _ => return Err(format!("Unsupported key depth: {dotted_key}").into()),
            }
        }

        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, doc.to_string())?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = Config::default();
        assert_eq!(config.recall.keymap, "emacs");
        assert!(config.recall.show_preview);
        assert_eq!(config.recall.result_limit, 5000);
        assert!(config.recall.preview.show_directory);
        assert!(config.recall.preview.show_timestamp);
        assert!(config.recall.preview.show_exit_status);
        assert!(!config.recall.preview.show_hostname);
        assert!(config.recall.preview.show_duration);
    }

    #[test]
    fn test_parse_config() {
        let toml = r#"
[recall]
keymap = "vim"
show_preview = false
result_limit = 1000

[recall.preview]
show_directory = false
show_hostname = true
"#;
        let config: Config = toml::from_str(toml).unwrap();
        assert_eq!(config.recall.keymap, "vim");
        assert!(!config.recall.show_preview);
        assert_eq!(config.recall.result_limit, 1000);
        assert!(!config.recall.preview.show_directory);
        assert!(config.recall.preview.show_hostname);
        // Defaults should be preserved for unspecified fields
        assert!(config.recall.preview.show_timestamp);
        assert!(config.recall.preview.show_exit_status);
    }

    #[test]
    fn test_default_host_config() {
        let config = Config::default();
        assert!(config.host.hostname.is_none());
        assert!(config.host.machine_id.is_none());
        assert!(config.host.aliases.is_empty());
    }

    #[test]
    fn test_parse_host_config() {
        let toml = r#"
[host]
hostname = "my-old-mac"
machine_id = 12345678901234567
aliases = ["old-mac", "work-laptop"]
"#;
        let config: Config = toml::from_str(toml).unwrap();
        assert_eq!(config.host.hostname.as_deref(), Some("my-old-mac"));
        assert_eq!(config.host.machine_id, Some(12345678901234567));
        assert_eq!(config.host.aliases, vec!["old-mac", "work-laptop"]);
    }

    #[test]
    fn test_parse_partial_host_config() {
        let toml = r#"
[host]
aliases = ["other-host"]
"#;
        let config: Config = toml::from_str(toml).unwrap();
        assert!(config.host.hostname.is_none());
        assert!(config.host.machine_id.is_none());
        assert_eq!(config.host.aliases, vec!["other-host"]);
    }

    #[test]
    fn test_update_config_preserves_existing() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("config.toml");
        std::fs::write(
            &path,
            r#"# my comment
[recall]
keymap = "vim"
"#,
        )
        .unwrap();

        Config::update_config_at_path(
            &path,
            &[
                ("host.hostname", toml_edit::value("my-host")),
                ("host.machine_id", toml_edit::value(42_i64)),
            ],
        )
        .unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("# my comment"), "comment preserved");
        assert!(content.contains("keymap = \"vim\""), "existing config preserved");
        assert!(content.contains("hostname = \"my-host\""));
        assert!(content.contains("machine_id = 42"));

        let config = Config::load_from_path(&path).unwrap();
        assert_eq!(config.host.hostname.as_deref(), Some("my-host"));
        assert_eq!(config.recall.keymap, "vim");
    }

    #[test]
    fn test_update_config_creates_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("subdir").join("config.toml");

        Config::update_config_at_path(
            &path,
            &[("host.aliases", toml_edit::value(toml_edit::Array::from_iter(["a", "b"])))],
        )
        .unwrap();

        let config = Config::load_from_path(&path).unwrap();
        assert_eq!(config.host.aliases, vec!["a", "b"]);
    }

    #[test]
    fn test_existing_config_without_host_section() {
        let toml = r#"
[recall]
keymap = "vim"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        assert!(config.host.hostname.is_none());
        assert!(config.host.aliases.is_empty());
        assert_eq!(config.recall.keymap, "vim");
    }

    #[test]
    fn test_initial_keymap_mode() {
        let mut config = RecallConfig::default();
        assert_eq!(config.initial_keymap_mode(), KeymapMode::Emacs);

        config.keymap = "vim".to_string();
        assert_eq!(config.initial_keymap_mode(), KeymapMode::VimInsert);

        config.keymap = "VIM".to_string();
        assert_eq!(config.initial_keymap_mode(), KeymapMode::VimInsert);

        config.keymap = "unknown".to_string();
        assert_eq!(config.initial_keymap_mode(), KeymapMode::Emacs);
    }
}