Skip to main content

synaps_cli/extensions/
config_store.rs

1//! Plugin-namespaced configuration store for extensions.
2//!
3//! Values are stored as simple `key = value` lines under
4//! `~/.synaps-cli/plugins/<plugin-id>/config` (or the active test/profile base).
5//! This is intentionally separate from the global Synaps config so rich plugins
6//! can own their settings without colonizing the core keyspace.
7
8use 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/// A single plugin-config change event.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct PluginConfigChange {
19    pub key: String,
20    pub value: Option<String>,
21}
22
23/// Compute the on-disk config path for one plugin id.
24pub fn plugin_config_path(plugin_id: &str) -> PathBuf {
25    crate::config::base_dir().join("plugins").join(plugin_id).join("config")
26}
27
28/// Read one plugin-owned config value.
29pub fn read_plugin_config(plugin_id: &str, key: &str) -> Option<String> {
30    read_plugin_config_from(&plugin_config_path(plugin_id), key)
31}
32
33/// Write one plugin-owned config value, preserving comments and unrelated keys.
34pub 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
38/// Read one plugin-owned config value from an explicit path (testable core).
39pub 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
57/// Write one plugin-owned config value to an explicit path (testable core).
58pub 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
106/// Subscribe to changes for selected keys in a plugin config file.
107///
108/// The returned watch receiver emits `Some(change)` whenever the config file is
109/// created or modified and one of the watched keys changes value. An empty
110/// `keys` list watches every parsed key.
111pub 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
118/// Testable implementation of [`subscribe_changes`] for an explicit path.
119pub 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 &current {
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}