Skip to main content

lean_ctx/core/providers/
registry.rs

1//! Provider Registry — dynamic registration and discovery of context providers.
2//!
3//! Every external data source registers itself here. The registry provides:
4//!   - Dynamic provider lookup by ID
5//!   - Discovery (list all available providers and their actions)
6//!   - Chunking bridge: converts `ProviderResult` → `Vec<ContentChunk>`
7//!   - Health checks across all providers
8//!
9//! Follows the Neocortical Column metaphor: each registered provider is a
10//! processing column that converts its native format into the universal
11//! `ContentChunk` format for the shared cortical pipeline.
12
13use 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
21/// Central registry for all context providers.
22pub 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    /// Startup-only authority. Later built-in/config/WASM initialization cannot
63    /// replace this entry, even when it registers the same ID.
64    #[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    /// Reuse startup-selected authority without trusting a later registry entry.
92    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    /// Execute and convert results to ContentChunks for BM25/embedding ingest.
127    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    /// List all registered providers with their availability and actions.
151    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/// Discovery info for a single provider.
203#[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
214/// The connector capability matrix (doc 17) as published in
215/// `docs/contracts/connector-capability-matrix-v1.md`: one row per connector
216/// kind, rendered from the connectors' own declarations.
217pub 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
270// ---------------------------------------------------------------------------
271// Chunking bridge: ProviderResult → ContentChunks
272// ---------------------------------------------------------------------------
273
274fn 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
286/// Convert a `ProviderResult` into a list of `ContentChunk`s.
287pub 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
336// ---------------------------------------------------------------------------
337// Global singleton (matches existing pattern in providers/cache.rs)
338// ---------------------------------------------------------------------------
339
340static 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
347/// A built-in provider constructed from the current configuration, for
348/// re-authorizing an item it produced. Never a registry entry: a provider
349/// registered later under the same id must not answer for a stored origin.
350/// Only providers that implement `reacquire_item` are named here.
351pub(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        // A Windows checkout may hold the document with CRLF line endings.
372        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        // Shared reuse is never claimed without a current ACL re-check.
388        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(&registry.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}