1use 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#[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
68pub 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}