Skip to main content

ferrule_config/
bookmarks.rs

1use crate::error::ConfigError;
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6/// A single saved query bookmark.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Bookmark {
9    pub sql: String,
10    pub connection: Option<String>,
11}
12
13/// Persistent store for bookmarks using TOML.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct BookmarkStore {
16    #[serde(flatten)]
17    pub bookmarks: IndexMap<String, Bookmark>,
18}
19
20impl BookmarkStore {
21    /// Return the default file path (`~/.config/ferrule/bookmarks.toml`).
22    pub fn default_path() -> Result<PathBuf, ConfigError> {
23        let config_dir = dirs::config_dir()
24            .ok_or_else(|| {
25                ConfigError::ConfigNotFound("could not determine config directory".into())
26            })?
27            .join("ferrule");
28        Ok(config_dir.join("bookmarks.toml"))
29    }
30
31    /// Load from the default path.
32    pub fn load() -> Result<Self, ConfigError> {
33        Self::load_from_path(&Self::default_path()?)
34    }
35
36    /// Load from an explicit path.
37    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
38        if !path.exists() {
39            return Ok(Self::default());
40        }
41        let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
42        let store: BookmarkStore =
43            toml::from_str(&content).map_err(|e| ConfigError::InvalidConfig(e.to_string()))?;
44        Ok(store)
45    }
46
47    /// Save to the default path.
48    pub fn save(&self) -> Result<(), ConfigError> {
49        self.save_to_path(&Self::default_path()?)
50    }
51
52    /// Save to an explicit path.
53    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
54        if let Some(parent) = path.parent() {
55            std::fs::create_dir_all(parent).map_err(ConfigError::Io)?;
56        }
57        let content =
58            toml::to_string(self).map_err(|e| ConfigError::InvalidConfig(e.to_string()))?;
59        std::fs::write(path, content).map_err(ConfigError::Io)?;
60        Ok(())
61    }
62
63    /// Look up a bookmark by its full dotted name.
64    pub fn get(&self, name: &str) -> Option<&Bookmark> {
65        self.bookmarks.get(name)
66    }
67
68    /// Insert or overwrite a bookmark.
69    pub fn insert(&mut self, name: String, sql: String, connection: Option<String>) {
70        self.bookmarks.insert(name, Bookmark { sql, connection });
71    }
72
73    /// Remove a bookmark by name.
74    pub fn remove(&mut self, name: &str) -> Result<(), ConfigError> {
75        self.bookmarks
76            .shift_remove(name)
77            .ok_or_else(|| ConfigError::ConnectionNotFound(name.to_string()))?;
78        Ok(())
79    }
80
81    /// Return an ordered list of all bookmarks.
82    pub fn list(&self) -> Vec<(&String, &Bookmark)> {
83        self.bookmarks.iter().collect()
84    }
85
86    /// Extract the connection hint from a dotted bookmark name.
87    ///
88    /// * `pg.select_users` → `Some("pg")`
89    /// * `count_all`       → `None`
90    pub fn connection_hint(name: &str) -> Option<&str> {
91        if let Some((prefix, _rest)) = name.split_once('.') {
92            Some(prefix)
93        } else {
94            None
95        }
96    }
97
98    /// Perform positional substitution of `${1}`, `${2}`, etc.
99    ///
100    /// Missing parameters leave the placeholder intact.
101    pub fn resolve_params(sql: &str, params: &[String]) -> String {
102        let mut result = sql.to_string();
103        for (i, param) in params.iter().enumerate() {
104            let placeholder = format!("${{{}}}", i + 1);
105            result = result.replace(&placeholder, param);
106        }
107        result
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_bookmark_param_substitution() {
117        let sql = "SELECT * FROM users WHERE id = ${1} AND name = ${2}";
118        let result = BookmarkStore::resolve_params(sql, &["42".into(), "Alice".into()]);
119        assert_eq!(result, "SELECT * FROM users WHERE id = 42 AND name = Alice");
120    }
121
122    #[test]
123    fn test_bookmark_missing_param_left_intact() {
124        let sql = "SELECT * FROM users WHERE id = ${1} AND x = ${3}";
125        let result = BookmarkStore::resolve_params(sql, &["42".into()]);
126        assert_eq!(result, "SELECT * FROM users WHERE id = 42 AND x = ${3}");
127    }
128
129    #[test]
130    fn test_connection_hint() {
131        assert_eq!(
132            BookmarkStore::connection_hint("pg.select_users"),
133            Some("pg")
134        );
135        assert_eq!(BookmarkStore::connection_hint("count_all"), None);
136        assert_eq!(
137            BookmarkStore::connection_hint("prod.db.users"),
138            Some("prod")
139        );
140    }
141
142    #[test]
143    fn test_roundtrip() {
144        let mut store = BookmarkStore::default();
145        store.insert(
146            "pg.select_users".into(),
147            "SELECT * FROM users;".into(),
148            Some("pg".into()),
149        );
150        store.insert(
151            "count_all".into(),
152            "SELECT COUNT(*) FROM ${1};".into(),
153            None,
154        );
155        assert_eq!(store.bookmarks.len(), 2);
156
157        let (name, bm) = store.list()[0];
158        assert_eq!(name, "pg.select_users");
159        assert_eq!(bm.sql, "SELECT * FROM users;");
160        assert_eq!(bm.connection.as_deref(), Some("pg"));
161
162        store.remove("pg.select_users").unwrap();
163        assert_eq!(store.bookmarks.len(), 1);
164        assert!(store.get("pg.select_users").is_none());
165    }
166}