ferrule_config/
bookmarks.rs1use crate::error::ConfigError;
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Bookmark {
9 pub sql: String,
10 pub connection: Option<String>,
11}
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct BookmarkStore {
16 #[serde(flatten)]
17 pub bookmarks: IndexMap<String, Bookmark>,
18}
19
20impl BookmarkStore {
21 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 pub fn load() -> Result<Self, ConfigError> {
33 Self::load_from_path(&Self::default_path()?)
34 }
35
36 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 pub fn save(&self) -> Result<(), ConfigError> {
49 self.save_to_path(&Self::default_path()?)
50 }
51
52 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 pub fn get(&self, name: &str) -> Option<&Bookmark> {
65 self.bookmarks.get(name)
66 }
67
68 pub fn insert(&mut self, name: String, sql: String, connection: Option<String>) {
70 self.bookmarks.insert(name, Bookmark { sql, connection });
71 }
72
73 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 pub fn list(&self) -> Vec<(&String, &Bookmark)> {
83 self.bookmarks.iter().collect()
84 }
85
86 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 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}