lean_ctx/core/providers/config_provider/
discovery.rs1use std::path::{Path, PathBuf};
10
11use super::schema::ProviderConfig;
12
13pub fn discover_configs(project_root: Option<&Path>) -> Vec<DiscoveredConfig> {
15 let mut configs = Vec::new();
16
17 for dir in config_directories(project_root) {
18 if !dir.is_dir() {
19 continue;
20 }
21 match std::fs::read_dir(&dir) {
22 Ok(entries) => {
23 for entry in entries.flatten() {
24 let path = entry.path();
25 if let Some(cfg) = try_load_config(&path) {
26 configs.push(cfg);
27 }
28 }
29 }
30 Err(e) => {
31 tracing::debug!("[config_provider] failed to read {}: {e}", dir.display());
32 }
33 }
34 }
35
36 let mut seen = std::collections::HashMap::new();
38 for cfg in configs {
39 seen.insert(cfg.config.id.clone(), cfg);
40 }
41 let mut result: Vec<_> = seen.into_values().collect();
42 result.sort_by(|a, b| a.config.id.cmp(&b.config.id));
43 result
44}
45
46#[derive(Debug, Clone)]
48pub struct DiscoveredConfig {
49 pub source_path: PathBuf,
50 pub config: ProviderConfig,
51}
52
53fn config_directories(project_root: Option<&Path>) -> Vec<PathBuf> {
56 let mut dirs = Vec::new();
57
58 if let Some(config_dir) = dirs::config_dir() {
60 dirs.push(config_dir.join("lean-ctx").join("providers"));
61 }
62
63 if let Some(home) = dirs::home_dir() {
65 dirs.push(home.join(".lean-ctx").join("providers"));
66 }
67
68 if let Some(root) = project_root {
70 dirs.push(root.join(".lean-ctx").join("providers"));
71 }
72
73 dirs
74}
75
76fn try_load_config(path: &Path) -> Option<DiscoveredConfig> {
78 let ext = path.extension()?.to_str()?;
79 let content = std::fs::read_to_string(path).ok()?;
80
81 let config: ProviderConfig = match ext {
82 "toml" => toml::from_str(&content)
83 .map_err(|e| {
84 tracing::warn!("[config_provider] failed to parse {}: {e}", path.display());
85 e
86 })
87 .ok()?,
88 "json" => serde_json::from_str(&content)
89 .map_err(|e| {
90 tracing::warn!("[config_provider] failed to parse {}: {e}", path.display());
91 e
92 })
93 .ok()?,
94 _ => return None,
95 };
96
97 if let Err(e) = config.validate() {
98 tracing::warn!("[config_provider] invalid config {}: {e}", path.display());
99 return None;
100 }
101
102 tracing::info!(
103 "[config_provider] loaded '{}' from {}",
104 config.id,
105 path.display()
106 );
107
108 Some(DiscoveredConfig {
109 source_path: path.to_path_buf(),
110 config,
111 })
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::fs;
118
119 #[test]
120 fn discover_toml_config_from_project() {
121 let dir = tempfile::tempdir().unwrap();
122 let providers_dir = dir.path().join(".lean-ctx").join("providers");
123 fs::create_dir_all(&providers_dir).unwrap();
124
125 fs::write(
126 providers_dir.join("myapi.toml"),
127 r#"
128id = "myapi"
129name = "My API"
130base_url = "https://api.example.com"
131
132[auth]
133type = "none"
134
135[resources.items]
136path = "/items"
137[resources.items.response.mapping]
138id = "id"
139title = "name"
140"#,
141 )
142 .unwrap();
143
144 let configs = discover_configs(Some(dir.path()));
145 assert_eq!(configs.len(), 1);
146 assert_eq!(configs[0].config.id, "myapi");
147 assert_eq!(configs[0].config.name, "My API");
148 }
149
150 #[test]
151 fn discover_json_config() {
152 let dir = tempfile::tempdir().unwrap();
153 let providers_dir = dir.path().join(".lean-ctx").join("providers");
154 fs::create_dir_all(&providers_dir).unwrap();
155
156 fs::write(
157 providers_dir.join("notion.json"),
158 r#"{
159 "id": "notion",
160 "name": "Notion",
161 "base_url": "https://api.notion.com/v1",
162 "auth": {"type": "none"},
163 "resources": {
164 "pages": {
165 "path": "/search",
166 "method": "POST",
167 "response": {
168 "root": "results",
169 "mapping": {
170 "id": "id",
171 "title": "properties.Name.title[0].text.content"
172 }
173 }
174 }
175 }
176 }"#,
177 )
178 .unwrap();
179
180 let configs = discover_configs(Some(dir.path()));
181 assert_eq!(configs.len(), 1);
182 assert_eq!(configs[0].config.id, "notion");
183 }
184
185 #[test]
186 fn discover_ignores_invalid_files() {
187 let dir = tempfile::tempdir().unwrap();
188 let providers_dir = dir.path().join(".lean-ctx").join("providers");
189 fs::create_dir_all(&providers_dir).unwrap();
190
191 fs::write(providers_dir.join("bad.toml"), "not valid toml {{{").unwrap();
193 fs::write(providers_dir.join("readme.md"), "# Providers").unwrap();
195
196 let configs = discover_configs(Some(dir.path()));
197 assert!(configs.is_empty());
198 }
199
200 #[test]
201 fn discover_deduplicates_by_id() {
202 let dir = tempfile::tempdir().unwrap();
203 let providers_dir = dir.path().join(".lean-ctx").join("providers");
204 fs::create_dir_all(&providers_dir).unwrap();
205
206 let cfg = r#"
207id = "dupe"
208name = "Dupe"
209base_url = "https://example.com"
210[auth]
211type = "none"
212[resources.data]
213path = "/data"
214[resources.data.response.mapping]
215id = "id"
216title = "title"
217"#;
218 fs::write(providers_dir.join("dupe1.toml"), cfg).unwrap();
219 fs::write(providers_dir.join("dupe2.toml"), cfg).unwrap();
220
221 let configs = discover_configs(Some(dir.path()));
222 assert_eq!(configs.len(), 1);
223 }
224
225 #[test]
226 fn discover_empty_when_no_dir() {
227 let configs = discover_configs(Some(Path::new("/nonexistent/path/12345")));
228 assert!(configs.is_empty() || !configs.is_empty()); }
231
232 #[test]
233 fn config_directories_includes_project_root() {
234 let root = Path::new("/tmp/myproject");
235 let dirs = config_directories(Some(root));
236 assert!(dirs
237 .iter()
238 .any(|d| d.ends_with("myproject/.lean-ctx/providers")));
239 }
240}