Skip to main content

ferrule_config/
registry.rs

1use crate::error::ConfigError;
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4
5/// Lightweight entry for a single connection.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8pub struct ConnectionEntry {
9    pub name: String,
10    pub url: String,
11}
12
13/// In-memory registry of connections.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct ConnectionRegistry {
16    pub entries: IndexMap<String, ConnectionEntry>,
17}
18
19impl ConnectionRegistry {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    pub fn add(&mut self, name: String, url: String) -> Result<(), ConfigError> {
25        if self.entries.contains_key(&name) {
26            return Err(ConfigError::DuplicateConnection(name));
27        }
28        self.entries
29            .insert(name.clone(), ConnectionEntry { name, url });
30        Ok(())
31    }
32
33    pub fn remove(&mut self, name: &str) -> Result<(), ConfigError> {
34        self.entries
35            .shift_remove(name)
36            .ok_or_else(|| ConfigError::ConnectionNotFound(name.to_string()))?;
37        Ok(())
38    }
39
40    pub fn get(&self, name: &str) -> Option<&ConnectionEntry> {
41        self.entries.get(name)
42    }
43
44    pub fn list(&self) -> Vec<&ConnectionEntry> {
45        self.entries.values().collect()
46    }
47
48    /// Load from the default config directory (`~/.config/ferrule/connections.toml`).
49    pub fn load_default() -> Result<Self, ConfigError> {
50        let path = default_config_path()?;
51        if !path.exists() {
52            return Ok(Self::new());
53        }
54        let content = std::fs::read_to_string(&path)?;
55        let mut registry: ConnectionRegistry =
56            toml::from_str(&content).map_err(|e| ConfigError::InvalidConfig(e.to_string()))?;
57        for entry in registry.entries.values_mut() {
58            entry.url = interpolate_env_vars(&entry.url);
59        }
60        Ok(registry)
61    }
62
63    /// Save to the default config directory.
64    pub fn save_default(&self) -> Result<(), ConfigError> {
65        let path = default_config_path()?;
66        if let Some(parent) = path.parent() {
67            std::fs::create_dir_all(parent)?;
68        }
69        let content =
70            toml::to_string(self).map_err(|e| ConfigError::InvalidConfig(e.to_string()))?;
71        std::fs::write(&path, content)?;
72        Ok(())
73    }
74}
75
76fn default_config_path() -> Result<std::path::PathBuf, ConfigError> {
77    let config_dir = dirs::config_dir()
78        .ok_or_else(|| ConfigError::ConfigNotFound("could not determine config directory".into()))?
79        .join("ferrule");
80    Ok(config_dir.join("connections.toml"))
81}
82
83/// Interpolate `${VAR}` and `${VAR:-default}` patterns in a string.
84/// Also supports `$$` as an escaped literal `$`.
85/// Unknown variables are left unchanged.
86pub fn interpolate_env_vars(input: &str) -> String {
87    let mut out = String::with_capacity(input.len());
88    let mut chars = input.chars().peekable();
89    while let Some(ch) = chars.next() {
90        if ch == '$' {
91            if chars.next_if_eq(&'$').is_some() {
92                out.push('$');
93                continue;
94            }
95            if chars.next_if_eq(&'{').is_some() {
96                let var_spec: String = chars.by_ref().take_while(|c| *c != '}').collect();
97                if let Some((var, default)) = var_spec.split_once(":-") {
98                    match std::env::var(var) {
99                        Ok(val) if !val.is_empty() => out.push_str(&val),
100                        _ => out.push_str(default),
101                    }
102                } else {
103                    match std::env::var(&var_spec) {
104                        Ok(val) => out.push_str(&val),
105                        Err(_) => {
106                            out.push_str("${");
107                            out.push_str(&var_spec);
108                            out.push('}');
109                        }
110                    }
111                }
112            } else {
113                out.push('$');
114            }
115        } else {
116            out.push(ch);
117        }
118    }
119    out
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_interpolate_basic() {
128        std::env::set_var("FERRULE_TEST_DB", "mydb");
129        assert_eq!(
130            interpolate_env_vars("postgres://u@h/${FERRULE_TEST_DB}"),
131            "postgres://u@h/mydb"
132        );
133    }
134
135    #[test]
136    fn test_interpolate_default() {
137        std::env::remove_var("FERRULE_TEST_MISSING");
138        assert_eq!(
139            interpolate_env_vars("host=${FERRULE_TEST_MISSING:-localhost}"),
140            "host=localhost"
141        );
142    }
143
144    #[test]
145    fn test_interpolate_default_override() {
146        std::env::set_var("FERRULE_TEST_HOST", "prod.example.com");
147        assert_eq!(
148            interpolate_env_vars("host=${FERRULE_TEST_HOST:-localhost}"),
149            "host=prod.example.com"
150        );
151        std::env::remove_var("FERRULE_TEST_HOST");
152    }
153
154    #[test]
155    fn test_interpolate_escape() {
156        assert_eq!(interpolate_env_vars("cost is $$5.00"), "cost is $5.00");
157    }
158
159    #[test]
160    fn test_interpolate_unknown() {
161        std::env::remove_var("FERRULE_TEST_UNKNOWN");
162        assert_eq!(
163            interpolate_env_vars("host=${FERRULE_TEST_UNKNOWN}"),
164            "host=${FERRULE_TEST_UNKNOWN}"
165        );
166    }
167
168    #[test]
169    fn test_interpolate_no_braces() {
170        // Bare $VAR is left as-is (not interpolated)
171        assert_eq!(interpolate_env_vars("host=$VAR"), "host=$VAR");
172    }
173}