1use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16use super::ProviderResult;
17use super::provider_trait::{ContextProvider, ProviderParams};
18use crate::core::bm25_index::ChunkKind;
19use crate::core::content_chunk::ContentChunk;
20
21pub struct ProviderRegistry {
23 providers: RwLock<HashMap<String, Entry>>,
24}
25
26struct Entry {
27 provider: Arc<dyn ContextProvider>,
28 pinned: bool,
29}
30
31impl std::fmt::Debug for ProviderRegistry {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 let count = self.providers.read().map(|p| p.len()).unwrap_or(0);
34 f.debug_struct("ProviderRegistry")
35 .field("providers_count", &count)
36 .finish()
37 }
38}
39
40impl ProviderRegistry {
41 pub fn new() -> Self {
42 Self {
43 providers: RwLock::new(HashMap::new()),
44 }
45 }
46
47 pub fn register(&self, provider: Arc<dyn ContextProvider>) {
48 let id = provider.id().to_string();
49 if let Ok(mut map) = self.providers.write() {
50 if !map.get(&id).is_some_and(|entry| entry.pinned) {
51 map.insert(
52 id,
53 Entry {
54 provider,
55 pinned: false,
56 },
57 );
58 }
59 }
60 }
61
62 #[cfg(any(unix, windows, test))]
65 pub(crate) fn pin(&self, provider: Arc<dyn ContextProvider>) -> Result<(), String> {
66 let mut map = self
67 .providers
68 .write()
69 .map_err(|_| "provider registry unavailable")?;
70 let id = provider.id().to_string();
71 if map.contains_key(&id) {
72 return Err("cannot install selected provider after registry initialization".into());
73 }
74 map.insert(
75 id,
76 Entry {
77 provider,
78 pinned: true,
79 },
80 );
81 Ok(())
82 }
83
84 pub fn get(&self, id: &str) -> Option<Arc<dyn ContextProvider>> {
85 self.providers
86 .read()
87 .ok()
88 .and_then(|map| map.get(id).map(|entry| Arc::clone(&entry.provider)))
89 }
90
91 pub(crate) fn get_pinned(&self, id: &str) -> Option<Arc<dyn ContextProvider>> {
93 self.providers.read().ok().and_then(|map| {
94 map.get(id)
95 .filter(|entry| entry.pinned)
96 .map(|entry| Arc::clone(&entry.provider))
97 })
98 }
99
100 pub fn execute(
101 &self,
102 provider_id: &str,
103 action: &str,
104 params: &ProviderParams,
105 ) -> Result<ProviderResult, String> {
106 let provider = self
107 .get(provider_id)
108 .ok_or_else(|| format!("Provider '{provider_id}' not registered"))?;
109
110 if !provider.is_available() {
111 return Err(format!(
112 "Provider '{provider_id}' is not available (check config/auth)"
113 ));
114 }
115
116 if !provider.supported_actions().contains(&action) {
117 return Err(format!(
118 "Provider '{provider_id}' does not support action '{action}'. Supported: {:?}",
119 provider.supported_actions()
120 ));
121 }
122
123 provider.execute(action, params)
124 }
125
126 pub fn execute_bound(
128 &self,
129 provider_id: &str,
130 action: &str,
131 params: &ProviderParams,
132 ) -> Result<super::provenance::BoundResult, String> {
133 let provider = self.get(provider_id).ok_or("provider unavailable")?;
134 if !provider.is_available() || !provider.supported_actions().contains(&action) {
135 return Err("provider action is unavailable".into());
136 }
137 super::provenance::BoundResult::execute(provider.as_ref(), action, params)
138 }
139
140 pub fn execute_as_chunks(
141 &self,
142 provider_id: &str,
143 action: &str,
144 params: &ProviderParams,
145 ) -> Result<Vec<ContentChunk>, String> {
146 let bound = self.execute_bound(provider_id, action, params)?;
147 Ok(bound.chunks(&bound.result))
148 }
149
150 pub fn discover(&self) -> Vec<ProviderInfo> {
152 let Ok(map) = self.providers.read() else {
153 return Vec::new();
154 };
155
156 let mut infos: Vec<ProviderInfo> = map
157 .values()
158 .map(|entry| &entry.provider)
159 .map(|p| ProviderInfo {
160 id: p.id().to_string(),
161 display_name: p.display_name().to_string(),
162 available: p.is_available(),
163 requires_auth: p.requires_auth(),
164 actions: p
165 .supported_actions()
166 .iter()
167 .map(std::string::ToString::to_string)
168 .collect(),
169 cache_ttl_secs: p.cache_ttl_secs(),
170 connector: p.descriptor(),
171 })
172 .collect();
173
174 infos.sort_by(|a, b| a.id.cmp(&b.id));
175 infos
176 }
177
178 pub fn provider_count(&self) -> usize {
179 self.providers.read().map_or(0, |m| m.len())
180 }
181
182 pub fn available_provider_ids(&self) -> Vec<String> {
183 self.providers
184 .read()
185 .map(|m| {
186 m.values()
187 .map(|entry| &entry.provider)
188 .filter(|p| p.is_available())
189 .map(|p| p.id().to_string())
190 .collect()
191 })
192 .unwrap_or_default()
193 }
194}
195
196impl Default for ProviderRegistry {
197 fn default() -> Self {
198 Self::new()
199 }
200}
201
202#[derive(Debug, Clone, serde::Serialize)]
204pub struct ProviderInfo {
205 pub id: String,
206 pub display_name: String,
207 pub available: bool,
208 pub requires_auth: bool,
209 pub actions: Vec<String>,
210 pub cache_ttl_secs: u64,
211 pub connector: super::provider_trait::ConnectorDescriptor,
212}
213
214pub fn capability_matrix_markdown() -> String {
218 use super::provider_trait::{CapabilityLevel, ConnectorDescriptor};
219 let level = |l: CapabilityLevel| match l {
220 CapabilityLevel::Supported => "yes",
221 CapabilityLevel::Partial => "partial",
222 CapabilityLevel::Unsupported => "no",
223 };
224 let rows: [(&str, ConnectorDescriptor); 8] = [
225 (
226 "GitLab (project configured)",
227 super::gitlab::descriptor(true),
228 ),
229 ("GitLab (no project)", super::gitlab::descriptor(false)),
230 ("GitHub", super::github::descriptor()),
231 ("Jira", super::jira::descriptor()),
232 ("MCP bridge", super::mcp_bridge::descriptor()),
233 ("Emspace", super::emspace::descriptor()),
234 (
235 "Config provider (GET resources)",
236 super::config_provider::descriptor(false),
237 ),
238 (
239 "Other providers (default)",
240 ConnectorDescriptor::list_only("token or none"),
241 ),
242 ];
243 let mut out = String::from(
244 "| Connector | search | list | get | delta | revalidate ACL | revalidate revision | delete event | shared reuse |\n\
245 |---|---|---|---|---|---|---|---|---|\n",
246 );
247 for (name, d) in &rows {
248 out.push_str(&format!(
249 "| {name} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
250 level(d.search),
251 level(d.list),
252 level(d.get),
253 level(d.delta),
254 level(d.revalidate_acl),
255 level(d.revalidate_revision),
256 level(d.delete_event),
257 if d.allows_shared_reuse() { "yes" } else { "no" },
258 ));
259 }
260 out.push('\n');
261 for (name, d) in &rows {
262 out.push_str(&format!(
263 "- **{name}** — principal: {}; content: {}; writes: {}; on failure: {}. {}\n",
264 d.principal_binding, d.content_trust, d.write_surface, d.failure_policy, d.notes
265 ));
266 }
267 out
268}
269
270fn action_to_chunk_kind(resource_type: &str) -> ChunkKind {
275 match resource_type {
276 "issues" => ChunkKind::Issue,
277 "merge_requests" | "pull_requests" | "prs" => ChunkKind::PullRequest,
278 "wikis" | "pages" => ChunkKind::WikiPage,
279 "schemas" | "tables" => ChunkKind::DbSchema,
280 "endpoints" | "routes" => ChunkKind::ApiEndpoint,
281 "tickets" => ChunkKind::Ticket,
282 _ => ChunkKind::ExternalOther,
283 }
284}
285
286pub fn result_to_chunks(result: &ProviderResult) -> Vec<ContentChunk> {
288 let kind = action_to_chunk_kind(&result.resource_type);
289
290 result
291 .items
292 .iter()
293 .map(|item| {
294 let body = item.body.as_deref().unwrap_or("");
295 let content = format!(
296 "#{} {}{}\n{}",
297 item.id,
298 item.title,
299 item.state
300 .as_ref()
301 .map(|s| format!(" [{s}]"))
302 .unwrap_or_default(),
303 body,
304 );
305
306 let mut references = crate::core::content_chunk::extract_file_references(&content);
307 if !item.references.is_empty() {
308 references.extend(item.references.iter().cloned());
309 references.sort();
310 references.dedup();
311 }
312
313 let metadata = serde_json::json!({
314 "state": item.state,
315 "author": item.author,
316 "created_at": item.created_at,
317 "updated_at": item.updated_at,
318 "url": item.url,
319 "labels": item.labels,
320 });
321
322 ContentChunk::from_provider(
323 &result.provider,
324 &result.resource_type,
325 &item.id,
326 &item.title,
327 kind.clone(),
328 content,
329 references,
330 Some(metadata),
331 )
332 })
333 .collect()
334}
335
336static GLOBAL_REGISTRY: std::sync::LazyLock<ProviderRegistry> =
341 std::sync::LazyLock::new(ProviderRegistry::new);
342
343pub fn global_registry() -> &'static ProviderRegistry {
344 &GLOBAL_REGISTRY
345}
346
347pub(crate) fn builtin_for_reuse(id: &str) -> Option<Arc<dyn ContextProvider>> {
352 match id {
353 "gitlab" => Some(Arc::new(super::gitlab::GitLabProvider::new())),
354 "github" => Some(Arc::new(super::github::GitHubProvider::new())),
355 "jira" => Some(Arc::new(super::jira::JiraProvider::new())),
356 _ => None,
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::core::providers::{ProviderItem, ProviderResult};
364
365 #[test]
366 fn the_published_capability_matrix_is_the_connectors_own_declaration() {
367 const BEGIN: &str = "<!-- connector-capability-matrix:begin -->\n";
368 const END: &str = "<!-- connector-capability-matrix:end -->";
369 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
370 .join("../docs/contracts/provider-framework-contract-v1.md");
371 let doc = std::fs::read_to_string(&path)
373 .unwrap()
374 .replace("\r\n", "\n");
375 let start = doc.find(BEGIN).unwrap() + BEGIN.len();
376 let end = doc.find(END).unwrap();
377 let rendered = capability_matrix_markdown();
378 if std::env::var_os("LEAN_CTX_UPDATE_GOLDEN").is_some() {
379 std::fs::write(&path, format!("{}{rendered}{}", &doc[..start], &doc[end..])).unwrap();
380 return;
381 }
382 assert_eq!(
383 &doc[start..end],
384 rendered,
385 "regenerate with LEAN_CTX_UPDATE_GOLDEN=1"
386 );
387 assert!(rendered.contains("| MCP bridge | no | yes | yes | no | no | no | no | no |"));
389 assert!(
390 rendered
391 .contains("| GitLab (no project) | partial | yes | yes | no | no | no | no | no |")
392 );
393 }
394
395 #[test]
396 fn result_to_chunks_preserves_provider_id() {
397 let result = ProviderResult {
398 provider: "github".into(),
399 resource_type: "issues".into(),
400 items: vec![ProviderItem {
401 id: "42".into(),
402 title: "Auth bug".into(),
403 state: Some("open".into()),
404 author: Some("dev".into()),
405 created_at: None,
406 updated_at: None,
407 url: Some("https://github.com/o/r/issues/42".into()),
408 labels: vec!["bug".into()],
409 body: Some("Fix in src/auth/handler.rs".into()),
410 ..Default::default()
411 }],
412 total_count: Some(1),
413 truncated: false,
414 };
415
416 let chunks = result_to_chunks(&result);
417 assert_eq!(chunks.len(), 1);
418 let c = &chunks[0];
419 assert_eq!(c.provider_id(), Some("github"));
420 assert_eq!(c.kind, ChunkKind::Issue);
421 assert!(c.file_path.contains("github://issues/42"));
422 assert!(c.references.contains(&"src/auth/handler.rs".to_string()));
423 }
424
425 #[test]
426 fn action_maps_to_correct_kind() {
427 assert_eq!(action_to_chunk_kind("issues"), ChunkKind::Issue);
428 assert_eq!(
429 action_to_chunk_kind("pull_requests"),
430 ChunkKind::PullRequest
431 );
432 assert_eq!(
433 action_to_chunk_kind("merge_requests"),
434 ChunkKind::PullRequest
435 );
436 assert_eq!(action_to_chunk_kind("wikis"), ChunkKind::WikiPage);
437 assert_eq!(action_to_chunk_kind("schemas"), ChunkKind::DbSchema);
438 assert_eq!(action_to_chunk_kind("endpoints"), ChunkKind::ApiEndpoint);
439 assert_eq!(action_to_chunk_kind("tickets"), ChunkKind::Ticket);
440 assert_eq!(action_to_chunk_kind("unknown"), ChunkKind::ExternalOther);
441 }
442
443 #[test]
444 fn registry_discover_returns_sorted() {
445 let reg = ProviderRegistry::new();
446 let infos = reg.discover();
447 assert!(infos.is_empty());
448 }
449
450 #[test]
451 fn registry_execute_unknown_provider_errors() {
452 let reg = ProviderRegistry::new();
453 let result = reg.execute("nonexistent", "issues", &ProviderParams::default());
454 assert!(result.is_err());
455 assert!(result.unwrap_err().contains("not registered"));
456 }
457
458 #[test]
459 fn selected_provider_survives_reinitialization_and_rejects_late_pin() {
460 use crate::core::providers::{config::GitLabConfig, gitlab::GitLabProvider};
461 let provider = || -> Arc<dyn ContextProvider> {
462 Arc::new(GitLabProvider::with_config(GitLabConfig {
463 host: "gitlab.example.test".into(),
464 token: "synthetic-registry-token".into(),
465 project_path: Some("5".into()),
466 }))
467 };
468 let registry = ProviderRegistry::new();
469 let selected = provider();
470 registry.pin(Arc::clone(&selected)).unwrap();
471 for _ in 0..3 {
472 registry.register(provider());
473 }
474 assert!(Arc::ptr_eq(®istry.get("gitlab").unwrap(), &selected));
475 assert!(registry.pin(provider()).is_err());
476 assert_eq!(registry.provider_count(), 1);
477 let ordinary = ProviderRegistry::new();
478 ordinary.register(provider());
479 assert!(ordinary.pin(provider()).is_err());
480 }
481}