agent_engine/extensions/
config_store.rs1use std::path::{Path, PathBuf};
9use std::sync::mpsc;
10use std::time::Duration;
11
12use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
13use serde::{Deserialize, Serialize};
14use tokio::sync::watch;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct PluginConfigChange {
19 pub key: String,
20 pub value: Option<String>,
21}
22
23pub fn plugin_config_path(plugin_id: &str) -> PathBuf {
25 crate::config::base_dir()
26 .join("plugins")
27 .join(plugin_id)
28 .join("config")
29}
30
31pub fn read_plugin_config(plugin_id: &str, key: &str) -> Option<String> {
33 read_plugin_config_from(&plugin_config_path(plugin_id), key)
34}
35
36pub fn write_plugin_config(plugin_id: &str, key: &str, value: &str) -> std::io::Result<()> {
38 write_plugin_config_to(&plugin_config_path(plugin_id), key, value)
39}
40
41pub fn read_plugin_config_from(path: &Path, key: &str) -> Option<String> {
43 let content = std::fs::read_to_string(path).ok()?;
44 let key_trimmed = key.trim();
45 for line in content.lines() {
46 let line = line.trim();
47 if line.is_empty() || line.starts_with('#') {
48 continue;
49 }
50 let Some((k, v)) = line.split_once('=') else {
51 continue;
52 };
53 if k.trim() == key_trimmed {
54 return Some(v.trim().to_string());
55 }
56 }
57 None
58}
59
60pub fn write_plugin_config_to(path: &Path, key: &str, value: &str) -> std::io::Result<()> {
62 if let Some(parent) = path.parent() {
63 std::fs::create_dir_all(parent)?;
64 }
65 let existing = std::fs::read_to_string(path).unwrap_or_default();
66 let key_trimmed = key.trim();
67 let replacement = format!("{} = {}", key_trimmed, value.trim());
68
69 let mut found = false;
70 let mut new_lines: Vec<String> = existing
71 .lines()
72 .map(|line| {
73 if found {
74 return line.to_string();
75 }
76 let t = line.trim_start();
77 if t.starts_with('#') || t.is_empty() {
78 return line.to_string();
79 }
80 if let Some((k, _)) = t.split_once('=') {
81 if k.trim() == key_trimmed {
82 found = true;
83 return replacement.clone();
84 }
85 }
86 line.to_string()
87 })
88 .collect();
89
90 if !found {
91 new_lines.push(replacement);
92 }
93 let mut out = new_lines.join("\n");
94 if !out.ends_with('\n') {
95 out.push('\n');
96 }
97
98 let tmp = path.with_extension("tmp");
99 std::fs::write(&tmp, out)?;
100 #[cfg(unix)]
101 {
102 use std::os::unix::fs::PermissionsExt;
103 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
104 }
105 std::fs::rename(&tmp, path)?;
106 Ok(())
107}
108
109pub fn subscribe_changes(
115 plugin_id: &str,
116 keys: Vec<String>,
117) -> notify::Result<watch::Receiver<Option<PluginConfigChange>>> {
118 subscribe_changes_at(plugin_config_path(plugin_id), keys)
119}
120
121pub fn subscribe_changes_at(
123 path: PathBuf,
124 keys: Vec<String>,
125) -> notify::Result<watch::Receiver<Option<PluginConfigChange>>> {
126 let (watch_tx, watch_rx) = watch::channel(None);
127 let (notify_tx, notify_rx) = mpsc::channel();
128 let watch_path = path.clone();
129 let parent = path
130 .parent()
131 .map(Path::to_path_buf)
132 .unwrap_or_else(|| PathBuf::from("."));
133 let watched: Vec<String> = keys.into_iter().map(|k| k.trim().to_string()).collect();
134
135 std::fs::create_dir_all(&parent).map_err(notify::Error::io)?;
136
137 let mut watcher = RecommendedWatcher::new(
138 move |res| {
139 let _ = notify_tx.send(res);
140 },
141 notify::Config::default().with_poll_interval(Duration::from_millis(50)),
142 )?;
143 watcher.watch(&parent, RecursiveMode::NonRecursive)?;
144
145 std::thread::spawn(move || {
146 let _keep_watcher_alive = watcher;
147 let mut previous = parse_config_file(&watch_path);
148 while let Ok(event) = notify_rx.recv() {
149 let Ok(event) = event else { continue };
150 if !matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)) {
151 continue;
152 }
153 if !event.paths.iter().any(|p| p == &watch_path) {
154 continue;
155 }
156 let current = parse_config_file(&watch_path);
157 for (key, value) in ¤t {
158 if !watched.is_empty() && !watched.iter().any(|k| k == key) {
159 continue;
160 }
161 if previous.get(key) != Some(value) {
162 let _ = watch_tx.send(Some(PluginConfigChange {
163 key: key.clone(),
164 value: Some(value.clone()),
165 }));
166 break;
167 }
168 }
169 previous = current;
170 }
171 });
172
173 Ok(watch_rx)
174}
175
176fn parse_config_file(path: &Path) -> std::collections::BTreeMap<String, String> {
177 let mut out = std::collections::BTreeMap::new();
178 let Ok(content) = std::fs::read_to_string(path) else {
179 return out;
180 };
181 for line in content.lines() {
182 let line = line.trim();
183 if line.is_empty() || line.starts_with('#') {
184 continue;
185 }
186 let Some((k, v)) = line.split_once('=') else {
187 continue;
188 };
189 out.insert(k.trim().to_string(), v.trim().to_string());
190 }
191 out
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn read_plugin_config_reads_exact_key_from_plugin_file() {
200 let dir = tempfile::tempdir().unwrap();
201 let path = dir.path().join("config");
202 std::fs::write(&path, "# comment\nmodel_path = /tmp/a.bin\nbackend = cpu\n").unwrap();
203
204 assert_eq!(
205 read_plugin_config_from(&path, "model_path"),
206 Some("/tmp/a.bin".to_string())
207 );
208 assert_eq!(read_plugin_config_from(&path, "missing"), None);
209 }
210
211 #[test]
212 fn write_plugin_config_replaces_existing_key_and_preserves_comments() {
213 let dir = tempfile::tempdir().unwrap();
214 let path = dir.path().join("config");
215 std::fs::write(&path, "# keep\nbackend = auto\nmodel_path = old\n").unwrap();
216
217 write_plugin_config_to(&path, "backend", "cpu").unwrap();
218
219 let content = std::fs::read_to_string(path).unwrap();
220 assert!(content.contains("# keep"));
221 assert!(content.contains("backend = cpu"));
222 assert!(content.contains("model_path = old"));
223 }
224
225 #[test]
226 fn write_plugin_config_creates_parent_directory_and_appends_missing_key() {
227 let dir = tempfile::tempdir().unwrap();
228 let path = dir.path().join("plugins").join("capture").join("config");
229
230 write_plugin_config_to(&path, "language", "auto").unwrap();
231
232 assert_eq!(
233 read_plugin_config_from(&path, "language"),
234 Some("auto".to_string())
235 );
236 }
237
238 #[tokio::test]
239 async fn subscribe_changes_notifies_for_watched_key_only() {
240 let dir = tempfile::tempdir().unwrap();
241 let path = dir.path().join("config");
242 std::fs::write(&path, "backend = auto\nignored = old\n").unwrap();
243
244 let mut rx = subscribe_changes_at(path.clone(), vec!["backend".to_string()]).unwrap();
245 write_plugin_config_to(&path, "ignored", "new").unwrap();
246 tokio::time::sleep(Duration::from_millis(100)).await;
247 assert!(rx.borrow().is_none());
248
249 write_plugin_config_to(&path, "backend", "cpu").unwrap();
250 tokio::time::timeout(Duration::from_secs(2), rx.changed())
251 .await
252 .unwrap()
253 .unwrap();
254 assert_eq!(
255 rx.borrow().clone(),
256 Some(PluginConfigChange {
257 key: "backend".to_string(),
258 value: Some("cpu".to_string())
259 })
260 );
261 }
262}