Skip to main content

lean_ctx/core/providers/config_provider/
mod.rs

1//! Config-based context providers.
2//!
3//! Users define providers via TOML/JSON files instead of writing Rust code.
4//! Drop a file into `~/.config/lean-ctx/providers/` or `.lean-ctx/providers/`
5//! to register a custom REST API as a first-class context source.
6//!
7//! Example TOML:
8//! ```toml
9//! id = "linear"
10//! name = "Linear"
11//! base_url = "https://api.linear.app"
12//!
13//! [auth]
14//! type = "bearer"
15//! token_env = "LINEAR_API_KEY"
16//!
17//! [resources.issues]
18//! path = "/issues"
19//! [resources.issues.response]
20//! root = "data"
21//! [resources.issues.response.mapping]
22//! id = "id"
23//! title = "title"
24//! body = "description"
25//! ```
26
27pub mod discovery;
28pub mod extract;
29pub mod http;
30pub mod schema;
31
32use std::collections::HashMap;
33
34use schema::ProviderConfig;
35
36use super::provider_trait::{ContextProvider, ProviderParams};
37use super::ProviderResult;
38use http::ResolvedAuth;
39
40/// A context provider dynamically created from a TOML/JSON config file.
41pub struct ConfigProvider {
42    id: &'static str,
43    display_name: &'static str,
44    actions: Vec<&'static str>,
45    config: ProviderConfig,
46}
47
48impl ConfigProvider {
49    /// Create a `ConfigProvider` from a parsed config.
50    ///
51    /// Leaks the id/name strings — acceptable because providers are registered
52    /// once at startup and live for the process lifetime.
53    pub fn from_config(config: ProviderConfig) -> Result<Self, String> {
54        config.validate()?;
55
56        let id: &'static str = Box::leak(config.id.clone().into_boxed_str());
57        let display_name: &'static str = Box::leak(config.name.clone().into_boxed_str());
58        let actions: Vec<&'static str> = config
59            .resources
60            .keys()
61            .map(|k| -> &'static str { Box::leak(k.clone().into_boxed_str()) })
62            .collect();
63
64        Ok(Self {
65            id,
66            display_name,
67            actions,
68            config,
69        })
70    }
71
72    fn build_interp_params(params: &ProviderParams) -> HashMap<String, String> {
73        let mut map = HashMap::new();
74        if let Some(ref p) = params.project {
75            map.insert("project".into(), p.clone());
76        }
77        if let Some(ref s) = params.state {
78            map.insert("state".into(), s.clone());
79        }
80        if let Some(limit) = params.limit {
81            map.insert("limit".into(), limit.to_string());
82        }
83        if let Some(ref q) = params.query {
84            map.insert("query".into(), q.clone());
85        }
86        if let Some(ref id) = params.id {
87            map.insert("id".into(), id.clone());
88        }
89        map
90    }
91}
92
93impl ContextProvider for ConfigProvider {
94    fn id(&self) -> &'static str {
95        self.id
96    }
97
98    fn display_name(&self) -> &'static str {
99        self.display_name
100    }
101
102    fn supported_actions(&self) -> &[&str] {
103        &self.actions
104    }
105
106    fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
107        let resource = self.config.resources.get(action).ok_or_else(|| {
108            format!(
109                "Provider '{}': unknown action '{}'. Available: {:?}",
110                self.id,
111                action,
112                self.config.resources.keys().collect::<Vec<_>>()
113            )
114        })?;
115
116        let auth = ResolvedAuth::from_config(&self.config.auth)?;
117        let interp_params = Self::build_interp_params(params);
118
119        let response_json =
120            http::execute_request(&self.config.base_url, resource, &auth, &interp_params)?;
121
122        let items_json =
123            extract::extract_items_array(&response_json, resource.response.root.as_deref())?;
124
125        let limit = params.limit.unwrap_or(50);
126        let total_count = items_json.len();
127        let truncated = total_count > limit;
128
129        let items: Vec<_> = items_json
130            .iter()
131            .take(limit)
132            .filter_map(|item| extract::map_item(item, &resource.response.mapping))
133            .collect();
134
135        Ok(ProviderResult {
136            provider: self.id.to_string(),
137            resource_type: action.to_string(),
138            items,
139            total_count: Some(total_count),
140            truncated,
141        })
142    }
143
144    fn cache_ttl_secs(&self) -> u64 {
145        self.config.cache_ttl_secs
146    }
147
148    fn requires_auth(&self) -> bool {
149        !matches!(self.config.auth, schema::AuthConfig::None)
150    }
151
152    fn is_available(&self) -> bool {
153        ResolvedAuth::is_available(&self.config.auth)
154    }
155}
156
157/// Simple base64 encoder (avoids adding a base64 crate dependency).
158pub(crate) fn base64_encode(data: &[u8]) -> String {
159    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
160    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
161    for chunk in data.chunks(3) {
162        let b0 = chunk[0] as u32;
163        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
164        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
165        let triple = (b0 << 16) | (b1 << 8) | b2;
166        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
167        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
168        if chunk.len() > 1 {
169            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
170        } else {
171            result.push('=');
172        }
173        if chunk.len() > 2 {
174            result.push(CHARS[(triple & 0x3F) as usize] as char);
175        } else {
176            result.push('=');
177        }
178    }
179    result
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn sample_config() -> ProviderConfig {
187        toml::from_str(
188            r#"
189id = "test-api"
190name = "Test API"
191base_url = "https://api.example.com"
192cache_ttl_secs = 60
193
194[auth]
195type = "none"
196
197[resources.items]
198path = "/items"
199[resources.items.response]
200root = "data"
201[resources.items.response.mapping]
202id = "id"
203title = "name"
204body = "description"
205state = "status"
206"#,
207        )
208        .unwrap()
209    }
210
211    #[test]
212    fn config_provider_from_config() {
213        let provider = ConfigProvider::from_config(sample_config()).unwrap();
214        assert_eq!(provider.id(), "test-api");
215        assert_eq!(provider.display_name(), "Test API");
216        assert_eq!(provider.supported_actions(), &["items"]);
217        assert!(!provider.requires_auth());
218        assert!(provider.is_available());
219        assert_eq!(provider.cache_ttl_secs(), 60);
220    }
221
222    #[test]
223    fn config_provider_rejects_invalid() {
224        let mut cfg = sample_config();
225        cfg.id = String::new();
226        assert!(ConfigProvider::from_config(cfg).is_err());
227    }
228
229    #[test]
230    fn base64_encode_basic_auth() {
231        let encoded = base64_encode(b"user:pass");
232        assert_eq!(encoded, "dXNlcjpwYXNz");
233    }
234
235    #[test]
236    fn base64_encode_padding() {
237        assert_eq!(base64_encode(b"a"), "YQ==");
238        assert_eq!(base64_encode(b"ab"), "YWI=");
239        assert_eq!(base64_encode(b"abc"), "YWJj");
240    }
241
242    #[test]
243    fn build_interp_params_maps_all_fields() {
244        let params = ProviderParams {
245            project: Some("myproject".into()),
246            state: Some("open".into()),
247            limit: Some(10),
248            query: Some("search".into()),
249            id: Some("42".into()),
250        };
251        let map = ConfigProvider::build_interp_params(&params);
252        assert_eq!(map.get("project"), Some(&"myproject".into()));
253        assert_eq!(map.get("state"), Some(&"open".into()));
254        assert_eq!(map.get("limit"), Some(&"10".into()));
255        assert_eq!(map.get("query"), Some(&"search".into()));
256        assert_eq!(map.get("id"), Some(&"42".into()));
257    }
258}