use crate::error::ConfigError;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bookmark {
pub sql: String,
pub connection: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BookmarkStore {
#[serde(flatten)]
pub bookmarks: IndexMap<String, Bookmark>,
}
impl BookmarkStore {
pub fn default_path() -> Result<PathBuf, ConfigError> {
let config_dir = dirs::config_dir()
.ok_or_else(|| {
ConfigError::ConfigNotFound("could not determine config directory".into())
})?
.join("ferrule");
Ok(config_dir.join("bookmarks.toml"))
}
pub fn load() -> Result<Self, ConfigError> {
Self::load_from_path(&Self::default_path()?)
}
pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
let store: BookmarkStore =
toml::from_str(&content).map_err(|e| ConfigError::InvalidConfig(e.to_string()))?;
Ok(store)
}
pub fn save(&self) -> Result<(), ConfigError> {
self.save_to_path(&Self::default_path()?)
}
pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(ConfigError::Io)?;
}
let content =
toml::to_string(self).map_err(|e| ConfigError::InvalidConfig(e.to_string()))?;
std::fs::write(path, content).map_err(ConfigError::Io)?;
Ok(())
}
pub fn get(&self, name: &str) -> Option<&Bookmark> {
self.bookmarks.get(name)
}
pub fn insert(&mut self, name: String, sql: String, connection: Option<String>) {
self.bookmarks.insert(name, Bookmark { sql, connection });
}
pub fn remove(&mut self, name: &str) -> Result<(), ConfigError> {
self.bookmarks
.shift_remove(name)
.ok_or_else(|| ConfigError::ConnectionNotFound(name.to_string()))?;
Ok(())
}
pub fn list(&self) -> Vec<(&String, &Bookmark)> {
self.bookmarks.iter().collect()
}
pub fn connection_hint(name: &str) -> Option<&str> {
if let Some((prefix, _rest)) = name.split_once('.') {
Some(prefix)
} else {
None
}
}
pub fn resolve_params(sql: &str, params: &[String]) -> String {
let mut result = sql.to_string();
for (i, param) in params.iter().enumerate() {
let placeholder = format!("${{{}}}", i + 1);
result = result.replace(&placeholder, param);
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmark_param_substitution() {
let sql = "SELECT * FROM users WHERE id = ${1} AND name = ${2}";
let result = BookmarkStore::resolve_params(sql, &["42".into(), "Alice".into()]);
assert_eq!(result, "SELECT * FROM users WHERE id = 42 AND name = Alice");
}
#[test]
fn test_bookmark_missing_param_left_intact() {
let sql = "SELECT * FROM users WHERE id = ${1} AND x = ${3}";
let result = BookmarkStore::resolve_params(sql, &["42".into()]);
assert_eq!(result, "SELECT * FROM users WHERE id = 42 AND x = ${3}");
}
#[test]
fn test_connection_hint() {
assert_eq!(
BookmarkStore::connection_hint("pg.select_users"),
Some("pg")
);
assert_eq!(BookmarkStore::connection_hint("count_all"), None);
assert_eq!(
BookmarkStore::connection_hint("prod.db.users"),
Some("prod")
);
}
#[test]
fn test_roundtrip() {
let mut store = BookmarkStore::default();
store.insert(
"pg.select_users".into(),
"SELECT * FROM users;".into(),
Some("pg".into()),
);
store.insert(
"count_all".into(),
"SELECT COUNT(*) FROM ${1};".into(),
None,
);
assert_eq!(store.bookmarks.len(), 2);
let (name, bm) = store.list()[0];
assert_eq!(name, "pg.select_users");
assert_eq!(bm.sql, "SELECT * FROM users;");
assert_eq!(bm.connection.as_deref(), Some("pg"));
store.remove("pg.select_users").unwrap();
assert_eq!(store.bookmarks.len(), 1);
assert!(store.get("pg.select_users").is_none());
}
}