use std::path::{Path, PathBuf};
use super::schema::ProviderConfig;
pub fn discover_configs(project_root: Option<&Path>) -> Vec<DiscoveredConfig> {
let mut configs = Vec::new();
for dir in config_directories(project_root) {
if !dir.is_dir() {
continue;
}
match std::fs::read_dir(&dir) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
if let Some(cfg) = try_load_config(&path) {
configs.push(cfg);
}
}
}
Err(e) => {
tracing::debug!("[config_provider] failed to read {}: {e}", dir.display());
}
}
}
let mut seen = std::collections::HashMap::new();
for cfg in configs {
seen.insert(cfg.config.id.clone(), cfg);
}
let mut result: Vec<_> = seen.into_values().collect();
result.sort_by(|a, b| a.config.id.cmp(&b.config.id));
result
}
#[derive(Debug, Clone)]
pub struct DiscoveredConfig {
pub source_path: PathBuf,
pub config: ProviderConfig,
}
fn config_directories(project_root: Option<&Path>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(config_dir) = dirs::config_dir() {
dirs.push(config_dir.join("lean-ctx").join("providers"));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".lean-ctx").join("providers"));
}
if let Some(root) = project_root {
dirs.push(root.join(".lean-ctx").join("providers"));
}
dirs
}
fn try_load_config(path: &Path) -> Option<DiscoveredConfig> {
let ext = path.extension()?.to_str()?;
let content = std::fs::read_to_string(path).ok()?;
let config: ProviderConfig = match ext {
"toml" => toml::from_str(&content)
.map_err(|e| {
tracing::warn!("[config_provider] failed to parse {}: {e}", path.display());
e
})
.ok()?,
"json" => serde_json::from_str(&content)
.map_err(|e| {
tracing::warn!("[config_provider] failed to parse {}: {e}", path.display());
e
})
.ok()?,
_ => return None,
};
if let Err(e) = config.validate() {
tracing::warn!("[config_provider] invalid config {}: {e}", path.display());
return None;
}
tracing::info!(
"[config_provider] loaded '{}' from {}",
config.id,
path.display()
);
Some(DiscoveredConfig {
source_path: path.to_path_buf(),
config,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn discover_toml_config_from_project() {
let dir = tempfile::tempdir().unwrap();
let providers_dir = dir.path().join(".lean-ctx").join("providers");
fs::create_dir_all(&providers_dir).unwrap();
fs::write(
providers_dir.join("myapi.toml"),
r#"
id = "myapi"
name = "My API"
base_url = "https://api.example.com"
[auth]
type = "none"
[resources.items]
path = "/items"
[resources.items.response.mapping]
id = "id"
title = "name"
"#,
)
.unwrap();
let configs = discover_configs(Some(dir.path()));
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].config.id, "myapi");
assert_eq!(configs[0].config.name, "My API");
}
#[test]
fn discover_json_config() {
let dir = tempfile::tempdir().unwrap();
let providers_dir = dir.path().join(".lean-ctx").join("providers");
fs::create_dir_all(&providers_dir).unwrap();
fs::write(
providers_dir.join("notion.json"),
r#"{
"id": "notion",
"name": "Notion",
"base_url": "https://api.notion.com/v1",
"auth": {"type": "none"},
"resources": {
"pages": {
"path": "/search",
"method": "POST",
"response": {
"root": "results",
"mapping": {
"id": "id",
"title": "properties.Name.title[0].text.content"
}
}
}
}
}"#,
)
.unwrap();
let configs = discover_configs(Some(dir.path()));
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].config.id, "notion");
}
#[test]
fn discover_ignores_invalid_files() {
let dir = tempfile::tempdir().unwrap();
let providers_dir = dir.path().join(".lean-ctx").join("providers");
fs::create_dir_all(&providers_dir).unwrap();
fs::write(providers_dir.join("bad.toml"), "not valid toml {{{").unwrap();
fs::write(providers_dir.join("readme.md"), "# Providers").unwrap();
let configs = discover_configs(Some(dir.path()));
assert!(configs.is_empty());
}
#[test]
fn discover_deduplicates_by_id() {
let dir = tempfile::tempdir().unwrap();
let providers_dir = dir.path().join(".lean-ctx").join("providers");
fs::create_dir_all(&providers_dir).unwrap();
let cfg = r#"
id = "dupe"
name = "Dupe"
base_url = "https://example.com"
[auth]
type = "none"
[resources.data]
path = "/data"
[resources.data.response.mapping]
id = "id"
title = "title"
"#;
fs::write(providers_dir.join("dupe1.toml"), cfg).unwrap();
fs::write(providers_dir.join("dupe2.toml"), cfg).unwrap();
let configs = discover_configs(Some(dir.path()));
assert_eq!(configs.len(), 1);
}
#[test]
fn discover_empty_when_no_dir() {
let configs = discover_configs(Some(Path::new("/nonexistent/path/12345")));
assert!(configs.is_empty() || !configs.is_empty()); }
#[test]
fn config_directories_includes_project_root() {
let root = Path::new("/tmp/myproject");
let dirs = config_directories(Some(root));
assert!(dirs
.iter()
.any(|d| d.ends_with("myproject/.lean-ctx/providers")));
}
}