ferrule_config/
registry.rs1use crate::error::ConfigError;
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8pub struct ConnectionEntry {
9 pub name: String,
10 pub url: String,
11}
12
13#[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 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 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
83pub 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 assert_eq!(interpolate_env_vars("host=$VAR"), "host=$VAR");
172 }
173}