meerkat_core/
config_store.rs1use crate::config::{Config, ConfigDelta, ConfigError};
4#[cfg(target_arch = "wasm32")]
5use crate::tokio;
6use async_trait::async_trait;
7use serde_json::Value;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use tokio::io::AsyncWriteExt;
11use uuid::Uuid;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct ConfigResolvedPaths {
16 pub root: String,
17 pub manifest_path: String,
18 pub config_path: String,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub sessions_sqlite_path: Option<String>,
21 pub sessions_jsonl_dir: String,
22}
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct ConfigStoreMetadata {
27 pub realm_id: Option<String>,
28 pub instance_id: Option<String>,
29 pub backend: Option<String>,
30 pub resolved_paths: Option<ConfigResolvedPaths>,
31}
32
33#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
35#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
36pub trait ConfigStore: Send + Sync {
37 async fn get(&self) -> Result<Config, ConfigError>;
39
40 async fn set(&self, config: Config) -> Result<(), ConfigError>;
42
43 async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError>;
45
46 fn metadata(&self) -> Option<ConfigStoreMetadata> {
48 None
49 }
50}
51
52pub struct MemoryConfigStore {
54 config: tokio::sync::RwLock<Config>,
55}
56
57impl MemoryConfigStore {
58 pub fn new(config: Config) -> Self {
59 Self {
60 config: tokio::sync::RwLock::new(config),
61 }
62 }
63}
64
65#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
66#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
67impl ConfigStore for MemoryConfigStore {
68 async fn get(&self) -> Result<Config, ConfigError> {
69 Ok(self.config.read().await.clone())
70 }
71
72 async fn set(&self, config: Config) -> Result<(), ConfigError> {
73 config.validate()?;
74 *self.config.write().await = config;
75 Ok(())
76 }
77
78 async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError> {
79 let mut config = self.config.write().await;
80 let mut value = serde_json::to_value(&*config).map_err(ConfigError::Json)?;
81 merge_patch(&mut value, delta.0);
82 let updated: Config = serde_json::from_value(value).map_err(ConfigError::Json)?;
83 updated.validate()?;
84 *config = updated.clone();
85 Ok(updated)
86 }
87}
88
89pub struct TaggedConfigStore {
91 inner: Arc<dyn ConfigStore>,
92 metadata: ConfigStoreMetadata,
93}
94
95impl TaggedConfigStore {
96 pub fn new(inner: Arc<dyn ConfigStore>, metadata: ConfigStoreMetadata) -> Self {
97 Self { inner, metadata }
98 }
99}
100
101#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
102#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
103impl ConfigStore for TaggedConfigStore {
104 async fn get(&self) -> Result<Config, ConfigError> {
105 self.inner.get().await
106 }
107
108 async fn set(&self, config: Config) -> Result<(), ConfigError> {
109 self.inner.set(config).await
110 }
111
112 async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError> {
113 self.inner.patch(delta).await
114 }
115
116 fn metadata(&self) -> Option<ConfigStoreMetadata> {
117 Some(self.metadata.clone())
118 }
119}
120
121pub struct FileConfigStore {
123 path: PathBuf,
124 create_if_missing: bool,
125}
126
127impl FileConfigStore {
128 pub fn new(path: PathBuf) -> Self {
130 Self {
131 path,
132 create_if_missing: false,
133 }
134 }
135
136 pub async fn global() -> Result<Self, ConfigError> {
138 let path = Config::global_config_path()
139 .ok_or_else(|| ConfigError::MissingField("HOME".to_string()))?;
140 let store = Self {
141 path,
142 create_if_missing: true,
143 };
144 store.ensure_exists().await?;
145 Ok(store)
146 }
147
148 pub fn project(project_root: impl Into<PathBuf>) -> Self {
150 let root = project_root.into();
151 Self::new(root.join(".rkat").join("config.toml"))
152 }
153
154 pub fn path(&self) -> &Path {
156 &self.path
157 }
158
159 async fn ensure_exists(&self) -> Result<(), ConfigError> {
160 if tokio::fs::try_exists(&self.path).await? {
161 return Ok(());
162 }
163 if let Some(parent) = self.path.parent() {
164 tokio::fs::create_dir_all(parent).await?;
165 }
166 let content = Config::template_toml();
167 tokio::fs::write(&self.path, content).await?;
168 Ok(())
169 }
170}
171
172#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
173#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
174impl ConfigStore for FileConfigStore {
175 async fn get(&self) -> Result<Config, ConfigError> {
176 if self.create_if_missing {
177 self.ensure_exists().await?;
178 }
179
180 if !tokio::fs::try_exists(&self.path).await? {
181 return Ok(Config::default());
182 }
183
184 let bytes = tokio::fs::read(&self.path).await?;
185 let content = String::from_utf8(bytes).map_err(ConfigError::Utf8)?;
186 toml::from_str(&content).map_err(ConfigError::Parse)
187 }
188
189 async fn set(&self, config: Config) -> Result<(), ConfigError> {
190 config.validate()?;
191 if let Some(parent) = self.path.parent() {
192 tokio::fs::create_dir_all(parent).await?;
193 }
194 let content = toml::to_string_pretty(&config).map_err(ConfigError::TomlSerialize)?;
195 let parent = self
196 .path
197 .parent()
198 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
199 let tmp_path = parent.join(format!(".config.tmp.{}", Uuid::now_v7()));
200 let mut tmp = tokio::fs::OpenOptions::new()
201 .write(true)
202 .create_new(true)
203 .open(&tmp_path)
204 .await?;
205 tmp.write_all(content.as_bytes()).await?;
206 tmp.sync_all().await?;
207 drop(tmp);
208 tokio::fs::rename(&tmp_path, &self.path).await?;
209 Ok(())
210 }
211
212 async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError> {
213 let mut value = serde_json::to_value(self.get().await?).map_err(ConfigError::Json)?;
214 merge_patch(&mut value, delta.0);
215 let updated: Config = serde_json::from_value(value).map_err(ConfigError::Json)?;
216 updated.validate()?;
217 self.set(updated.clone()).await?;
218 Ok(updated)
219 }
220}
221
222pub(crate) fn merge_patch(base: &mut Value, patch: Value) {
224 match (base, patch) {
225 (Value::Object(base_map), Value::Object(patch_map)) => {
226 for (k, v) in patch_map {
227 if v.is_null() {
228 base_map.remove(&k);
229 } else {
230 merge_patch(base_map.entry(k).or_insert(Value::Null), v);
231 }
232 }
233 }
234 (base_val, patch_val) => {
235 *base_val = patch_val;
236 }
237 }
238}