Skip to main content

agent_engine/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()
26        .join("plugins")
27        .join(plugin_id)
28        .join("config")
29}
30
31/// Read one plugin-owned config value.
32pub fn read_plugin_config(plugin_id: &str, key: &str) -> Option<String> {
33    read_plugin_config_from(&plugin_config_path(plugin_id), key)
34}
35
36/// Write one plugin-owned config value, preserving comments and unrelated keys.
37pub 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
41/// Read one plugin-owned config value from an explicit path (testable core).
42pub 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
60/// Write one plugin-owned config value to an explicit path (testable core).
61pub 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
109/// Subscribe to changes for selected keys in a plugin config file.
110///
111/// The returned watch receiver emits `Some(change)` whenever the config file is
112/// created or modified and one of the watched keys changes value. An empty
113/// `keys` list watches every parsed key.
114pub 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
121/// Testable implementation of [`subscribe_changes`] for an explicit path.
122pub 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 &current {
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}