claude_agent/config/
file.rs

1//! File-based Configuration Provider
2//!
3//! Loads configuration from JSON files (CLI compatible).
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use tokio::sync::RwLock;
10
11use super::ConfigResult;
12use super::provider::ConfigProvider;
13
14/// File-based configuration provider
15pub struct FileConfigProvider {
16    /// Path to the configuration file
17    path: PathBuf,
18    /// Cached data
19    data: Arc<RwLock<Option<HashMap<String, serde_json::Value>>>>,
20    /// Whether to auto-reload on get
21    auto_reload: bool,
22}
23
24impl FileConfigProvider {
25    /// Create a new file provider
26    pub fn new(path: PathBuf) -> Self {
27        Self {
28            path,
29            data: Arc::new(RwLock::new(None)),
30            auto_reload: false,
31        }
32    }
33
34    /// Create a file provider with auto-reload enabled
35    pub fn with_auto_reload(path: PathBuf) -> Self {
36        Self {
37            path,
38            data: Arc::new(RwLock::new(None)),
39            auto_reload: true,
40        }
41    }
42
43    /// Load configuration from file
44    async fn load(&self) -> ConfigResult<HashMap<String, serde_json::Value>> {
45        if !self.path.exists() {
46            return Ok(HashMap::new());
47        }
48
49        let content = tokio::fs::read_to_string(&self.path).await?;
50        let data: HashMap<String, serde_json::Value> = serde_json::from_str(&content)?;
51        Ok(data)
52    }
53
54    /// Ensure data is loaded
55    async fn ensure_loaded(&self) -> ConfigResult<()> {
56        let mut data = self.data.write().await;
57        if data.is_none() || self.auto_reload {
58            *data = Some(self.load().await?);
59        }
60        Ok(())
61    }
62
63    /// Save configuration to file
64    async fn save(&self, data: &HashMap<String, serde_json::Value>) -> ConfigResult<()> {
65        // Ensure parent directory exists
66        if let Some(parent) = self.path.parent() {
67            tokio::fs::create_dir_all(parent).await?;
68        }
69
70        let content = serde_json::to_string_pretty(data)?;
71        tokio::fs::write(&self.path, content).await?;
72        Ok(())
73    }
74
75    /// Reload configuration from file
76    pub async fn reload(&self) -> ConfigResult<()> {
77        let mut data = self.data.write().await;
78        *data = Some(self.load().await?);
79        Ok(())
80    }
81
82    /// Get the file path
83    pub fn path(&self) -> &PathBuf {
84        &self.path
85    }
86}
87
88#[async_trait::async_trait]
89impl ConfigProvider for FileConfigProvider {
90    fn name(&self) -> &str {
91        "file"
92    }
93
94    async fn get_raw(&self, key: &str) -> ConfigResult<Option<String>> {
95        self.ensure_loaded().await?;
96
97        let data = self.data.read().await;
98        if let Some(ref map) = *data {
99            // Support nested keys with dot notation
100            let parts: Vec<&str> = key.split('.').collect();
101            let mut current: Option<&serde_json::Value> = None;
102
103            for (i, part) in parts.iter().enumerate() {
104                if i == 0 {
105                    current = map.get(*part);
106                } else {
107                    current = current.and_then(|v| v.get(*part));
108                }
109            }
110
111            match current {
112                Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
113                Some(v) => Ok(Some(v.to_string())),
114                None => Ok(None),
115            }
116        } else {
117            Ok(None)
118        }
119    }
120
121    async fn set_raw(&self, key: &str, value: &str) -> ConfigResult<()> {
122        self.ensure_loaded().await?;
123
124        let mut data = self.data.write().await;
125        let map = data.get_or_insert_with(HashMap::new);
126
127        // Parse value as JSON if possible, otherwise store as string
128        let json_value: serde_json::Value = serde_json::from_str(value)
129            .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
130
131        // Simple top-level set (doesn't support nested paths for writes)
132        map.insert(key.to_string(), json_value);
133
134        self.save(map).await?;
135        Ok(())
136    }
137
138    async fn delete(&self, key: &str) -> ConfigResult<bool> {
139        self.ensure_loaded().await?;
140
141        let mut data = self.data.write().await;
142        if let Some(ref mut map) = *data {
143            let existed = map.remove(key).is_some();
144            if existed {
145                self.save(map).await?;
146            }
147            Ok(existed)
148        } else {
149            Ok(false)
150        }
151    }
152
153    async fn list_keys(&self, prefix: &str) -> ConfigResult<Vec<String>> {
154        self.ensure_loaded().await?;
155
156        let data = self.data.read().await;
157        if let Some(ref map) = *data {
158            let keys: Vec<String> = map
159                .keys()
160                .filter(|k| k.starts_with(prefix))
161                .cloned()
162                .collect();
163            Ok(keys)
164        } else {
165            Ok(Vec::new())
166        }
167    }
168}
169
170impl std::fmt::Debug for FileConfigProvider {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("FileConfigProvider")
173            .field("path", &self.path)
174            .field("auto_reload", &self.auto_reload)
175            .finish()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use tempfile::TempDir;
183
184    #[tokio::test]
185    async fn test_file_provider_create_and_read() {
186        let temp_dir = TempDir::new().unwrap();
187        let config_path = temp_dir.path().join("config.json");
188
189        // Create config file
190        let config = serde_json::json!({
191            "api_key": "sk-test-123",
192            "model": "claude-sonnet-4-5",
193            "nested": {
194                "value": "inner"
195            }
196        });
197        tokio::fs::write(&config_path, config.to_string())
198            .await
199            .unwrap();
200
201        let provider = FileConfigProvider::new(config_path);
202
203        // Read values
204        assert_eq!(
205            provider.get_raw("api_key").await.unwrap(),
206            Some("sk-test-123".to_string())
207        );
208        assert_eq!(
209            provider.get_raw("model").await.unwrap(),
210            Some("claude-sonnet-4-5".to_string())
211        );
212
213        // Read nested - string values from nested objects
214        assert_eq!(
215            provider.get_raw("nested.value").await.unwrap(),
216            Some("inner".to_string())
217        );
218    }
219
220    #[tokio::test]
221    async fn test_file_provider_nonexistent() {
222        let temp_dir = TempDir::new().unwrap();
223        let config_path = temp_dir.path().join("nonexistent.json");
224
225        let provider = FileConfigProvider::new(config_path);
226
227        // Should return None for non-existent file
228        assert_eq!(provider.get_raw("key").await.unwrap(), None);
229    }
230
231    #[tokio::test]
232    async fn test_file_provider_write() {
233        let temp_dir = TempDir::new().unwrap();
234        let config_path = temp_dir.path().join("new_config.json");
235
236        let provider = FileConfigProvider::new(config_path.clone());
237
238        // Write
239        provider.set_raw("key1", "value1").await.unwrap();
240        provider.set_raw("key2", "42").await.unwrap();
241
242        // Verify file was created
243        assert!(config_path.exists());
244
245        // Read back
246        assert_eq!(
247            provider.get_raw("key1").await.unwrap(),
248            Some("value1".to_string())
249        );
250    }
251
252    #[tokio::test]
253    async fn test_file_provider_delete() {
254        let temp_dir = TempDir::new().unwrap();
255        let config_path = temp_dir.path().join("delete_config.json");
256
257        let provider = FileConfigProvider::new(config_path);
258
259        // Set then delete
260        provider.set_raw("temp", "value").await.unwrap();
261        assert!(provider.delete("temp").await.unwrap());
262        assert_eq!(provider.get_raw("temp").await.unwrap(), None);
263    }
264
265    #[tokio::test]
266    async fn test_file_provider_list_keys() {
267        let temp_dir = TempDir::new().unwrap();
268        let config_path = temp_dir.path().join("list_config.json");
269
270        let provider = FileConfigProvider::new(config_path);
271
272        provider.set_raw("app.name", "\"test\"").await.unwrap();
273        provider.set_raw("app.version", "\"1.0\"").await.unwrap();
274        provider.set_raw("other", "\"value\"").await.unwrap();
275
276        let keys = provider.list_keys("app.").await.unwrap();
277        assert_eq!(keys.len(), 2);
278    }
279}