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::{ProviderResult, gitlab};
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 match registry.get(pid) {
172 Some(provider) => {
173 let mut total = 0;
174 for action in provider.supported_actions() {
175 let params = ProviderParams {
176 limit: Some(20),
177 ..Default::default()
178 };
179 match registry.execute_as_chunks(pid, action, ¶ms) {
180 Ok(chunks) => {
181 consolidate_to_session(&chunks, ctx);
182 total += chunks.len();
183 }
184 Err(e) => {
185 tracing::debug!("[ctx_provider] refresh {pid}/{action} failed: {e}");
186 }
187 }
188 }
189 out.push_str(&format!(
190 "Re-fetched all actions for '{pid}': {total} items consolidated\n"
191 ));
192 }
193 _ => {
194 out.push_str(&format!("Provider '{pid}' not found\n"));
195 }
196 }
197 } else {
198 out.push_str("Specify provider= to also re-fetch data after cache invalidation\n");
199 }
200
201 out
202}
203
204fn handle_configure(
209 args: &serde_json::Map<String, serde_json::Value>,
210 ctx: &ToolContext,
211) -> String {
212 let sub = args
213 .get("resource")
214 .and_then(|v| v.as_str())
215 .unwrap_or("show");
216
217 match sub {
218 "paths" => {
219 let mut out = String::from("Provider config locations (checked in order):\n");
220 out.push_str(" Single-file (providers.toml):\n");
221 if let Some(config_dir) = dirs::config_dir() {
222 let p = config_dir.join("lean-ctx").join("providers.toml");
223 let exists = if p.exists() { " ✓" } else { "" };
224 out.push_str(&format!(" {}{exists}\n", p.display()));
225 }
226 if let Some(home) = dirs::home_dir() {
227 let p = home.join(".lean-ctx").join("providers.toml");
228 let exists = if p.exists() { " ✓" } else { "" };
229 out.push_str(&format!(" {}{exists}\n", p.display()));
230 }
231 let p = std::path::Path::new(&ctx.project_root)
232 .join(".lean-ctx")
233 .join("providers.toml");
234 let exists = if p.exists() { " ✓" } else { "" };
235 out.push_str(&format!(" {}{exists}\n", p.display()));
236
237 out.push_str(" Per-file (one provider per file):\n");
238 if let Some(config_dir) = dirs::config_dir() {
239 let p = config_dir.join("lean-ctx").join("providers");
240 let exists = if p.exists() { " ✓" } else { "" };
241 out.push_str(&format!(" {}/{exists}\n", p.display()));
242 }
243 let p = std::path::Path::new(&ctx.project_root)
244 .join(".lean-ctx")
245 .join("providers");
246 let exists = if p.exists() { " ✓" } else { "" };
247 out.push_str(&format!(" {}/{exists}\n", p.display()));
248
249 out.push_str("\nEnvironment variables:\n");
250 for (var, label) in [
251 ("GITHUB_TOKEN", "GitHub"),
252 ("GITLAB_TOKEN", "GitLab"),
253 ("JIRA_URL", "Jira"),
254 ("DATABASE_URL", "PostgreSQL"),
255 ] {
256 let set = if std::env::var(var).is_ok() {
257 "✓ set"
258 } else {
259 "✗ not set"
260 };
261 out.push_str(&format!(" {var} ({label}): {set}\n"));
262 }
263 out
264 }
265 "template" => String::from(
266 r#"# providers.toml — drop in ~/.config/lean-ctx/ or .lean-ctx/
267# Each [[providers]] entry registers a custom REST API as a context source.
268
269[[providers]]
270id = "linear"
271name = "Linear"
272base_url = "https://api.linear.app"
273cache_ttl_secs = 120
274
275[providers.auth]
276type = "bearer"
277token_env = "LINEAR_API_KEY"
278
279[providers.resources.issues]
280method = "POST"
281path = "/graphql"
282
283[providers.resources.issues.response]
284root = "data.issues.nodes"
285
286[providers.resources.issues.response.mapping]
287id = "id"
288title = "title"
289body = "description"
290state = "state.name"
291labels = "labels.nodes[].name"
292
293# --- Built-in providers (env vars only) ---
294# GitHub: set GITHUB_TOKEN
295# GitLab: set GITLAB_TOKEN
296# Jira: set JIRA_URL + JIRA_EMAIL + JIRA_TOKEN
297# Postgres: set DATABASE_URL or PGDATABASE
298"#,
299 ),
300 _ => {
301 let cfg = crate::core::config::Config::load();
302 let mut out = String::from("Provider configuration:\n");
303 out.push_str(&format!(" enabled: {}\n", cfg.providers.enabled));
304 out.push_str(&format!(" auto_index: {}\n", cfg.providers.auto_index));
305 out.push_str(&format!(
306 " github.enabled: {}\n",
307 cfg.providers.github.enabled
308 ));
309 out.push_str(&format!(
310 " gitlab.enabled: {}\n",
311 cfg.providers.gitlab.enabled
312 ));
313
314 if !cfg.providers.mcp_bridges.is_empty() {
315 out.push_str(&format!(
316 " mcp_bridges: {} configured\n",
317 cfg.providers.mcp_bridges.len()
318 ));
319 }
320
321 let discovered = crate::core::providers::config_provider::discovery::discover_configs(
322 Some(std::path::Path::new(&ctx.project_root)),
323 );
324 if !discovered.is_empty() {
325 out.push_str(&format!(
326 " config providers: {} discovered\n",
327 discovered.len()
328 ));
329 for d in &discovered {
330 out.push_str(&format!(
331 " {} — {}\n",
332 d.config.id,
333 d.source_path.display()
334 ));
335 }
336 }
337
338 out.push_str(
339 "\nUse resource=\"paths\" to see config file locations.\n\
340 Use resource=\"template\" to get a providers.toml template.\n",
341 );
342 out
343 }
344 }
345}
346
347fn handle_mcp_resources(
352 args: &serde_json::Map<String, serde_json::Value>,
353 ctx: &ToolContext,
354) -> String {
355 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
356 &ctx.project_root,
357 )));
358
359 let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
360 let registry = global_registry();
361 let mcp_providers: Vec<_> = registry
362 .discover()
363 .into_iter()
364 .filter(|p| p.id.starts_with("mcp:"))
365 .collect();
366
367 if mcp_providers.is_empty() {
368 return "No MCP bridges configured. Add [providers.mcp_bridges] to config.toml."
369 .to_string();
370 }
371
372 let mut out = format!("Available MCP bridges ({}):\n", mcp_providers.len());
373 for p in &mcp_providers {
374 let status = if p.available { "ready" } else { "unavailable" };
375 out.push_str(&format!(" {} ({}) [{}]\n", p.id, p.display_name, status));
376 }
377 out.push_str("\nUse provider=\"mcp:<name>\" to list resources from a specific bridge.");
378 return out;
379 };
380
381 let provider_id = if provider_id.starts_with("mcp:") {
382 provider_id.to_string()
383 } else {
384 format!("mcp:{provider_id}")
385 };
386
387 let params = ProviderParams {
388 limit: args
389 .get("limit")
390 .and_then(serde_json::Value::as_u64)
391 .map(|n| n as usize),
392 ..Default::default()
393 };
394
395 match global_registry().execute(&provider_id, "resources", ¶ms) {
396 Ok(result) => format_result(&result),
397 Err(e) => format!("Error: {e}"),
398 }
399}
400
401fn handle_registry_query(
406 args: &serde_json::Map<String, serde_json::Value>,
407 ctx: &ToolContext,
408) -> String {
409 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
410 &ctx.project_root,
411 )));
412
413 let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
414 return "Error: 'provider' is required for action=query".to_string();
415 };
416 let Some(resource) = args.get("resource").and_then(|v| v.as_str()) else {
417 return "Error: 'resource' is required for action=query".to_string();
418 };
419
420 let params = ProviderParams {
421 project: args
422 .get("project")
423 .and_then(|v| v.as_str())
424 .map(String::from),
425 state: args.get("state").and_then(|v| v.as_str()).map(String::from),
426 limit: args
427 .get("limit")
428 .and_then(serde_json::Value::as_u64)
429 .map(|n| n as usize),
430 query: args.get("query").and_then(|v| v.as_str()).map(String::from),
431 id: args.get("id").and_then(|v| v.as_str()).map(String::from),
432 };
433
434 let mode = args
435 .get("mode")
436 .and_then(|v| v.as_str())
437 .unwrap_or("compact");
438
439 match mode {
440 "chunks" => handle_registry_chunks(provider_id, resource, ¶ms, ctx),
441 _ => handle_registry_compact(provider_id, resource, ¶ms, ctx),
442 }
443}
444
445fn handle_registry_compact(
446 provider_id: &str,
447 resource: &str,
448 params: &ProviderParams,
449 ctx: &ToolContext,
450) -> String {
451 match global_registry().execute_as_chunks(provider_id, resource, params) {
452 Ok(chunks) => {
453 consolidate_to_session(&chunks, ctx);
454 let result = global_registry().execute(provider_id, resource, params);
455 match result {
456 Ok(r) => format_result(&r),
457 Err(_) => format_chunks_compact(&chunks, provider_id, resource),
458 }
459 }
460 Err(e) => format!("Error: {e}"),
461 }
462}
463
464fn handle_registry_chunks(
465 provider_id: &str,
466 resource: &str,
467 params: &ProviderParams,
468 ctx: &ToolContext,
469) -> String {
470 match global_registry().execute_as_chunks(provider_id, resource, params) {
471 Ok(chunks) => {
472 consolidate_to_session(&chunks, ctx);
473 let mut out = format!(
474 "{} content chunks from {provider_id}/{resource}:\n",
475 chunks.len()
476 );
477 for c in &chunks {
478 let refs = if c.references.is_empty() {
479 String::new()
480 } else {
481 format!(" refs:[{}]", c.references.join(","))
482 };
483 out.push_str(&format!(
484 " {} {:?} ({}tok){}\n",
485 c.file_path, c.kind, c.token_count, refs
486 ));
487 }
488 out
489 }
490 Err(e) => format!("Error: {e}"),
491 }
492}
493
494fn consolidate_to_session(chunks: &[crate::core::content_chunk::ContentChunk], ctx: &ToolContext) {
504 if chunks.is_empty() {
505 return;
506 }
507
508 let trusted = crate::core::workspace_trust::is_trusted(std::path::Path::new(&ctx.project_root));
513 let strict_screened: Vec<crate::core::content_chunk::ContentChunk>;
514 let chunks: &[crate::core::content_chunk::ContentChunk] = if trusted {
515 chunks
516 } else {
517 strict_screened = chunks
518 .iter()
519 .filter(|c| {
520 if c.is_external()
521 && let Some(reason) = crate::core::immune_detector::screen_strict(&c.content)
522 {
523 tracing::warn!(
524 target: "immune",
525 "untrusted workspace: quarantined {} ({reason})",
526 c.file_path
527 );
528 crate::core::introspect::tick("immune_detector");
529 return false;
530 }
531 true
532 })
533 .cloned()
534 .collect();
535 &strict_screened
536 };
537
538 let artifacts = consolidation::consolidate(chunks);
539 if artifacts.is_empty() {
540 return;
541 }
542
543 if let Some(cache_lock) = ctx.cache.as_ref()
545 && let Ok(mut cache) = cache_lock.try_write()
546 {
547 for entry in &artifacts.cache_entries {
548 cache.store(&entry.uri, &entry.content);
549 }
550 }
551
552 let external_count = artifacts
553 .bm25_chunks
554 .iter()
555 .filter(|c| c.is_external())
556 .count();
557 let edge_count = artifacts.edges.len();
558 let fact_count = artifacts.facts.len();
559 let cache_count = artifacts.cache_entries.len();
560
561 tracing::debug!(
562 "[ctx_provider] consolidated {} chunks → {} edges, {} facts, {} cached",
563 external_count,
564 edge_count,
565 fact_count,
566 cache_count,
567 );
568
569 let cfg = crate::core::config::Config::load();
571 if !cfg.providers.auto_index {
572 return;
573 }
574
575 let project_root = ctx.project_root.clone();
576 std::thread::spawn(move || {
577 apply_artifacts_to_stores(&artifacts, &project_root);
578 });
579}
580
581pub fn apply_artifacts_to_stores(
584 artifacts: &consolidation::ConsolidationArtifacts,
585 project_root: &str,
586) {
587 let root_path = std::path::Path::new(project_root);
588
589 if !artifacts.bm25_chunks.is_empty() {
591 let mut index = crate::core::bm25_index::BM25Index::load_or_build(root_path);
592 let ingested = index.ingest_content_chunks(artifacts.bm25_chunks.clone());
593 if ingested > 0 {
594 if let Err(e) = index.save(root_path) {
595 tracing::warn!("[ctx_provider] BM25 save failed: {e}");
596 } else {
597 tracing::info!("[ctx_provider] indexed {ingested} provider chunks into BM25");
598 }
599 }
600 }
601
602 if !artifacts.edges.is_empty() {
608 match crate::core::property_graph::CodeGraph::open(project_root) {
609 Ok(pg) => {
610 let mut added = 0usize;
611 for edge in &artifacts.edges {
612 if pg
613 .upsert_cross_source_edge(&edge.from, &edge.to, &edge.kind, edge.weight)
614 .is_ok()
615 {
616 added += 1;
617 }
618 }
619 tracing::info!("[ctx_provider] wrote {added} cross-source edges to property graph");
620 }
621 Err(e) => tracing::warn!("[ctx_provider] property graph open failed: {e}"),
622 }
623 }
624
625 if !artifacts.facts.is_empty() {
627 let policy = crate::core::memory_policy::MemoryPolicy::default();
628 let mut knowledge = crate::core::knowledge::ProjectKnowledge::load(project_root)
629 .unwrap_or_else(|| crate::core::knowledge::ProjectKnowledge::new(project_root));
630
631 let session_id = format!("provider-ingest-{}", chrono::Utc::now().timestamp());
632 for fact in &artifacts.facts {
633 knowledge.remember(
634 &fact.category,
635 &fact.key,
636 &fact.value,
637 &session_id,
638 fact.confidence,
639 &policy,
640 );
641 }
642
643 if let Err(e) = knowledge.save() {
644 tracing::warn!("[ctx_provider] knowledge save failed: {e}");
645 } else {
646 tracing::info!(
647 "[ctx_provider] remembered {} facts from provider data",
648 artifacts.facts.len()
649 );
650 }
651 }
652}
653
654fn format_chunks_compact(
655 chunks: &[crate::core::content_chunk::ContentChunk],
656 provider_id: &str,
657 resource: &str,
658) -> String {
659 let mut out = format!("{} results from {provider_id}/{resource}:\n", chunks.len());
660 for c in chunks {
661 out.push_str(&format!(
662 " #{} {}\n",
663 c.file_path.rsplit('/').next().unwrap_or("?"),
664 c.symbol_name
665 ));
666 }
667 out
668}
669
670fn handle_gitlab_issues(args: &serde_json::Map<String, serde_json::Value>) -> String {
675 let config = match GitLabConfig::from_env() {
676 Ok(c) => c,
677 Err(e) => return format!("Error: {e}"),
678 };
679 let state = args.get("state").and_then(|v| v.as_str());
680 let labels = args.get("labels").and_then(|v| v.as_str());
681 let limit = args
682 .get("limit")
683 .and_then(serde_json::Value::as_u64)
684 .map(|n| n as usize);
685
686 match gitlab::list_issues(&config, state, labels, limit) {
687 Ok(result) => format_result(&result),
688 Err(e) => format!("Error: {e}"),
689 }
690}
691
692fn handle_gitlab_issue(args: &serde_json::Map<String, serde_json::Value>) -> String {
693 let config = match GitLabConfig::from_env() {
694 Ok(c) => c,
695 Err(e) => return format!("Error: {e}"),
696 };
697 let iid = args
698 .get("iid")
699 .and_then(serde_json::Value::as_u64)
700 .unwrap_or(0);
701 if iid == 0 {
702 return "Error: iid is required for gitlab_issue".to_string();
703 }
704
705 match gitlab::show_issue(&config, iid) {
706 Ok(result) => format_result(&result),
707 Err(e) => format!("Error: {e}"),
708 }
709}
710
711fn handle_gitlab_mrs(args: &serde_json::Map<String, serde_json::Value>) -> String {
712 let config = match GitLabConfig::from_env() {
713 Ok(c) => c,
714 Err(e) => return format!("Error: {e}"),
715 };
716 let state = args.get("state").and_then(|v| v.as_str());
717 let limit = args
718 .get("limit")
719 .and_then(serde_json::Value::as_u64)
720 .map(|n| n as usize);
721
722 match gitlab::list_mrs(&config, state, limit) {
723 Ok(result) => format_result(&result),
724 Err(e) => format!("Error: {e}"),
725 }
726}
727
728fn handle_gitlab_pipelines(args: &serde_json::Map<String, serde_json::Value>) -> String {
729 let config = match GitLabConfig::from_env() {
730 Ok(c) => c,
731 Err(e) => return format!("Error: {e}"),
732 };
733 let status = args.get("status").and_then(|v| v.as_str());
734 let limit = args
735 .get("limit")
736 .and_then(serde_json::Value::as_u64)
737 .map(|n| n as usize);
738
739 match gitlab::list_pipelines(&config, status, limit) {
740 Ok(result) => format_result(&result),
741 Err(e) => format!("Error: {e}"),
742 }
743}
744
745fn format_result(result: &ProviderResult) -> String {
746 crate::core::redaction::redact_text_if_enabled(&result.format_compact())
747}