use crate::domain::error::DomainResult;
use crate::util::path::expand_path;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, instrument, trace, warn};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FzfOpts {
#[serde(default = "default_height")]
pub height: String,
#[serde(default)]
pub reverse: bool,
#[serde(default)]
pub show_tags: bool,
#[serde(default)]
pub no_url: bool,
#[serde(default = "default_show_action")]
pub show_action: bool,
#[serde(default = "default_show_file_info")]
pub show_file_info: bool,
}
fn default_height() -> String {
"50%".to_string()
}
fn default_show_action() -> bool {
true
}
fn default_show_file_info() -> bool {
true
}
impl Default for FzfOpts {
fn default() -> Self {
Self {
height: default_height(),
reverse: false,
show_tags: false,
no_url: false,
show_action: default_show_action(),
show_file_info: default_show_file_info(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ShellOpts {
#[serde(default = "default_interactive")]
pub interactive: bool,
}
fn default_interactive() -> bool {
true
}
impl Default for ShellOpts {
fn default() -> Self {
Self {
interactive: default_interactive(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Settings {
#[serde(default = "default_db_path")]
pub db_url: String,
#[serde(default)]
pub fzf_opts: FzfOpts,
#[serde(default)]
pub shell_opts: ShellOpts,
#[serde(default)]
pub base_paths: HashMap<String, String>,
#[serde(skip)]
pub config_source: ConfigSource,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
pub enum ConfigSource {
#[default]
Default,
ConfigFile,
Environment,
}
fn default_db_path() -> String {
let db_dir = match dirs::home_dir() {
Some(home) => home.join(".config/bkmr"),
None => {
if let Some(data_dir) = dirs::data_local_dir() {
data_dir.join("bkmr")
}
else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".bkmr")
}
}
};
std::fs::create_dir_all(&db_dir).ok();
db_dir
.join("bkmr.db")
.to_str()
.unwrap_or("./bkmr.db") .to_string()
}
impl Default for Settings {
fn default() -> Self {
Self {
db_url: default_db_path(),
fzf_opts: FzfOpts::default(),
shell_opts: ShellOpts::default(),
base_paths: HashMap::new(),
config_source: ConfigSource::Default,
}
}
}
fn parse_fzf_opts(opts_str: &str) -> FzfOpts {
let mut opts = FzfOpts::default();
let parts: Vec<&str> = opts_str.split_whitespace().collect();
for i in 0..parts.len() {
match parts[i] {
"--height" if i + 1 < parts.len() => {
opts.height = parts[i + 1].to_string();
}
"--reverse" => {
opts.reverse = true;
}
"--show-tags" => {
opts.show_tags = true;
}
"--no-url" => {
opts.no_url = true;
}
"--no-action" => {
opts.show_action = false;
}
"--no-file-info" => {
opts.show_file_info = false;
}
_ => {} }
}
opts
}
#[instrument(level = "debug")]
pub fn load_settings(config_file: Option<&Path>) -> DomainResult<Settings> {
trace!("Loading settings");
let mut settings = Settings::default();
if let Some(path) = config_file {
if path.exists() {
trace!("Loading config from specified file: {:?}", path);
if let Ok(config_text) = std::fs::read_to_string(path) {
if let Ok(mut file_settings) = toml::from_str::<Settings>(&config_text) {
file_settings.config_source = ConfigSource::ConfigFile;
settings = file_settings;
expand_db_url(&mut settings);
trace!("Successfully loaded settings from specified file");
} else {
warn!("Failed to parse config file: {:?}", path);
}
} else {
warn!("Failed to read config file: {:?}", path);
}
apply_env_overrides(&mut settings);
return Ok(settings);
} else {
warn!("Specified config file does not exist: {:?}", path);
}
}
let config_sources = [
dirs::home_dir().map(|p| p.join(".config/bkmr/config.toml")),
];
for config_path in config_sources.iter().flatten() {
if config_path.exists() {
trace!("Loading config from: {:?}", config_path);
if let Ok(config_text) = std::fs::read_to_string(config_path) {
if let Ok(mut file_settings) = toml::from_str::<Settings>(&config_text) {
file_settings.config_source = ConfigSource::ConfigFile;
settings = file_settings;
expand_db_url(&mut settings);
break; }
}
}
}
apply_env_overrides(&mut settings);
if settings.config_source == ConfigSource::Default {
debug!("No configuration file or environment variables found, using default settings.");
}
trace!("Settings loaded: {:?}", settings);
Ok(settings)
}
fn expand_db_url(settings: &mut Settings) {
settings.db_url = expand_path(&settings.db_url);
}
fn apply_env_overrides(settings: &mut Settings) {
let mut used_env_vars = false;
if let Ok(db_url) = std::env::var("BKMR_DB_URL") {
trace!("Using BKMR_DB_URL from environment: {}", db_url);
settings.db_url = db_url;
used_env_vars = true;
}
if let Ok(fzf_opts) = std::env::var("BKMR_FZF_OPTS") {
trace!("Using BKMR_FZF_OPTS from environment: {}", fzf_opts);
settings.fzf_opts = parse_fzf_opts(&fzf_opts);
used_env_vars = true;
}
if let Ok(shell_interactive) = std::env::var("BKMR_SHELL_INTERACTIVE") {
trace!(
"Using BKMR_SHELL_INTERACTIVE from environment: {}",
shell_interactive
);
settings.shell_opts.interactive = shell_interactive.to_lowercase() == "true";
used_env_vars = true;
}
if used_env_vars && settings.config_source == ConfigSource::Default {
settings.config_source = ConfigSource::Environment;
}
}
pub fn generate_default_config() -> String {
let default_settings = Settings::default();
toml::to_string_pretty(&default_settings)
.unwrap_or_else(|_| "# Error generating default configuration".to_string())
}
pub fn get_config_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".config/bkmr/config.toml"))
}
pub fn has_base_path(settings: &Settings, name: &str) -> bool {
settings.base_paths.contains_key(name)
}
pub fn resolve_file_path(settings: &Settings, path: &str) -> String {
let mut resolved = path.to_string();
for (name, base_path) in &settings.base_paths {
let var_pattern = format!("${}", name);
if resolved.contains(&var_pattern) {
let expanded_base =
shellexpand::full(base_path).unwrap_or(std::borrow::Cow::Borrowed(base_path));
resolved = resolved.replace(&var_pattern, &expanded_base);
}
}
match shellexpand::full(&resolved) {
Ok(expanded) => expanded.to_string(),
Err(_) => resolved,
}
}
pub fn create_file_path_with_base(base_path_name: &str, relative_path: &str) -> String {
format!("${}/{}", base_path_name, relative_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::testing::EnvGuard;
use std::env;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn create_temp_config_file(content: &str) -> (TempDir, PathBuf) {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("config.toml");
fs::write(&config_path, content).unwrap();
(temp_dir, config_path)
}
#[test]
fn given_no_config_file_when_load_settings_then_uses_defaults() {
let _guard = EnvGuard::new();
env::remove_var("BKMR_DB_URL");
env::remove_var("BKMR_FZF_OPTS");
let settings = load_settings(None).unwrap();
assert!(settings.db_url.contains("bkmr.db"));
assert_eq!(settings.fzf_opts.height, "50%");
assert!(!settings.fzf_opts.reverse);
assert!(!settings.fzf_opts.show_tags);
assert!(!settings.fzf_opts.no_url);
}
#[test]
fn given_custom_config_file_when_load_settings_then_uses_file_values() {
let _guard = EnvGuard::new();
env::remove_var("BKMR_DB_URL");
env::remove_var("BKMR_FZF_OPTS");
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("custom_config.toml");
let config_content = r#"
db_url = "/custom/path/to/db.db"
[fzf_opts]
height = "75%"
reverse = true
show_tags = true
no_url = true
"#;
fs::write(&config_path, config_content).unwrap();
let settings = load_settings(Some(&config_path)).unwrap();
assert_eq!(settings.db_url, "/custom/path/to/db.db");
assert_eq!(settings.fzf_opts.height, "75%");
assert!(settings.fzf_opts.reverse);
assert!(settings.fzf_opts.show_tags);
assert!(settings.fzf_opts.no_url);
}
#[test]
fn given_env_vars_and_config_file_when_load_settings_then_env_vars_override() {
let _guard = EnvGuard::new();
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("custom_config.toml");
let config_content = r#"
db_url = "/config/path/to/db.db"
[fzf_opts]
height = "60%"
reverse = false
show_tags = false
no_url = false
"#;
fs::write(&config_path, config_content).unwrap();
env::set_var("BKMR_DB_URL", "/env/path/to/db.db");
env::set_var("BKMR_FZF_OPTS", "--height 80% --reverse --show-tags");
let settings = load_settings(Some(&config_path)).unwrap();
assert_eq!(settings.db_url, "/env/path/to/db.db");
assert_eq!(settings.fzf_opts.height, "80%");
assert!(settings.fzf_opts.reverse);
assert!(settings.fzf_opts.show_tags);
assert!(!settings.fzf_opts.no_url);
}
#[test]
fn given_nonexistent_config_file_when_load_settings_then_uses_defaults() {
let _guard = EnvGuard::new();
env::remove_var("BKMR_DB_URL");
env::remove_var("BKMR_FZF_OPTS");
let non_existent_path = Path::new("/this/path/does/not/exist/config.toml");
let settings = load_settings(Some(non_existent_path)).unwrap();
assert!(settings.db_url.contains("bkmr.db"));
assert_eq!(settings.fzf_opts.height, "50%");
assert!(!settings.fzf_opts.reverse);
assert!(!settings.fzf_opts.show_tags);
assert!(!settings.fzf_opts.no_url);
}
#[test]
fn given_env_vars_when_load_settings_then_overrides_defaults() {
let _guard = EnvGuard::new();
env::set_var("BKMR_DB_URL", "/test/custom.db");
env::set_var("BKMR_FZF_OPTS", "--height 75% --reverse --show-tags");
let settings = load_settings(None).unwrap();
assert_eq!(settings.db_url, "/test/custom.db");
assert_eq!(settings.fzf_opts.height, "75%");
assert!(settings.fzf_opts.reverse);
assert!(settings.fzf_opts.show_tags);
assert!(!settings.fzf_opts.no_url);
}
#[test]
fn given_partial_env_vars_when_load_settings_then_overrides_only_specified() {
let _guard = EnvGuard::new();
env::set_var("BKMR_DB_URL", "/partial/override.db");
env::remove_var("BKMR_FZF_OPTS");
let settings = load_settings(None).unwrap();
assert_eq!(settings.db_url, "/partial/override.db");
assert_eq!(settings.fzf_opts.height, "50%"); assert!(!settings.fzf_opts.reverse); }
#[test]
fn given_fzf_option_string_when_parse_fzf_opts_then_returns_parsed_options() {
let opts = parse_fzf_opts("--height 80% --reverse --show-tags --no-url");
assert_eq!(opts.height, "80%");
assert!(opts.reverse);
assert!(opts.show_tags);
assert!(opts.no_url);
let opts = parse_fzf_opts("--height 60% --show-tags");
assert_eq!(opts.height, "60%");
assert!(!opts.reverse);
assert!(opts.show_tags);
assert!(!opts.no_url);
let opts = parse_fzf_opts("--height 70% --unknown-option");
assert_eq!(opts.height, "70%");
assert!(!opts.reverse);
assert!(!opts.show_tags);
assert!(!opts.no_url);
let opts = parse_fzf_opts("--reverse --height 90%");
assert_eq!(opts.height, "90%");
assert!(opts.reverse);
}
#[test]
fn given_config_file_content_when_create_settings_then_matches_expected_values() {
let _guard = EnvGuard::new();
env::remove_var("BKMR_DB_URL");
env::remove_var("BKMR_FZF_OPTS");
let config_content = r#"
db_url = "/config/file/path.db"
[fzf_opts]
height = "65%"
reverse = true
show_tags = true
no_url = false
"#;
let (temp_dir, _config_path) = create_temp_config_file(config_content);
let _original_config_dir = dirs::config_dir();
let settings = Settings {
db_url: "/config/file/path.db".to_string(),
fzf_opts: FzfOpts {
height: "65%".to_string(),
reverse: true,
show_tags: true,
no_url: false,
show_action: true,
show_file_info: true,
},
shell_opts: ShellOpts { interactive: true },
base_paths: HashMap::new(),
config_source: ConfigSource::ConfigFile,
};
assert_eq!(settings.db_url, "/config/file/path.db");
assert_eq!(settings.fzf_opts.height, "65%");
assert!(settings.fzf_opts.reverse);
assert!(settings.fzf_opts.show_tags);
assert!(!settings.fzf_opts.no_url);
drop(temp_dir);
}
#[test]
fn given_env_vars_and_config_content_when_load_settings_then_env_overrides_config() {
let _guard = EnvGuard::new();
env::set_var("BKMR_DB_URL", "/env/override.db");
env::set_var("BKMR_FZF_OPTS", "--height 95% --no-url");
let config_content = r#"
db_url = "/config/non-override.db"
[fzf_opts]
height = "30%"
reverse = true
show_tags = true
no_url = false
"#;
let (temp_dir, _config_path) = create_temp_config_file(config_content);
let settings = load_settings(None).unwrap();
assert_eq!(settings.db_url, "/env/override.db");
assert_eq!(settings.fzf_opts.height, "95%");
assert!(!settings.fzf_opts.reverse); assert!(!settings.fzf_opts.show_tags); assert!(settings.fzf_opts.no_url);
drop(temp_dir);
}
#[test]
fn given_no_custom_path_when_default_db_path_then_contains_bkmr_db() {
let path = default_db_path();
assert!(path.contains("bkmr.db"));
}
#[test]
fn given_shell_interactive_env_var_when_load_settings_then_overrides_default() {
let _guard = EnvGuard::new();
env::set_var("BKMR_SHELL_INTERACTIVE", "false");
env::remove_var("BKMR_DB_URL");
env::remove_var("BKMR_FZF_OPTS");
let settings = load_settings(None).unwrap();
assert!(
!settings.shell_opts.interactive,
"Should disable interactive mode via environment"
);
env::set_var("BKMR_SHELL_INTERACTIVE", "true");
let settings = load_settings(None).unwrap();
assert!(
settings.shell_opts.interactive,
"Should enable interactive mode via environment"
);
}
}