Skip to main content

oxicode_sdk/ports/fs/
config.rs

1//! File-based `ConfigStore` — single TOML file with dotted-key flattening.
2
3use parking_lot::RwLock;
4use serde_json::Value as JsonValue;
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use crate::SdkError;
9use crate::ports::{ConfigStore, PortValue};
10
11/// File-based config. Reads from `path` on construction and writes back on
12/// every `set`.
13pub struct FileConfigStore {
14    path: PathBuf,
15    state: RwLock<BTreeMap<String, JsonValue>>,
16}
17
18impl std::fmt::Debug for FileConfigStore {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        f.debug_struct("FileConfigStore")
21            .field("path", &self.path)
22            .finish()
23    }
24}
25
26impl FileConfigStore {
27    /// Create a config store backed by `path`. If the file does not exist,
28    /// an empty store is returned; the file is created on the first `set`.
29    pub fn new(path: impl Into<PathBuf>) -> Self {
30        let path = path.into();
31        let state = Self::load(&path);
32        Self {
33            path,
34            state: RwLock::new(state),
35        }
36    }
37
38    fn load(path: &std::path::Path) -> BTreeMap<String, JsonValue> {
39        if !path.exists() {
40            return BTreeMap::new();
41        }
42        match std::fs::read_to_string(path) {
43            Ok(text) => toml_to_flat_map(&text).unwrap_or_default(),
44            Err(_) => BTreeMap::new(),
45        }
46    }
47
48    fn save(&self) -> std::io::Result<()> {
49        if let Some(parent) = self.path.parent() {
50            std::fs::create_dir_all(parent)?;
51        }
52        let snapshot = self.state.read().clone();
53        let nested = flat_map_to_nested(&snapshot);
54        let text = toml::to_string_pretty(&nested)
55            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
56        let tmp = self.path.with_extension("toml.tmp");
57        std::fs::write(&tmp, text)?;
58        std::fs::rename(&tmp, &self.path)?;
59        Ok(())
60    }
61}
62
63/// Convert a TOML document into a flat dotted-key map.
64fn toml_to_flat_map(text: &str) -> Result<BTreeMap<String, JsonValue>, toml::de::Error> {
65    let v: toml::Value = toml::from_str(text)?;
66    let mut out = BTreeMap::new();
67    flatten_into(&v, "", &mut out);
68    Ok(out)
69}
70
71fn flatten_into(v: &toml::Value, prefix: &str, out: &mut BTreeMap<String, JsonValue>) {
72    match v {
73        toml::Value::Table(t) => {
74            for (k, vv) in t {
75                let next = if prefix.is_empty() {
76                    k.clone()
77                } else {
78                    format!("{prefix}.{k}")
79                };
80                flatten_into(vv, &next, out);
81            }
82        }
83        toml::Value::Array(a) => {
84            let j = serde_json::to_value(a).unwrap_or(JsonValue::Null);
85            out.insert(prefix.to_string(), j);
86        }
87        other => {
88            let j = serde_json::to_value(other).unwrap_or(JsonValue::Null);
89            out.insert(prefix.to_string(), j);
90        }
91    }
92}
93
94fn flat_map_to_nested(flat: &BTreeMap<String, JsonValue>) -> toml::Value {
95    let mut root = toml::value::Table::new();
96    for (key, value) in flat {
97        let parts: Vec<&str> = key.split('.').collect();
98        insert_nested(&mut root, &parts, value.clone());
99    }
100    toml::Value::Table(root)
101}
102
103fn insert_nested(root: &mut toml::value::Table, parts: &[&str], value: JsonValue) {
104    if parts.is_empty() {
105        return;
106    }
107    if parts.len() == 1 {
108        root.insert(parts[0].to_string(), json_to_toml(value));
109        return;
110    }
111    let head = parts[0];
112    let entry = root
113        .entry(head.to_string())
114        .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
115    if let toml::Value::Table(t) = entry {
116        insert_nested(t, &parts[1..], value);
117    }
118}
119
120fn json_to_toml(v: JsonValue) -> toml::Value {
121    match v {
122        JsonValue::Null => toml::Value::String(String::new()),
123        JsonValue::Bool(b) => toml::Value::Boolean(b),
124        JsonValue::Number(n) => {
125            if let Some(i) = n.as_i64() {
126                toml::Value::Integer(i)
127            } else if let Some(f) = n.as_f64() {
128                toml::Value::Float(f)
129            } else {
130                toml::Value::String(n.to_string())
131            }
132        }
133        JsonValue::String(s) => toml::Value::String(s),
134        JsonValue::Array(a) => toml::Value::Array(a.into_iter().map(json_to_toml).collect()),
135        JsonValue::Object(o) => {
136            let mut t = toml::value::Table::new();
137            for (k, v) in o {
138                t.insert(k, json_to_toml(v));
139            }
140            toml::Value::Table(t)
141        }
142    }
143}
144
145impl ConfigStore for FileConfigStore {
146    fn get(&self, key: &str) -> Result<Option<PortValue>, SdkError> {
147        Ok(self.state.read().get(key).cloned())
148    }
149
150    fn set(&self, key: &str, value: PortValue) -> Result<(), SdkError> {
151        {
152            let mut s = self.state.write();
153            s.insert(key.to_string(), value);
154        }
155        self.save().map_err(|e| SdkError::Internal(e.into()))
156    }
157
158    fn list(&self) -> Result<Vec<(String, PortValue)>, SdkError> {
159        Ok(self
160            .state
161            .read()
162            .iter()
163            .map(|(k, v)| (k.clone(), v.clone()))
164            .collect())
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use serde_json::json;
172    use tempfile::TempDir;
173
174    #[test]
175    fn round_trip_nested_keys() {
176        let tmp = TempDir::new().unwrap();
177        let p = tmp.path().join("settings.toml");
178        let c = FileConfigStore::new(&p);
179        c.set("model.provider", json!("anthropic")).unwrap();
180        c.set("model.name", json!("claude-sonnet-4-20250514"))
181            .unwrap();
182        c.set("ui.theme", json!("dark")).unwrap();
183        // Reload from disk.
184        let c2 = FileConfigStore::new(&p);
185        assert_eq!(c2.get("model.provider").unwrap(), Some(json!("anthropic")));
186        assert_eq!(c2.get("ui.theme").unwrap(), Some(json!("dark")));
187    }
188
189    #[test]
190    fn get_missing_returns_none() {
191        let tmp = TempDir::new().unwrap();
192        let c = FileConfigStore::new(tmp.path().join("nope.toml"));
193        assert!(c.get("any").unwrap().is_none());
194    }
195}