Skip to main content

lean_ctx/core/providers/config_provider/
discovery.rs

1//! Auto-discovery of provider config files from well-known directories.
2//!
3//! Scans:
4//! 1. `~/.config/lean-ctx/providers/` — user-global providers
5//! 2. `.lean-ctx/providers/` — project-local providers
6//!
7//! Supports `.toml` and `.json` files.
8
9use std::path::{Path, PathBuf};
10
11use super::schema::ProviderConfig;
12
13/// Discover all provider config files from standard directories.
14pub 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    // Deduplicate: project-local configs override global ones (last wins).
37    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/// A config file that was successfully parsed.
47#[derive(Debug, Clone)]
48pub struct DiscoveredConfig {
49    pub source_path: PathBuf,
50    pub config: ProviderConfig,
51}
52
53/// Returns the list of directories to scan, in priority order.
54/// Later entries override earlier ones (project-local > global).
55fn config_directories(project_root: Option<&Path>) -> Vec<PathBuf> {
56    let mut dirs = Vec::new();
57
58    // 1. Global: ~/.config/lean-ctx/providers/
59    if let Some(config_dir) = dirs::config_dir() {
60        dirs.push(config_dir.join("lean-ctx").join("providers"));
61    }
62
63    // 2. Global alt: ~/.lean-ctx/providers/
64    if let Some(home) = dirs::home_dir() {
65        dirs.push(home.join(".lean-ctx").join("providers"));
66    }
67
68    // 3. Project-local: <project>/.lean-ctx/providers/
69    if let Some(root) = project_root {
70        dirs.push(root.join(".lean-ctx").join("providers"));
71    }
72
73    dirs
74}
75
76/// Try to load and parse a single config file.
77fn 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        // Invalid TOML
192        fs::write(providers_dir.join("bad.toml"), "not valid toml {{{").unwrap();
193        // Not a config file
194        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        // Should not crash, just return empty (the dir doesn't exist)
229        assert!(configs.is_empty() || !configs.is_empty()); // always true, we just check no panic
230    }
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}