lean_ctx/core/providers/config_provider/
mod.rs1pub 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
40pub struct ConfigProvider {
42 id: &'static str,
43 display_name: &'static str,
44 actions: Vec<&'static str>,
45 config: ProviderConfig,
46}
47
48impl ConfigProvider {
49 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
157pub(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(¶ms);
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}