use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
pub const DEFAULT_CONFIG_FILE: &str = ".zam.json";
pub const DEFAULT_MAX_ENTRIES: usize = 100_000;
pub const DEFAULT_REDACTION_PLACEHOLDER: &str = "<redacted>";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub history_file: PathBuf,
pub max_entries: usize,
pub enable_redaction: bool,
pub redaction: RedactionConfig,
pub import: ImportConfig,
pub search: SearchConfig,
pub logging: LoggingConfig,
pub shell_integration: ShellIntegrationConfig,
pub custom_env_vars: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedactionConfig {
pub placeholder: String,
pub use_builtin_patterns: bool,
pub custom_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
pub redact_env_vars: bool,
pub min_redaction_length: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportConfig {
pub shell_history_paths: HashMap<String, PathBuf>,
pub auto_detect: bool,
pub deduplicate: bool,
pub preserve_timestamps: bool,
pub max_age_days: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
pub fuzzy_search: bool,
pub case_sensitive: bool,
pub include_directory: bool,
pub include_timestamps: bool,
pub max_results: usize,
pub highlight_matches: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub level: String,
pub log_to_file: bool,
pub log_file: Option<PathBuf>,
pub include_timestamps: bool,
pub log_redacted_commands: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellIntegrationConfig {
pub auto_log: bool,
pub exclude_commands: Vec<String>,
pub log_space_prefixed: bool,
pub log_duplicates: bool,
pub min_command_length: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
history_file: crate::default_history_path()
.unwrap_or_else(|_| PathBuf::from("/tmp").join(crate::DEFAULT_HISTORY_FILE)),
max_entries: DEFAULT_MAX_ENTRIES,
enable_redaction: true,
redaction: RedactionConfig::default(),
import: ImportConfig::default(),
search: SearchConfig::default(),
logging: LoggingConfig::default(),
shell_integration: ShellIntegrationConfig::default(),
custom_env_vars: vec![
"PASSWORD".to_string(),
"SECRET".to_string(),
"TOKEN".to_string(),
"API_KEY".to_string(),
"PRIVATE_KEY".to_string(),
],
}
}
}
impl Default for RedactionConfig {
fn default() -> Self {
Self {
placeholder: DEFAULT_REDACTION_PLACEHOLDER.to_string(),
use_builtin_patterns: true,
custom_patterns: Vec::new(),
exclude_patterns: Vec::new(),
redact_env_vars: true,
min_redaction_length: 3,
}
}
}
impl Default for ImportConfig {
fn default() -> Self {
let mut shell_history_paths = HashMap::new();
if let Some(home) = home::home_dir() {
shell_history_paths.insert("zsh".to_string(), home.join(".histfile"));
shell_history_paths.insert("bash".to_string(), home.join(".bash_history"));
shell_history_paths.insert(
"fish".to_string(),
home.join(".local/share/fish/fish_history"),
);
}
Self {
shell_history_paths,
auto_detect: true,
deduplicate: true,
preserve_timestamps: true,
max_age_days: 0, }
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
fuzzy_search: true,
case_sensitive: false,
include_directory: true,
include_timestamps: false,
max_results: 1000,
highlight_matches: true,
}
}
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
log_to_file: false,
log_file: None,
include_timestamps: true,
log_redacted_commands: false,
}
}
}
impl Default for ShellIntegrationConfig {
fn default() -> Self {
Self {
auto_log: true,
exclude_commands: vec![
"ls".to_string(),
"cd".to_string(),
"pwd".to_string(),
"clear".to_string(),
"history".to_string(),
],
log_space_prefixed: false,
log_duplicates: false,
min_command_length: 1,
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::default_config_path()?;
Self::load_from_path(&config_path)
}
pub fn load_from_path(path: &PathBuf) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(path).map_err(Error::Io)?;
let config: Config = serde_json::from_str(&content).map_err(Error::Json)?;
config.validate()?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_path = Self::default_config_path()?;
self.save_to_path(&config_path)
}
pub fn save_to_path(&self, path: &PathBuf) -> Result<()> {
self.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
fs::write(path, content)?;
Ok(())
}
pub fn default_config_path() -> Result<PathBuf> {
let home = home::home_dir().ok_or(Error::HomeDirectoryNotFound)?;
Ok(home.join(DEFAULT_CONFIG_FILE))
}
pub fn validate(&self) -> Result<()> {
for pattern in &self.redaction.custom_patterns {
regex::Regex::new(pattern).map_err(|_| Error::InvalidRedactionPattern {
pattern: pattern.clone(),
})?;
}
for pattern in &self.redaction.exclude_patterns {
regex::Regex::new(pattern).map_err(|_| Error::InvalidRedactionPattern {
pattern: pattern.clone(),
})?;
}
if self.max_entries == 0 {
return Err(Error::config_validation(
"max_entries",
"must be greater than 0 or use a very large number for unlimited",
));
}
match self.logging.level.as_str() {
"trace" | "debug" | "info" | "warn" | "error" => {}
_ => {
return Err(Error::config_validation(
"logging.level",
"must be one of: trace, debug, info, warn, error",
));
}
}
if self.search.max_results == 0 {
return Err(Error::config_validation(
"search.max_results",
"must be greater than 0",
));
}
Ok(())
}
pub fn merge(&mut self, other: &Config) {
self.history_file = other.history_file.clone();
self.max_entries = other.max_entries;
self.enable_redaction = other.enable_redaction;
self.redaction = other.redaction.clone();
self.import = other.import.clone();
self.search = other.search.clone();
self.logging = other.logging.clone();
self.shell_integration = other.shell_integration.clone();
self.custom_env_vars = other.custom_env_vars.clone();
}
pub fn get_all_redaction_patterns(&self) -> Vec<String> {
let mut patterns = Vec::new();
if self.redaction.use_builtin_patterns {
patterns.extend(vec![
r"(?i)password\s*[=:]\s*[^\s]+".to_string(),
r"(?i)token\s*[=:]\s*[^\s]+".to_string(),
r"(?i)secret\s*[=:]\s*[^\s]+".to_string(),
r"(?i)api_key\s*[=:]\s*[^\s]+".to_string(),
r"(?i)(://[^:/@]+:)[^@]*(@)".to_string(),
r"(?i)bearer\s+[a-zA-Z0-9._-]+".to_string(),
]);
}
patterns.extend(self.redaction.custom_patterns.clone());
patterns
}
pub fn should_exclude_command(&self, command: &str) -> bool {
for excluded in &self.shell_integration.exclude_commands {
if command.starts_with(excluded) {
return true;
}
}
if command.len() < self.shell_integration.min_command_length {
return true;
}
if !self.shell_integration.log_space_prefixed && command.starts_with(' ') {
return true;
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.enable_redaction);
assert_eq!(config.max_entries, DEFAULT_MAX_ENTRIES);
assert_eq!(config.redaction.placeholder, DEFAULT_REDACTION_PLACEHOLDER);
}
#[test]
fn test_config_validation() {
let mut config = Config::default();
assert!(config.validate().is_ok());
config
.redaction
.custom_patterns
.push("[invalid".to_string());
assert!(config.validate().is_err());
config.redaction.custom_patterns.clear();
config.max_entries = 0;
assert!(config.validate().is_err());
config.max_entries = 1000;
config.logging.level = "invalid".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_config_save_load() {
let temp_file = NamedTempFile::new().unwrap();
let config_path = temp_file.path().to_path_buf();
let config = Config {
max_entries: 50000,
redaction: RedactionConfig {
placeholder: "<HIDDEN>".to_string(),
..Default::default()
},
..Default::default()
};
config.save_to_path(&config_path).unwrap();
let loaded_config = Config::load_from_path(&config_path).unwrap();
assert_eq!(loaded_config.max_entries, 50000);
assert_eq!(loaded_config.redaction.placeholder, "<HIDDEN>");
}
#[test]
fn test_should_exclude_command() {
let config = Config::default();
assert!(config.should_exclude_command("ls -la"));
assert!(config.should_exclude_command("cd /tmp"));
assert!(!config.should_exclude_command("echo hello"));
assert!(!config.should_exclude_command("grep pattern file"));
}
#[test]
fn test_get_all_redaction_patterns() {
let mut config = Config::default();
config
.redaction
.custom_patterns
.push("custom_pattern".to_string());
let patterns = config.get_all_redaction_patterns();
assert!(!patterns.is_empty());
assert!(patterns.contains(&"custom_pattern".to_string()));
}
#[test]
fn test_config_merge() {
let mut config1 = Config {
max_entries: 1000,
..Default::default()
};
let config2 = Config {
max_entries: 2000,
enable_redaction: false,
..Default::default()
};
config1.merge(&config2);
assert_eq!(config1.max_entries, 2000);
assert!(!config1.enable_redaction);
}
}