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