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::ProviderResult;
37use super::provider_trait::{ContextProvider, ProviderParams};
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 = crate::core::providers::intern(config.id.clone());
57        let display_name: &'static str = crate::core::providers::intern(config.name.clone());
58        let actions: Vec<&'static str> = config
59            .resources
60            .keys()
61            .map(|k| crate::core::providers::intern(k.clone()))
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 descriptor(&self) -> super::provider_trait::ConnectorDescriptor {
149        descriptor(
150            self.config
151                .resources
152                .values()
153                .any(|resource| !resource.method.eq_ignore_ascii_case("GET")),
154        )
155    }
156
157    fn reuse_binding(&self, action: &str, _params: &ProviderParams) -> Option<String> {
158        let resource = self.config.resources.get(action)?;
159        if !resource.method.eq_ignore_ascii_case("GET") {
160            return None;
161        }
162        let auth = ResolvedAuth::from_config(&self.config.auth).ok()?;
163        super::provenance::digest(&serde_json::json!({
164            "version": 1, "provider": self.id, "base_url": self.config.base_url,
165            "resource": resource, "credential_digest": auth.binding_digest(),
166        }))
167    }
168
169    fn reacquire_item(
170        &self,
171        action: &str,
172        params: &ProviderParams,
173        item_id: &str,
174    ) -> Result<ProviderResult, String> {
175        if self.reuse_binding(action, params).is_none() {
176            return Err("resource does not support read-only reauthorization".into());
177        }
178        let mut result = self.execute(action, params)?;
179        result.items.retain(|item| item.id == item_id);
180        Ok(result)
181    }
182
183    fn requires_auth(&self) -> bool {
184        !matches!(self.config.auth, schema::AuthConfig::None)
185    }
186
187    fn is_available(&self) -> bool {
188        ResolvedAuth::is_available(&self.config.auth)
189    }
190}
191
192/// Simple base64 encoder (avoids adding a base64 crate dependency).
193pub(crate) fn base64_encode(data: &[u8]) -> String {
194    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
195    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
196    for chunk in data.chunks(3) {
197        let b0 = chunk[0] as u32;
198        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
199        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
200        let triple = (b0 << 16) | (b1 << 8) | b2;
201        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
202        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
203        if chunk.len() > 1 {
204            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
205        } else {
206            result.push('=');
207        }
208        if chunk.len() > 2 {
209            result.push(CHARS[(triple & 0x3F) as usize] as char);
210        } else {
211            result.push('=');
212        }
213    }
214    result
215}
216
217pub(crate) fn descriptor(writes: bool) -> super::provider_trait::ConnectorDescriptor {
218    use super::provider_trait::{CapabilityLevel as L, ConnectorDescriptor as C};
219    C {
220        principal_binding: "configured auth (none, bearer, basic or header), digest-bound with base URL and resource",
221        search: L::Unsupported,
222        list: L::Supported,
223        get: L::Partial,
224        delta: L::Unsupported,
225        revalidate_acl: L::Supported,
226        revalidate_revision: L::Supported,
227        delete_event: L::Partial,
228        content_trust: C::UNTRUSTED,
229        write_surface: if writes {
230            "declared POST query resources (explicit configuration, empty body; write methods are refused); \
231             never re-checked or reused"
232        } else {
233            C::NO_WRITES
234        },
235        failure_policy: C::FAIL_CLOSED,
236        notes: "re-check re-runs the GET resource with the current credential and keeps only the stored id \
237                (an item that left the first page reads as no longer accessible); non-GET resources have no \
238                binding and are withheld from governed reuse",
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn sample_config() -> ProviderConfig {
247        toml::from_str(
248            r#"
249id = "test-api"
250name = "Test API"
251base_url = "https://api.example.com"
252cache_ttl_secs = 60
253
254[auth]
255type = "none"
256
257[resources.items]
258path = "/items"
259[resources.items.response]
260root = "data"
261[resources.items.response.mapping]
262id = "id"
263title = "name"
264body = "description"
265state = "status"
266"#,
267        )
268        .unwrap()
269    }
270
271    #[test]
272    fn config_provider_from_config() {
273        let provider = ConfigProvider::from_config(sample_config()).unwrap();
274        assert_eq!(provider.id(), "test-api");
275        assert_eq!(provider.display_name(), "Test API");
276        assert_eq!(provider.supported_actions(), &["items"]);
277        assert!(!provider.requires_auth());
278        assert!(provider.is_available());
279        assert_eq!(provider.cache_ttl_secs(), 60);
280    }
281
282    #[test]
283    fn config_provider_rejects_invalid() {
284        let mut cfg = sample_config();
285        cfg.id = String::new();
286        assert!(ConfigProvider::from_config(cfg).is_err());
287    }
288
289    #[test]
290    fn base64_encode_basic_auth() {
291        let encoded = base64_encode(b"user:pass");
292        assert_eq!(encoded, "dXNlcjpwYXNz");
293    }
294
295    #[test]
296    fn base64_encode_padding() {
297        assert_eq!(base64_encode(b"a"), "YQ==");
298        assert_eq!(base64_encode(b"ab"), "YWI=");
299        assert_eq!(base64_encode(b"abc"), "YWJj");
300    }
301
302    #[test]
303    fn build_interp_params_maps_all_fields() {
304        let params = ProviderParams {
305            project: Some("myproject".into()),
306            state: Some("open".into()),
307            limit: Some(10),
308            query: Some("search".into()),
309            id: Some("42".into()),
310        };
311        let map = ConfigProvider::build_interp_params(&params);
312        assert_eq!(map.get("project"), Some(&"myproject".into()));
313        assert_eq!(map.get("state"), Some(&"open".into()));
314        assert_eq!(map.get("limit"), Some(&"10".into()));
315        assert_eq!(map.get("query"), Some(&"search".into()));
316        assert_eq!(map.get("id"), Some(&"42".into()));
317    }
318}