Skip to main content

remembrall_server/tools/
graph.rs

1//! Graph tool parameter structs and implementation helpers.
2//!
3//! Covers: remembrall_index, remembrall_impact, remembrall_lookup_symbol, remembrall_tour.
4//! The `#[tool]` wrapper methods live in `lib.rs` (required by `#[tool_router]`).
5
6use std::collections::HashSet;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use rmcp::{ErrorData as McpError, model::*, schemars};
11use serde_json::json;
12use tokio::sync::Mutex;
13
14use remembrall_core::{
15    graph::{
16        store::GraphStore,
17        types::{Direction, SymbolType, TourStop},
18    },
19    parser::index_directory,
20};
21
22use crate::watcher;
23
24// ---------------------------------------------------------------------------
25// Parameter structs
26// ---------------------------------------------------------------------------
27
28#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
29pub struct IndexParams {
30    #[schemars(description = "Absolute path to the project root directory")]
31    pub path: String,
32    #[schemars(description = "Logical project name for namespacing")]
33    pub project: String,
34}
35
36#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
37pub struct ImpactParams {
38    #[schemars(description = "Name of the function, class, or method to analyze")]
39    pub symbol_name: String,
40    #[schemars(description = "One of: function, class, method, file")]
41    pub symbol_type: Option<String>,
42    #[schemars(description = "Filter to a specific project (as used in remembrall_index)")]
43    pub project: Option<String>,
44    #[schemars(description = "upstream (who calls me?), downstream (what do I call?), or both. Default: upstream")]
45    pub direction: Option<String>,
46    #[schemars(description = "How many levels deep to traverse (default 3, max 10)")]
47    pub max_depth: Option<i32>,
48}
49
50#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
51pub struct LookupParams {
52    #[schemars(description = "Symbol name or file stem to look up. Case-insensitive. Matches class/function names (e.g. 'UsersController') and file basenames without extension (e.g. 'users_controller' matches symbols in 'users_controller.rb').")]
53    pub name: String,
54    #[schemars(description = "Filter: function, class, method, file")]
55    pub symbol_type: Option<String>,
56    #[schemars(description = "Filter to a specific project (as used in remembrall_index)")]
57    pub project: Option<String>,
58}
59
60#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
61pub struct TourParams {
62    #[schemars(description = "Project name (must have been indexed first with remembrall_index)")]
63    pub project: String,
64    #[schemars(description = "Maximum number of stops in the tour (default 20)")]
65    pub limit: Option<usize>,
66}
67
68// ---------------------------------------------------------------------------
69// Logic helpers
70// ---------------------------------------------------------------------------
71
72pub async fn index_impl(
73    graph: &Arc<GraphStore>,
74    watched_dirs: &Arc<Mutex<HashSet<PathBuf>>>,
75    params: IndexParams,
76) -> Result<CallToolResult, McpError> {
77    let IndexParams { path, project } = params;
78
79    let graph_arc = Arc::clone(graph);
80    let path_clone = path.clone();
81    let project_clone = project.clone();
82
83    let index_result = tokio::task::spawn_blocking(move || {
84        index_directory(&path_clone, &project_clone, None)
85    })
86    .await
87    .map_err(|e| McpError::internal_error(format!("spawn_blocking failed: {e}"), None))?
88    .map_err(|e| McpError::internal_error(format!("index_directory failed: {e}"), None))?;
89
90    graph_arc
91        .remove_project(&project)
92        .await
93        .map_err(|e| McpError::internal_error(format!("remove_project failed: {e}"), None))?;
94
95    graph_arc
96        .upsert_symbols_batch(&index_result.symbols)
97        .await
98        .map_err(|e| McpError::internal_error(format!("upsert_symbols_batch failed: {e}"), None))?;
99
100    graph_arc
101        .add_relationships_batch(&index_result.relationships)
102        .await
103        .map_err(|e| McpError::internal_error(format!("add_relationships_batch failed: {e}"), None))?;
104
105    let symbols_stored = index_result.symbols.len() as u64;
106    let relationships_stored = index_result.relationships.len() as u64;
107
108    let canonical = std::path::Path::new(&path)
109        .canonicalize()
110        .unwrap_or_else(|_| PathBuf::from(&path));
111
112    let already_watching = {
113        let mut guard = watched_dirs.lock().await;
114        if guard.contains(&canonical) {
115            true
116        } else {
117            guard.insert(canonical.clone());
118            false
119        }
120    };
121
122    if !already_watching {
123        let watcher_graph = Arc::clone(graph);
124        let project_name = project.clone();
125        let watch_root = canonical.clone();
126
127        tokio::spawn(async move {
128            let fw = watcher::FileWatcher::new(watcher_graph);
129            fw.add_project(watch_root, project_name).await;
130            fw.run().await;
131        });
132
133        tracing::info!("background watcher started for {}", canonical.display());
134    }
135
136    let text = json!({
137        "path": path,
138        "project": project,
139        "files_parsed": index_result.files_parsed,
140        "files_skipped": index_result.files_skipped,
141        "symbols_stored": symbols_stored,
142        "relationships_stored": relationships_stored,
143        "watching": !already_watching,
144        "note": "Indexing complete. A background watcher is now keeping the graph up to date.",
145    })
146    .to_string();
147
148    Ok(CallToolResult::success(vec![Content::text(text)]))
149}
150
151pub async fn impact_impl(
152    graph: &Arc<GraphStore>,
153    params: ImpactParams,
154) -> Result<CallToolResult, McpError> {
155    let ImpactParams { symbol_name, symbol_type, project, direction, max_depth } = params;
156
157    let stype: Option<SymbolType> = symbol_type
158        .as_deref()
159        .map(|s| {
160            s.parse::<SymbolType>().map_err(|e: String| {
161                McpError::invalid_params(format!("invalid symbol_type: {e}"), None)
162            })
163        })
164        .transpose()?;
165
166    let symbols = graph
167        .find_symbol(&symbol_name, stype.as_ref(), project.as_deref())
168        .await
169        .map_err(|e| McpError::internal_error(format!("find_symbol failed: {e}"), None))?;
170
171    if symbols.is_empty() {
172        let text = json!({
173            "symbol_name": symbol_name,
174            "found": false,
175            "message": "Symbol not found in graph. Has the project been indexed?",
176        })
177        .to_string();
178        return Ok(CallToolResult::success(vec![Content::text(text)]));
179    }
180
181    let symbol = &symbols[0];
182
183    let dir = match direction.as_deref().unwrap_or("upstream") {
184        "downstream" => Direction::Downstream,
185        "both" => Direction::Both,
186        _ => Direction::Upstream,
187    };
188
189    let depth = max_depth.unwrap_or(3).min(10).max(1);
190
191    let results = graph
192        .impact_analysis(symbol.id, dir, depth)
193        .await
194        .map_err(|e| McpError::internal_error(format!("impact_analysis failed: {e}"), None))?;
195
196    let mut files: Vec<String> = results
197        .iter()
198        .map(|r| r.symbol.file_path.clone())
199        .collect::<std::collections::HashSet<_>>()
200        .into_iter()
201        .collect();
202    files.sort();
203
204    let affected: Vec<serde_json::Value> = results
205        .iter()
206        .map(|r| {
207            json!({
208                "name": r.symbol.name,
209                "type": r.symbol.symbol_type.to_string(),
210                "file": r.symbol.file_path,
211                "layer": r.symbol.layer,
212                "depth": r.depth,
213                "relationship": r.relationship.to_string(),
214                "confidence": r.confidence,
215            })
216        })
217        .collect();
218
219    let mut layers_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
220    if let Some(ref l) = symbol.layer {
221        layers_set.insert(l.clone());
222    }
223    for r in &results {
224        if let Some(ref l) = r.symbol.layer {
225            layers_set.insert(l.clone());
226        }
227    }
228    let layers_crossed: Vec<String> = layers_set.into_iter().collect();
229    let layer_crossing_count = layers_crossed.len();
230
231    let mut response = json!({
232        "symbol": symbol.name,
233        "file": symbol.file_path,
234        "layer": symbol.layer,
235        "direction": format!("{:?}", dir).to_lowercase(),
236        "affected_symbols": affected,
237        "affected_files": files,
238        "total_symbols": affected.len(),
239        "total_files": files.len(),
240        "layers_crossed": layers_crossed,
241        "layer_crossing_count": layer_crossing_count,
242    });
243
244    if layer_crossing_count >= 3 {
245        response["risk_note"] = json!(format!(
246            "This change crosses {} architectural layers - review carefully.",
247            layer_crossing_count
248        ));
249    }
250
251    Ok(CallToolResult::success(vec![Content::text(response.to_string())]))
252}
253
254pub async fn lookup_symbol_impl(
255    graph: &Arc<GraphStore>,
256    params: LookupParams,
257) -> Result<CallToolResult, McpError> {
258    let LookupParams { name, symbol_type, project } = params;
259
260    let stype: Option<SymbolType> = symbol_type
261        .as_deref()
262        .map(|s| {
263            s.parse::<SymbolType>().map_err(|e: String| {
264                McpError::invalid_params(format!("invalid symbol_type: {e}"), None)
265            })
266        })
267        .transpose()?;
268
269    let symbols = graph
270        .find_symbol(&name, stype.as_ref(), project.as_deref())
271        .await
272        .map_err(|e| McpError::internal_error(format!("find_symbol failed: {e}"), None))?;
273
274    let result: Vec<serde_json::Value> = symbols
275        .iter()
276        .map(|s| {
277            json!({
278                "id": s.id.to_string(),
279                "name": s.name,
280                "type": s.symbol_type.to_string(),
281                "file": s.file_path,
282                "start_line": s.start_line,
283                "end_line": s.end_line,
284                "language": s.language,
285                "project": s.project,
286                "signature": s.signature,
287            })
288        })
289        .collect();
290
291    let text = json!({ "symbols": result, "count": result.len() }).to_string();
292    Ok(CallToolResult::success(vec![Content::text(text)]))
293}
294
295pub async fn tour_impl(
296    graph: &Arc<GraphStore>,
297    params: TourParams,
298) -> Result<CallToolResult, McpError> {
299    let TourParams { project, limit } = params;
300    let limit = limit.unwrap_or(20).max(1).min(100);
301
302    let stops: Vec<TourStop> = graph
303        .generate_tour(&project, limit)
304        .await
305        .map_err(|e| McpError::internal_error(format!("generate_tour failed: {e}"), None))?;
306
307    if stops.is_empty() {
308        let text = json!({
309            "project": project,
310            "total_files": 0,
311            "tour_stops": 0,
312            "tour": [],
313            "message": "No files found for this project. Has it been indexed with remembrall_index?",
314        })
315        .to_string();
316        return Ok(CallToolResult::success(vec![Content::text(text)]));
317    }
318
319    let total_files = stops.last().map(|s| s.order).unwrap_or(0);
320
321    let tour: Vec<serde_json::Value> = stops
322        .iter()
323        .map(|stop| {
324            json!({
325                "order": stop.order,
326                "file": stop.file_path,
327                "language": stop.language,
328                "symbols": stop.symbols,
329                "imports_from": stop.imports_from,
330                "imported_by": stop.imported_by,
331                "reason": stop.reason,
332            })
333        })
334        .collect();
335
336    let text = json!({
337        "project": project,
338        "total_files": total_files,
339        "tour_stops": tour.len(),
340        "tour": tour,
341    })
342    .to_string();
343
344    Ok(CallToolResult::success(vec![Content::text(text)]))
345}