oxicode_sdk/ports/fs/
config.rs1use 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
11pub 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 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
63fn 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 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}