1use crate::core::consolidation;
2use crate::core::providers::cache as provider_cache;
3use crate::core::providers::config::GitLabConfig;
4use crate::core::providers::provider_trait::ProviderParams;
5use crate::core::providers::registry::global_registry;
6use crate::core::providers::{gitlab, ProviderResult};
7use crate::server::tool_trait::ToolContext;
8
9pub fn handle(args: &serde_json::Map<String, serde_json::Value>, ctx: &ToolContext) -> String {
10 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
11
12 match action {
13 "discover" | "list" => handle_discover(ctx),
15 "status" => handle_status(ctx),
16 "refresh" => handle_refresh(args, ctx),
17 "configure" => handle_configure(args, ctx),
18
19 "query" => handle_registry_query(args, ctx),
21
22 "mcp_resources" => handle_mcp_resources(args, ctx),
24
25 "gitlab_issues" => handle_gitlab_issues(args),
27 "gitlab_issue" => handle_gitlab_issue(args),
28 "gitlab_mrs" => handle_gitlab_mrs(args),
29 "gitlab_pipelines" => handle_gitlab_pipelines(args),
30
31 _ => {
32 let available = "discover, list, status, refresh, configure, query, mcp_resources, \
33 gitlab_issues, gitlab_issue, gitlab_mrs, gitlab_pipelines";
34 format!("Unknown action: {action}. Available: {available}")
35 }
36 }
37}
38
39fn handle_discover(ctx: &ToolContext) -> String {
44 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
45 &ctx.project_root,
46 )));
47 let infos = global_registry().discover();
48 if infos.is_empty() {
49 return "No providers registered. Set GITHUB_TOKEN or GITLAB_TOKEN.".to_string();
50 }
51
52 let mut out = format!("Registered providers ({}):\n", infos.len());
53 for info in &infos {
54 let status = if info.available {
55 "ready"
56 } else {
57 "unavailable"
58 };
59 out.push_str(&format!(
60 " {} ({}) [{}] actions: {}\n",
61 info.id,
62 info.display_name,
63 status,
64 info.actions.join(", "),
65 ));
66 }
67 out
68}
69
70fn handle_status(ctx: &ToolContext) -> String {
75 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
76 &ctx.project_root,
77 )));
78
79 let infos = global_registry().discover();
80 let metrics = provider_cache::cache_metrics();
81
82 let mut out = String::new();
83
84 out.push_str(&format!("Provider Status ({} registered):\n", infos.len()));
86 for info in &infos {
87 let status = if info.available { "✓" } else { "✗" };
88 let auth = if info.requires_auth { " (auth)" } else { "" };
89 out.push_str(&format!(
90 " {status} {} — {} [ttl:{}s]{auth}\n",
91 info.id, info.display_name, info.cache_ttl_secs,
92 ));
93 }
94
95 out.push_str(&format!(
97 "\nCache: {} entries, {:.0}% hit rate ({} hits / {} misses)\n",
98 metrics.total_entries,
99 metrics.total_hit_rate() * 100.0,
100 metrics.total_hits,
101 metrics.total_misses,
102 ));
103
104 if !metrics.provider_stats.is_empty() {
105 out.push_str("Per-provider:\n");
106 for ps in &metrics.provider_stats {
107 let last = ps
108 .last_fetch
109 .and_then(|t| t.elapsed().ok())
110 .map_or_else(|| "never".into(), |d| format!("{}s ago", d.as_secs()));
111 out.push_str(&format!(
112 " {} — {} cached, {:.0}% hit rate, last fetch: {}\n",
113 ps.provider_id,
114 ps.entry_count,
115 ps.hit_rate() * 100.0,
116 last,
117 ));
118 }
119 }
120
121 out
122}
123
124fn handle_refresh(args: &serde_json::Map<String, serde_json::Value>, ctx: &ToolContext) -> String {
129 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
130 &ctx.project_root,
131 )));
132
133 let provider_id = args.get("provider").and_then(|v| v.as_str());
134 let resource = args.get("resource").and_then(|v| v.as_str());
135
136 let invalidated = if let Some(pid) = provider_id {
138 let count = provider_cache::invalidate_provider(pid);
139 format!("Invalidated {count} cached entries for '{pid}'")
140 } else {
141 let count = provider_cache::invalidate_all();
142 format!("Invalidated {count} cached entries (all providers)")
143 };
144
145 let mut out = format!("{invalidated}\n");
146
147 if let (Some(pid), Some(res)) = (provider_id, resource) {
149 let params = ProviderParams {
150 state: args.get("state").and_then(|v| v.as_str()).map(String::from),
151 limit: args
152 .get("limit")
153 .and_then(serde_json::Value::as_u64)
154 .map(|n| n as usize),
155 ..Default::default()
156 };
157
158 match global_registry().execute_as_chunks(pid, res, ¶ms) {
159 Ok(chunks) => {
160 consolidate_to_session(&chunks, ctx);
161 out.push_str(&format!(
162 "Re-fetched {pid}/{res}: {} items, consolidated to BM25+Graph+Knowledge\n",
163 chunks.len()
164 ));
165 }
166 Err(e) => out.push_str(&format!("Re-fetch failed: {e}\n")),
167 }
168 } else if let Some(pid) = provider_id {
169 let registry = global_registry();
171 if let Some(provider) = registry.get(pid) {
172 let mut total = 0;
173 for action in provider.supported_actions() {
174 let params = ProviderParams {
175 limit: Some(20),
176 ..Default::default()
177 };
178 match registry.execute_as_chunks(pid, action, ¶ms) {
179 Ok(chunks) => {
180 consolidate_to_session(&chunks, ctx);
181 total += chunks.len();
182 }
183 Err(e) => {
184 tracing::debug!("[ctx_provider] refresh {pid}/{action} failed: {e}");
185 }
186 }
187 }
188 out.push_str(&format!(
189 "Re-fetched all actions for '{pid}': {total} items consolidated\n"
190 ));
191 } else {
192 out.push_str(&format!("Provider '{pid}' not found\n"));
193 }
194 } else {
195 out.push_str("Specify provider= to also re-fetch data after cache invalidation\n");
196 }
197
198 out
199}
200
201fn handle_configure(
206 args: &serde_json::Map<String, serde_json::Value>,
207 ctx: &ToolContext,
208) -> String {
209 let sub = args
210 .get("resource")
211 .and_then(|v| v.as_str())
212 .unwrap_or("show");
213
214 match sub {
215 "paths" => {
216 let mut out = String::from("Provider config locations (checked in order):\n");
217 out.push_str(" Single-file (providers.toml):\n");
218 if let Some(config_dir) = dirs::config_dir() {
219 let p = config_dir.join("lean-ctx").join("providers.toml");
220 let exists = if p.exists() { " ✓" } else { "" };
221 out.push_str(&format!(" {}{exists}\n", p.display()));
222 }
223 if let Some(home) = dirs::home_dir() {
224 let p = home.join(".lean-ctx").join("providers.toml");
225 let exists = if p.exists() { " ✓" } else { "" };
226 out.push_str(&format!(" {}{exists}\n", p.display()));
227 }
228 let p = std::path::Path::new(&ctx.project_root)
229 .join(".lean-ctx")
230 .join("providers.toml");
231 let exists = if p.exists() { " ✓" } else { "" };
232 out.push_str(&format!(" {}{exists}\n", p.display()));
233
234 out.push_str(" Per-file (one provider per file):\n");
235 if let Some(config_dir) = dirs::config_dir() {
236 let p = config_dir.join("lean-ctx").join("providers");
237 let exists = if p.exists() { " ✓" } else { "" };
238 out.push_str(&format!(" {}/{exists}\n", p.display()));
239 }
240 let p = std::path::Path::new(&ctx.project_root)
241 .join(".lean-ctx")
242 .join("providers");
243 let exists = if p.exists() { " ✓" } else { "" };
244 out.push_str(&format!(" {}/{exists}\n", p.display()));
245
246 out.push_str("\nEnvironment variables:\n");
247 for (var, label) in [
248 ("GITHUB_TOKEN", "GitHub"),
249 ("GITLAB_TOKEN", "GitLab"),
250 ("JIRA_URL", "Jira"),
251 ("DATABASE_URL", "PostgreSQL"),
252 ] {
253 let set = if std::env::var(var).is_ok() {
254 "✓ set"
255 } else {
256 "✗ not set"
257 };
258 out.push_str(&format!(" {var} ({label}): {set}\n"));
259 }
260 out
261 }
262 "template" => String::from(
263 r#"# providers.toml — drop in ~/.config/lean-ctx/ or .lean-ctx/
264# Each [[providers]] entry registers a custom REST API as a context source.
265
266[[providers]]
267id = "linear"
268name = "Linear"
269base_url = "https://api.linear.app"
270cache_ttl_secs = 120
271
272[providers.auth]
273type = "bearer"
274token_env = "LINEAR_API_KEY"
275
276[providers.resources.issues]
277method = "POST"
278path = "/graphql"
279
280[providers.resources.issues.response]
281root = "data.issues.nodes"
282
283[providers.resources.issues.response.mapping]
284id = "id"
285title = "title"
286body = "description"
287state = "state.name"
288labels = "labels.nodes[].name"
289
290# --- Built-in providers (env vars only) ---
291# GitHub: set GITHUB_TOKEN
292# GitLab: set GITLAB_TOKEN
293# Jira: set JIRA_URL + JIRA_EMAIL + JIRA_TOKEN
294# Postgres: set DATABASE_URL or PGDATABASE
295"#,
296 ),
297 _ => {
298 let cfg = crate::core::config::Config::load();
299 let mut out = String::from("Provider configuration:\n");
300 out.push_str(&format!(" enabled: {}\n", cfg.providers.enabled));
301 out.push_str(&format!(" auto_index: {}\n", cfg.providers.auto_index));
302 out.push_str(&format!(
303 " github.enabled: {}\n",
304 cfg.providers.github.enabled
305 ));
306 out.push_str(&format!(
307 " gitlab.enabled: {}\n",
308 cfg.providers.gitlab.enabled
309 ));
310
311 if !cfg.providers.mcp_bridges.is_empty() {
312 out.push_str(&format!(
313 " mcp_bridges: {} configured\n",
314 cfg.providers.mcp_bridges.len()
315 ));
316 }
317
318 let discovered = crate::core::providers::config_provider::discovery::discover_configs(
319 Some(std::path::Path::new(&ctx.project_root)),
320 );
321 if !discovered.is_empty() {
322 out.push_str(&format!(
323 " config providers: {} discovered\n",
324 discovered.len()
325 ));
326 for d in &discovered {
327 out.push_str(&format!(
328 " {} — {}\n",
329 d.config.id,
330 d.source_path.display()
331 ));
332 }
333 }
334
335 out.push_str(
336 "\nUse resource=\"paths\" to see config file locations.\n\
337 Use resource=\"template\" to get a providers.toml template.\n",
338 );
339 out
340 }
341 }
342}
343
344fn handle_mcp_resources(
349 args: &serde_json::Map<String, serde_json::Value>,
350 ctx: &ToolContext,
351) -> String {
352 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
353 &ctx.project_root,
354 )));
355
356 let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
357 let registry = global_registry();
358 let mcp_providers: Vec<_> = registry
359 .discover()
360 .into_iter()
361 .filter(|p| p.id.starts_with("mcp:"))
362 .collect();
363
364 if mcp_providers.is_empty() {
365 return "No MCP bridges configured. Add [providers.mcp_bridges] to config.toml."
366 .to_string();
367 }
368
369 let mut out = format!("Available MCP bridges ({}):\n", mcp_providers.len());
370 for p in &mcp_providers {
371 let status = if p.available { "ready" } else { "unavailable" };
372 out.push_str(&format!(" {} ({}) [{}]\n", p.id, p.display_name, status));
373 }
374 out.push_str("\nUse provider=\"mcp:<name>\" to list resources from a specific bridge.");
375 return out;
376 };
377
378 let provider_id = if provider_id.starts_with("mcp:") {
379 provider_id.to_string()
380 } else {
381 format!("mcp:{provider_id}")
382 };
383
384 let params = ProviderParams {
385 limit: args
386 .get("limit")
387 .and_then(serde_json::Value::as_u64)
388 .map(|n| n as usize),
389 ..Default::default()
390 };
391
392 match global_registry().execute(&provider_id, "resources", ¶ms) {
393 Ok(result) => format_result(&result),
394 Err(e) => format!("Error: {e}"),
395 }
396}
397
398fn handle_registry_query(
403 args: &serde_json::Map<String, serde_json::Value>,
404 ctx: &ToolContext,
405) -> String {
406 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
407 &ctx.project_root,
408 )));
409
410 let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
411 return "Error: 'provider' is required for action=query".to_string();
412 };
413 let Some(resource) = args.get("resource").and_then(|v| v.as_str()) else {
414 return "Error: 'resource' is required for action=query".to_string();
415 };
416
417 let params = ProviderParams {
418 project: args
419 .get("project")
420 .and_then(|v| v.as_str())
421 .map(String::from),
422 state: args.get("state").and_then(|v| v.as_str()).map(String::from),
423 limit: args
424 .get("limit")
425 .and_then(serde_json::Value::as_u64)
426 .map(|n| n as usize),
427 query: args.get("query").and_then(|v| v.as_str()).map(String::from),
428 id: args.get("id").and_then(|v| v.as_str()).map(String::from),
429 };
430
431 let mode = args
432 .get("mode")
433 .and_then(|v| v.as_str())
434 .unwrap_or("compact");
435
436 match mode {
437 "chunks" => handle_registry_chunks(provider_id, resource, ¶ms, ctx),
438 _ => handle_registry_compact(provider_id, resource, ¶ms, ctx),
439 }
440}
441
442fn handle_registry_compact(
443 provider_id: &str,
444 resource: &str,
445 params: &ProviderParams,
446 ctx: &ToolContext,
447) -> String {
448 match global_registry().execute_as_chunks(provider_id, resource, params) {
449 Ok(chunks) => {
450 consolidate_to_session(&chunks, ctx);
451 let result = global_registry().execute(provider_id, resource, params);
452 match result {
453 Ok(r) => format_result(&r),
454 Err(_) => format_chunks_compact(&chunks, provider_id, resource),
455 }
456 }
457 Err(e) => format!("Error: {e}"),
458 }
459}
460
461fn handle_registry_chunks(
462 provider_id: &str,
463 resource: &str,
464 params: &ProviderParams,
465 ctx: &ToolContext,
466) -> String {
467 match global_registry().execute_as_chunks(provider_id, resource, params) {
468 Ok(chunks) => {
469 consolidate_to_session(&chunks, ctx);
470 let mut out = format!(
471 "{} content chunks from {provider_id}/{resource}:\n",
472 chunks.len()
473 );
474 for c in &chunks {
475 let refs = if c.references.is_empty() {
476 String::new()
477 } else {
478 format!(" refs:[{}]", c.references.join(","))
479 };
480 out.push_str(&format!(
481 " {} {:?} ({}tok){}\n",
482 c.file_path, c.kind, c.token_count, refs
483 ));
484 }
485 out
486 }
487 Err(e) => format!("Error: {e}"),
488 }
489}
490
491fn consolidate_to_session(chunks: &[crate::core::content_chunk::ContentChunk], ctx: &ToolContext) {
501 if chunks.is_empty() {
502 return;
503 }
504
505 let artifacts = consolidation::consolidate(chunks);
506 if artifacts.is_empty() {
507 return;
508 }
509
510 if let Some(cache_lock) = ctx.cache.as_ref() {
512 if let Ok(mut cache) = cache_lock.try_write() {
513 for entry in &artifacts.cache_entries {
514 cache.store(&entry.uri, &entry.content);
515 }
516 }
517 }
518
519 let external_count = artifacts
520 .bm25_chunks
521 .iter()
522 .filter(|c| c.is_external())
523 .count();
524 let edge_count = artifacts.edges.len();
525 let fact_count = artifacts.facts.len();
526 let cache_count = artifacts.cache_entries.len();
527
528 tracing::debug!(
529 "[ctx_provider] consolidated {} chunks → {} edges, {} facts, {} cached",
530 external_count,
531 edge_count,
532 fact_count,
533 cache_count,
534 );
535
536 let cfg = crate::core::config::Config::load();
538 if !cfg.providers.auto_index {
539 return;
540 }
541
542 let project_root = ctx.project_root.clone();
543 std::thread::spawn(move || {
544 apply_artifacts_to_stores(&artifacts, &project_root);
545 });
546}
547
548pub fn apply_artifacts_to_stores(
551 artifacts: &consolidation::ConsolidationArtifacts,
552 project_root: &str,
553) {
554 let root_path = std::path::Path::new(project_root);
555
556 if !artifacts.bm25_chunks.is_empty() {
558 let mut index = crate::core::bm25_index::BM25Index::load_or_build(root_path);
559 let ingested = index.ingest_content_chunks(artifacts.bm25_chunks.clone());
560 if ingested > 0 {
561 if let Err(e) = index.save(root_path) {
562 tracing::warn!("[ctx_provider] BM25 save failed: {e}");
563 } else {
564 tracing::info!("[ctx_provider] indexed {ingested} provider chunks into BM25");
565 }
566 }
567 }
568
569 if !artifacts.edges.is_empty() {
571 let mut graph = crate::core::graph_index::load_or_build(project_root);
572 let added =
573 crate::core::cross_source_edges::merge_edges(&mut graph.edges, artifacts.edges.clone());
574 if added > 0 {
575 if let Err(e) = graph.save() {
576 tracing::warn!("[ctx_provider] graph save failed: {e}");
577 } else {
578 tracing::info!("[ctx_provider] added {added} cross-source edges to graph");
579 }
580 }
581 }
582
583 if !artifacts.facts.is_empty() {
585 let policy = crate::core::memory_policy::MemoryPolicy::default();
586 let mut knowledge = crate::core::knowledge::ProjectKnowledge::load(project_root)
587 .unwrap_or_else(|| crate::core::knowledge::ProjectKnowledge::new(project_root));
588
589 let session_id = format!("provider-ingest-{}", chrono::Utc::now().timestamp());
590 for fact in &artifacts.facts {
591 knowledge.remember(
592 &fact.category,
593 &fact.key,
594 &fact.value,
595 &session_id,
596 fact.confidence,
597 &policy,
598 );
599 }
600
601 if let Err(e) = knowledge.save() {
602 tracing::warn!("[ctx_provider] knowledge save failed: {e}");
603 } else {
604 tracing::info!(
605 "[ctx_provider] remembered {} facts from provider data",
606 artifacts.facts.len()
607 );
608 }
609 }
610}
611
612fn format_chunks_compact(
613 chunks: &[crate::core::content_chunk::ContentChunk],
614 provider_id: &str,
615 resource: &str,
616) -> String {
617 let mut out = format!("{} results from {provider_id}/{resource}:\n", chunks.len());
618 for c in chunks {
619 out.push_str(&format!(
620 " #{} {}\n",
621 c.file_path.rsplit('/').next().unwrap_or("?"),
622 c.symbol_name
623 ));
624 }
625 out
626}
627
628fn handle_gitlab_issues(args: &serde_json::Map<String, serde_json::Value>) -> String {
633 let config = match GitLabConfig::from_env() {
634 Ok(c) => c,
635 Err(e) => return format!("Error: {e}"),
636 };
637 let state = args.get("state").and_then(|v| v.as_str());
638 let labels = args.get("labels").and_then(|v| v.as_str());
639 let limit = args
640 .get("limit")
641 .and_then(serde_json::Value::as_u64)
642 .map(|n| n as usize);
643
644 match gitlab::list_issues(&config, state, labels, limit) {
645 Ok(result) => format_result(&result),
646 Err(e) => format!("Error: {e}"),
647 }
648}
649
650fn handle_gitlab_issue(args: &serde_json::Map<String, serde_json::Value>) -> String {
651 let config = match GitLabConfig::from_env() {
652 Ok(c) => c,
653 Err(e) => return format!("Error: {e}"),
654 };
655 let iid = args
656 .get("iid")
657 .and_then(serde_json::Value::as_u64)
658 .unwrap_or(0);
659 if iid == 0 {
660 return "Error: iid is required for gitlab_issue".to_string();
661 }
662
663 match gitlab::show_issue(&config, iid) {
664 Ok(result) => format_result(&result),
665 Err(e) => format!("Error: {e}"),
666 }
667}
668
669fn handle_gitlab_mrs(args: &serde_json::Map<String, serde_json::Value>) -> String {
670 let config = match GitLabConfig::from_env() {
671 Ok(c) => c,
672 Err(e) => return format!("Error: {e}"),
673 };
674 let state = args.get("state").and_then(|v| v.as_str());
675 let limit = args
676 .get("limit")
677 .and_then(serde_json::Value::as_u64)
678 .map(|n| n as usize);
679
680 match gitlab::list_mrs(&config, state, limit) {
681 Ok(result) => format_result(&result),
682 Err(e) => format!("Error: {e}"),
683 }
684}
685
686fn handle_gitlab_pipelines(args: &serde_json::Map<String, serde_json::Value>) -> String {
687 let config = match GitLabConfig::from_env() {
688 Ok(c) => c,
689 Err(e) => return format!("Error: {e}"),
690 };
691 let status = args.get("status").and_then(|v| v.as_str());
692 let limit = args
693 .get("limit")
694 .and_then(serde_json::Value::as_u64)
695 .map(|n| n as usize);
696
697 match gitlab::list_pipelines(&config, status, limit) {
698 Ok(result) => format_result(&result),
699 Err(e) => format!("Error: {e}"),
700 }
701}
702
703fn format_result(result: &ProviderResult) -> String {
704 crate::core::redaction::redact_text_if_enabled(&result.format_compact())
705}