Skip to main content

ainl_repo_intel/
lib.rs

1//! Normalize MCP `tools/list` style inventories into [`RepoIntelCapabilityProfile`](ainl_contracts::RepoIntelCapabilityProfile).
2
3use ainl_contracts::{
4    RepoIntelCapabilityProfile, RepoIntelCapabilityState, RepoIntelToolClass,
5    CONTRACT_SCHEMA_VERSION,
6};
7use std::collections::{HashMap, HashSet};
8
9/// Stable readiness check id for ArmaraOS / API (`readiness.checks`).
10pub const CHECK_ID_REPO_INTELLIGENCE: &str = "repo_intelligence";
11
12/// One tool row (namespaced name allowed).
13#[derive(Debug, Clone)]
14pub struct McpToolRow {
15    pub server_name: String,
16    pub tool_name: String,
17    pub description: String,
18}
19
20/// Heuristic: does this tool belong to class `c`?
21#[must_use]
22pub fn tool_class_matches(tool_name: &str, description: &str, class: RepoIntelToolClass) -> bool {
23    let n = format!("{} {}", tool_name, description).to_ascii_lowercase();
24    match class {
25        RepoIntelToolClass::Query => {
26            n.contains("query")
27                && (n.contains("search") || n.contains("hybrid") || n.contains("bm25"))
28                || n.contains("gitnexus") && n.contains("query")
29                || n.ends_with("query")
30        }
31        RepoIntelToolClass::Context => {
32            n.contains("context") && n.contains("symbol")
33                || n.contains("360")
34                || n.contains("callee")
35                || n.contains("caller")
36        }
37        RepoIntelToolClass::Impact => {
38            n.contains("impact") || n.contains("blast") || n.contains("radius")
39        }
40        RepoIntelToolClass::DetectChanges => {
41            n.contains("detect_changes")
42                || n.contains("detectchanges")
43                || (n.contains("diff") && n.contains("impact"))
44        }
45        RepoIntelToolClass::Cypher => n.contains("cypher"),
46    }
47}
48
49/// Build a profile from discovered tools.
50#[must_use]
51pub fn classify_inventory(rows: &[McpToolRow]) -> RepoIntelCapabilityProfile {
52    let mut classes: HashSet<RepoIntelToolClass> = HashSet::new();
53    for row in rows {
54        for c in [
55            RepoIntelToolClass::Query,
56            RepoIntelToolClass::Context,
57            RepoIntelToolClass::Impact,
58            RepoIntelToolClass::DetectChanges,
59            RepoIntelToolClass::Cypher,
60        ] {
61            if tool_class_matches(&row.tool_name, &row.description, c) {
62                classes.insert(c);
63            }
64        }
65    }
66
67    let mut class_vec: Vec<RepoIntelToolClass> = classes.into_iter().collect();
68    class_vec.sort_by_key(|c| format!("{c:?}"));
69
70    let has_impact = class_vec.contains(&RepoIntelToolClass::Impact);
71    let has_query_or_ctx = class_vec.contains(&RepoIntelToolClass::Query)
72        || class_vec.contains(&RepoIntelToolClass::Context);
73
74    let state = if has_impact && has_query_or_ctx {
75        RepoIntelCapabilityState::Ready
76    } else if !class_vec.is_empty() {
77        RepoIntelCapabilityState::Degraded
78    } else {
79        RepoIntelCapabilityState::Absent
80    };
81
82    let note = match state {
83        RepoIntelCapabilityState::Ready => None,
84        RepoIntelCapabilityState::Degraded => Some(
85            "Some repo-intelligence tools detected; prefer impact+context or impact+query for full workflow."
86                .into(),
87        ),
88        RepoIntelCapabilityState::Absent => Some(
89            "No repo-intelligence MCP tools detected (query/context/impact). Install a GitNexus-class indexer or similar."
90                .into(),
91        ),
92    };
93
94    RepoIntelCapabilityProfile {
95        schema_version: CONTRACT_SCHEMA_VERSION,
96        state,
97        classes_present: class_vec,
98        note,
99    }
100}
101
102/// Per-server summary for API payload.
103#[derive(Debug, Clone, serde::Serialize)]
104pub struct ServerRepoIntelSummary {
105    pub server_name: String,
106    pub profile: RepoIntelCapabilityProfile,
107}
108
109#[must_use]
110pub fn summarize_per_server(rows: &[McpToolRow]) -> Vec<ServerRepoIntelSummary> {
111    let mut by_server: HashMap<String, Vec<McpToolRow>> = HashMap::new();
112    for r in rows {
113        by_server
114            .entry(r.server_name.clone())
115            .or_default()
116            .push(r.clone());
117    }
118    let mut names: Vec<_> = by_server.keys().cloned().collect();
119    names.sort();
120    names
121        .into_iter()
122        .map(|name| ServerRepoIntelSummary {
123            profile: classify_inventory(by_server.get(&name).map(|v| v.as_slice()).unwrap_or(&[])),
124            server_name: name,
125        })
126        .collect()
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn gitnex_class_like_tools_ready() {
135        let rows = vec![
136            McpToolRow {
137                server_name: "gitnexus".into(),
138                tool_name: "mcp_gitnexus_query".into(),
139                description: "Hybrid search".into(),
140            },
141            McpToolRow {
142                server_name: "gitnexus".into(),
143                tool_name: "mcp_gitnexus_impact".into(),
144                description: "Blast radius".into(),
145            },
146        ];
147        let p = classify_inventory(&rows);
148        assert_eq!(p.state, RepoIntelCapabilityState::Ready);
149    }
150}