Skip to main content

lean_ctx/core/providers/config_provider/
schema.rs

1//! Schema definitions for user-defined provider configs.
2//!
3//! Users drop a `.toml` or `.json` file into `~/.config/lean-ctx/providers/`
4//! (or `.lean-ctx/providers/` in a project) to register a custom data source.
5
6use std::collections::HashMap;
7
8use serde::Deserialize;
9
10/// Top-level provider configuration.
11#[derive(Debug, Clone, Deserialize)]
12pub struct ProviderConfig {
13    pub id: String,
14    pub name: String,
15    pub base_url: String,
16    #[serde(default)]
17    pub auth: AuthConfig,
18    #[serde(default = "default_cache_ttl")]
19    pub cache_ttl_secs: u64,
20    pub resources: HashMap<String, ResourceConfig>,
21}
22
23fn default_cache_ttl() -> u64 {
24    120
25}
26
27/// Authentication strategy for the external API.
28#[derive(Debug, Clone, Default, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum AuthConfig {
31    /// `Authorization: Bearer <token>` from an env var.
32    Bearer { token_env: String },
33    /// API key sent as a header or query parameter.
34    ApiKey {
35        key_env: String,
36        /// Header name (e.g. `X-Api-Key`). Mutually exclusive with `query_param`.
37        #[serde(default)]
38        header_name: Option<String>,
39        /// Query parameter name (e.g. `api_key`).
40        #[serde(default)]
41        query_param: Option<String>,
42    },
43    /// HTTP Basic auth from two env vars.
44    Basic {
45        username_env: String,
46        password_env: String,
47    },
48    /// Arbitrary header (e.g. `X-Custom-Token: <value>`).
49    Header {
50        header_name: String,
51        value_env: String,
52    },
53    /// No authentication required.
54    #[default]
55    None,
56}
57
58/// Configuration for a single API resource/endpoint.
59#[derive(Debug, Clone, Deserialize)]
60pub struct ResourceConfig {
61    /// HTTP method. Defaults to `"GET"`.
62    #[serde(default = "default_method")]
63    pub method: String,
64    /// URL path appended to `base_url` (supports `{param}` interpolation).
65    pub path: String,
66    /// Query parameters (`{limit}`, `{state}` etc. are interpolated from `ProviderParams`).
67    #[serde(default)]
68    pub query_params: HashMap<String, String>,
69    /// Extra headers for this resource.
70    #[serde(default)]
71    pub headers: HashMap<String, String>,
72    /// How to extract items from the JSON response.
73    pub response: ResponseConfig,
74}
75
76fn default_method() -> String {
77    "GET".into()
78}
79
80/// Describes how to map a JSON response to `ProviderItem`s.
81#[derive(Debug, Clone, Deserialize)]
82pub struct ResponseConfig {
83    /// Dot-notation path to the array of items (e.g. `"data.issues"`).
84    /// If `None`, the response root is treated as the array.
85    #[serde(default)]
86    pub root: Option<String>,
87    /// Maps `ProviderItem` fields to JSON paths within each array element.
88    pub mapping: FieldMapping,
89}
90
91/// Maps `ProviderItem` fields to dot-notation paths in the JSON response.
92///
93/// `id` and `title` are required; everything else is optional.
94#[derive(Debug, Clone, Deserialize)]
95pub struct FieldMapping {
96    pub id: String,
97    pub title: String,
98    #[serde(default)]
99    pub body: Option<String>,
100    #[serde(default)]
101    pub state: Option<String>,
102    #[serde(default)]
103    pub author: Option<String>,
104    #[serde(default)]
105    pub url: Option<String>,
106    /// Path to labels array. Each element is stringified.
107    #[serde(default)]
108    pub labels: Option<String>,
109    #[serde(default)]
110    pub created_at: Option<String>,
111    #[serde(default)]
112    pub updated_at: Option<String>,
113}
114
115impl ProviderConfig {
116    /// Validate that the config is well-formed.
117    pub fn validate(&self) -> Result<(), String> {
118        if self.id.is_empty() {
119            return Err("Provider config: 'id' must not be empty".into());
120        }
121        if self.base_url.is_empty() {
122            return Err("Provider config: 'base_url' must not be empty".into());
123        }
124        if self.resources.is_empty() {
125            return Err(format!(
126                "Provider '{}': must define at least one resource",
127                self.id
128            ));
129        }
130        for (name, res) in &self.resources {
131            if res.path.is_empty() {
132                return Err(format!(
133                    "Provider '{}' resource '{}': 'path' must not be empty",
134                    self.id, name
135                ));
136            }
137            let method = res.method.to_uppercase();
138            if !["GET", "POST", "PUT", "PATCH", "DELETE"].contains(&method.as_str()) {
139                return Err(format!(
140                    "Provider '{}' resource '{}': unsupported method '{}'",
141                    self.id, name, res.method
142                ));
143            }
144        }
145        Ok(())
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn parse_toml_bearer_config() {
155        let toml_str = r#"
156id = "linear"
157name = "Linear"
158base_url = "https://api.linear.app"
159
160[auth]
161type = "bearer"
162token_env = "LINEAR_API_KEY"
163
164[resources.issues]
165method = "GET"
166path = "/issues"
167
168[resources.issues.query_params]
169limit = "{limit}"
170state = "{state}"
171
172[resources.issues.response]
173root = "data"
174
175[resources.issues.response.mapping]
176id = "id"
177title = "title"
178body = "description"
179state = "state.name"
180author = "creator.name"
181url = "url"
182labels = "labels[].name"
183created_at = "createdAt"
184updated_at = "updatedAt"
185"#;
186        let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
187        assert_eq!(cfg.id, "linear");
188        assert_eq!(cfg.name, "Linear");
189        assert!(matches!(cfg.auth, AuthConfig::Bearer { .. }));
190        assert!(cfg.resources.contains_key("issues"));
191        let issues = &cfg.resources["issues"];
192        assert_eq!(issues.path, "/issues");
193        assert_eq!(issues.response.mapping.id, "id");
194        assert_eq!(issues.response.mapping.title, "title");
195        assert_eq!(issues.response.mapping.labels, Some("labels[].name".into()));
196        assert!(cfg.validate().is_ok());
197    }
198
199    #[test]
200    fn parse_json_api_key_config() {
201        let json_str = r#"{
202            "id": "notion",
203            "name": "Notion",
204            "base_url": "https://api.notion.com/v1",
205            "auth": {
206                "type": "api_key",
207                "key_env": "NOTION_TOKEN",
208                "header_name": "Notion-Version"
209            },
210            "resources": {
211                "pages": {
212                    "path": "/search",
213                    "method": "POST",
214                    "response": {
215                        "root": "results",
216                        "mapping": {
217                            "id": "id",
218                            "title": "properties.title.title[0].text.content"
219                        }
220                    }
221                }
222            }
223        }"#;
224        let cfg: ProviderConfig = serde_json::from_str(json_str).unwrap();
225        assert_eq!(cfg.id, "notion");
226        assert!(matches!(cfg.auth, AuthConfig::ApiKey { .. }));
227        assert!(cfg.validate().is_ok());
228    }
229
230    #[test]
231    fn parse_no_auth_config() {
232        let toml_str = r#"
233id = "public-api"
234name = "Public API"
235base_url = "https://api.example.com"
236
237[auth]
238type = "none"
239
240[resources.data]
241path = "/data"
242
243[resources.data.response.mapping]
244id = "uuid"
245title = "name"
246"#;
247        let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
248        assert!(matches!(cfg.auth, AuthConfig::None));
249        assert!(!cfg.resources["data"].query_params.contains_key("limit"));
250        assert!(cfg.validate().is_ok());
251    }
252
253    #[test]
254    fn validate_catches_empty_id() {
255        let cfg = ProviderConfig {
256            id: String::new(),
257            name: "Test".into(),
258            base_url: "https://example.com".into(),
259            auth: AuthConfig::None,
260            cache_ttl_secs: 120,
261            resources: HashMap::new(),
262        };
263        assert!(cfg.validate().is_err());
264    }
265
266    #[test]
267    fn validate_catches_no_resources() {
268        let cfg = ProviderConfig {
269            id: "test".into(),
270            name: "Test".into(),
271            base_url: "https://example.com".into(),
272            auth: AuthConfig::None,
273            cache_ttl_secs: 120,
274            resources: HashMap::new(),
275        };
276        let err = cfg.validate().unwrap_err();
277        assert!(err.contains("at least one resource"));
278    }
279
280    #[test]
281    fn parse_basic_auth_config() {
282        let toml_str = r#"
283id = "jira-custom"
284name = "Jira (Custom)"
285base_url = "https://mycompany.atlassian.net/rest/api/3"
286
287[auth]
288type = "basic"
289username_env = "JIRA_USER"
290password_env = "JIRA_TOKEN"
291
292[resources.issues]
293path = "/search"
294[resources.issues.query_params]
295jql = "project={project} ORDER BY updated DESC"
296maxResults = "{limit}"
297[resources.issues.response]
298root = "issues"
299[resources.issues.response.mapping]
300id = "key"
301title = "fields.summary"
302body = "fields.description"
303state = "fields.status.name"
304author = "fields.reporter.displayName"
305labels = "fields.labels"
306"#;
307        let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
308        assert!(matches!(cfg.auth, AuthConfig::Basic { .. }));
309        assert!(cfg.validate().is_ok());
310    }
311
312    #[test]
313    fn default_method_is_get() {
314        let toml_str = r#"
315id = "test"
316name = "Test"
317base_url = "https://example.com"
318
319[auth]
320type = "none"
321
322[resources.items]
323path = "/items"
324[resources.items.response.mapping]
325id = "id"
326title = "name"
327"#;
328        let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
329        assert_eq!(cfg.resources["items"].method, "GET");
330    }
331}