Skip to main content

shodh_memory/handlers/
files.rs

1//! File Memory Handlers
2//!
3//! Handlers for codebase file indexing and search.
4
5use axum::{
6    extract::{Path, Query, State},
7    response::Json,
8};
9use serde::{Deserialize, Serialize};
10use tracing::info;
11
12use super::state::MultiUserMemoryManager;
13use super::todos::TodoQuery;
14use super::types::MemoryEvent;
15use crate::errors::{AppError, ValidationErrorExt};
16use crate::memory::{FileMemoryStats, IndexingResult, ProjectId};
17use crate::validation;
18use std::sync::Arc;
19
20type AppState = Arc<MultiUserMemoryManager>;
21
22fn default_search_limit() -> usize {
23    10
24}
25
26/// Request for listing files
27#[derive(Debug, Deserialize)]
28pub struct ListFilesRequest {
29    pub user_id: String,
30    #[serde(default)]
31    pub limit: Option<usize>,
32}
33
34/// Request to scan/index a codebase
35#[derive(Debug, Deserialize)]
36pub struct IndexCodebaseRequest {
37    pub user_id: String,
38    pub codebase_path: String,
39    #[serde(default)]
40    pub force: bool,
41}
42
43/// Request to search files
44#[derive(Debug, Deserialize)]
45pub struct SearchFilesRequest {
46    pub user_id: String,
47    pub query: String,
48    #[serde(default = "default_search_limit")]
49    pub limit: usize,
50}
51
52/// Response for file list operations
53#[derive(Debug, Serialize)]
54pub struct FileListResponse {
55    pub success: bool,
56    pub files: Vec<FileMemorySummary>,
57    pub total: usize,
58}
59
60/// Summary of a file memory
61#[derive(Debug, Serialize)]
62pub struct FileMemorySummary {
63    pub id: String,
64    pub path: String,
65    pub absolute_path: String,
66    pub file_type: String,
67    pub summary: String,
68    pub key_items: Vec<String>,
69    pub access_count: u32,
70    pub last_accessed: String,
71    pub heat_score: u8,
72    pub size_bytes: u64,
73    pub line_count: usize,
74}
75
76/// Response for scan operation
77#[derive(Debug, Serialize)]
78pub struct ScanResponse {
79    pub success: bool,
80    pub total_files: usize,
81    pub eligible_files: usize,
82    pub skipped_files: usize,
83    pub limit_reached: bool,
84    pub message: String,
85}
86
87/// Response for index operation
88#[derive(Debug, Serialize)]
89pub struct IndexResponse {
90    pub success: bool,
91    pub result: IndexingResult,
92    pub message: String,
93}
94
95/// Response for file stats
96#[derive(Debug, Serialize)]
97pub struct FileStatsResponse {
98    pub success: bool,
99    pub stats: FileMemoryStats,
100}
101
102/// POST /api/projects/{project_id}/files - List files for a project
103pub async fn list_project_files(
104    State(state): State<AppState>,
105    Path(project_id): Path<String>,
106    Json(req): Json<ListFilesRequest>,
107) -> Result<Json<FileListResponse>, AppError> {
108    validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
109
110    let project = state
111        .todo_store
112        .find_project_by_name(&req.user_id, &project_id)
113        .map_err(AppError::Internal)?
114        .or_else(|| {
115            uuid::Uuid::parse_str(&project_id).ok().and_then(|uuid| {
116                state
117                    .todo_store
118                    .get_project(&req.user_id, &ProjectId(uuid))
119                    .ok()
120                    .flatten()
121            })
122        })
123        .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
124
125    let files = state
126        .file_store
127        .list_by_project(&req.user_id, &project.id, req.limit)
128        .map_err(AppError::Internal)?;
129
130    let total = files.len();
131    let summaries: Vec<FileMemorySummary> = files
132        .into_iter()
133        .map(|f| {
134            let heat_score = f.heat_score();
135            FileMemorySummary {
136                id: f.id.0.to_string(),
137                path: f.path,
138                absolute_path: f.absolute_path,
139                file_type: format!("{:?}", f.file_type),
140                summary: f.summary,
141                key_items: f.key_items,
142                access_count: f.access_count,
143                last_accessed: f.last_accessed.to_rfc3339(),
144                heat_score,
145                size_bytes: f.size_bytes,
146                line_count: f.line_count,
147            }
148        })
149        .collect();
150
151    Ok(Json(FileListResponse {
152        success: true,
153        files: summaries,
154        total,
155    }))
156}
157
158/// POST /api/projects/{project_id}/scan - Scan codebase
159pub async fn scan_project_codebase(
160    State(state): State<AppState>,
161    Path(project_id): Path<String>,
162    Json(req): Json<IndexCodebaseRequest>,
163) -> Result<Json<ScanResponse>, AppError> {
164    validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
165
166    let project = state
167        .todo_store
168        .find_project_by_name(&req.user_id, &project_id)
169        .map_err(AppError::Internal)?
170        .or_else(|| {
171            uuid::Uuid::parse_str(&project_id).ok().and_then(|uuid| {
172                state
173                    .todo_store
174                    .get_project(&req.user_id, &ProjectId(uuid))
175                    .ok()
176                    .flatten()
177            })
178        })
179        .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
180
181    let codebase_path = std::path::Path::new(&req.codebase_path);
182    if !codebase_path.exists() {
183        return Err(AppError::InvalidInput {
184            field: "codebase_path".to_string(),
185            reason: format!("Path does not exist: {}", req.codebase_path),
186        });
187    }
188    if !codebase_path.is_dir() {
189        return Err(AppError::InvalidInput {
190            field: "codebase_path".to_string(),
191            reason: format!("Path is not a directory: {}", req.codebase_path),
192        });
193    }
194
195    let scan_result = state
196        .file_store
197        .scan_codebase(codebase_path, None)
198        .map_err(AppError::Internal)?;
199
200    let message = if scan_result.limit_reached {
201        format!(
202            "Found {} eligible files (limit reached). {} files skipped.",
203            scan_result.eligible_files, scan_result.skipped_files
204        )
205    } else {
206        format!(
207            "Found {} eligible files. {} files skipped.",
208            scan_result.eligible_files, scan_result.skipped_files
209        )
210    };
211
212    info!(
213        user_id = %req.user_id,
214        project_id = %project.id.0,
215        path = %req.codebase_path,
216        eligible = scan_result.eligible_files,
217        "Scanned codebase"
218    );
219
220    Ok(Json(ScanResponse {
221        success: true,
222        total_files: scan_result.total_files,
223        eligible_files: scan_result.eligible_files,
224        skipped_files: scan_result.skipped_files,
225        limit_reached: scan_result.limit_reached,
226        message,
227    }))
228}
229
230/// POST /api/projects/{project_id}/index - Index codebase files
231pub async fn index_project_codebase(
232    State(state): State<AppState>,
233    Path(project_id): Path<String>,
234    Json(req): Json<IndexCodebaseRequest>,
235) -> Result<Json<IndexResponse>, AppError> {
236    validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
237
238    let mut project = state
239        .todo_store
240        .find_project_by_name(&req.user_id, &project_id)
241        .map_err(AppError::Internal)?
242        .or_else(|| {
243            uuid::Uuid::parse_str(&project_id).ok().and_then(|uuid| {
244                state
245                    .todo_store
246                    .get_project(&req.user_id, &ProjectId(uuid))
247                    .ok()
248                    .flatten()
249            })
250        })
251        .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
252
253    if project.codebase_indexed && !req.force {
254        return Err(AppError::InvalidInput {
255            field: "force".to_string(),
256            reason: "Codebase already indexed. Use force=true to re-index.".to_string(),
257        });
258    }
259
260    let codebase_path = std::path::Path::new(&req.codebase_path);
261    if !codebase_path.exists() {
262        return Err(AppError::InvalidInput {
263            field: "codebase_path".to_string(),
264            reason: format!("Path does not exist: {}", req.codebase_path),
265        });
266    }
267
268    if req.force && project.codebase_indexed {
269        state
270            .file_store
271            .delete_project_files(&req.user_id, &project.id)
272            .map_err(AppError::Internal)?;
273    }
274
275    let result = state
276        .file_store
277        .index_codebase(codebase_path, &project.id, &req.user_id, None)
278        .map_err(AppError::Internal)?;
279
280    project.codebase_path = Some(req.codebase_path.clone());
281    project.codebase_indexed = true;
282    project.codebase_indexed_at = Some(chrono::Utc::now());
283    project.codebase_file_count = result.indexed_files;
284
285    state
286        .todo_store
287        .store_project(&project)
288        .map_err(AppError::Internal)?;
289
290    let message = format!(
291        "Indexed {} files ({} skipped, {} errors)",
292        result.indexed_files,
293        result.skipped_files,
294        result.errors.len()
295    );
296
297    state.emit_event(MemoryEvent {
298        event_type: "CODEBASE_INDEXED".to_string(),
299        timestamp: chrono::Utc::now(),
300        user_id: req.user_id.clone(),
301        memory_id: Some(project.id.0.to_string()),
302        content_preview: Some(format!("{} files indexed", result.indexed_files)),
303        memory_type: Some("Codebase".to_string()),
304        importance: None,
305        count: Some(result.indexed_files),
306        results: None,
307    });
308
309    info!(
310        user_id = %req.user_id,
311        project_id = %project.id.0,
312        indexed = result.indexed_files,
313        "Indexed codebase"
314    );
315
316    Ok(Json(IndexResponse {
317        success: true,
318        result,
319        message,
320    }))
321}
322
323/// POST /api/projects/{project_id}/files/search - Search files
324pub async fn search_project_files(
325    State(state): State<AppState>,
326    Path(project_id): Path<String>,
327    Json(req): Json<SearchFilesRequest>,
328) -> Result<Json<FileListResponse>, AppError> {
329    validation::validate_user_id(&req.user_id).map_validation_err("user_id")?;
330
331    let project = state
332        .todo_store
333        .find_project_by_name(&req.user_id, &project_id)
334        .map_err(AppError::Internal)?
335        .or_else(|| {
336            uuid::Uuid::parse_str(&project_id).ok().and_then(|uuid| {
337                state
338                    .todo_store
339                    .get_project(&req.user_id, &ProjectId(uuid))
340                    .ok()
341                    .flatten()
342            })
343        })
344        .ok_or_else(|| AppError::ProjectNotFound(project_id.clone()))?;
345
346    let all_files = state
347        .file_store
348        .list_by_project(&req.user_id, &project.id, None)
349        .map_err(AppError::Internal)?;
350
351    let query_lower = req.query.to_lowercase();
352    let matching_files: Vec<_> = all_files
353        .into_iter()
354        .filter(|f| {
355            f.path.to_lowercase().contains(&query_lower)
356                || f.key_items
357                    .iter()
358                    .any(|k| k.to_lowercase().contains(&query_lower))
359                || f.summary.to_lowercase().contains(&query_lower)
360        })
361        .take(req.limit)
362        .collect();
363
364    let total = matching_files.len();
365    let summaries: Vec<FileMemorySummary> = matching_files
366        .into_iter()
367        .map(|f| {
368            let heat_score = f.heat_score();
369            FileMemorySummary {
370                id: f.id.0.to_string(),
371                path: f.path,
372                absolute_path: f.absolute_path,
373                file_type: format!("{:?}", f.file_type),
374                summary: f.summary,
375                key_items: f.key_items,
376                access_count: f.access_count,
377                last_accessed: f.last_accessed.to_rfc3339(),
378                heat_score,
379                size_bytes: f.size_bytes,
380                line_count: f.line_count,
381            }
382        })
383        .collect();
384
385    Ok(Json(FileListResponse {
386        success: true,
387        files: summaries,
388        total,
389    }))
390}
391
392/// GET /api/files/stats - Get file memory statistics
393pub async fn get_file_stats(
394    State(state): State<AppState>,
395    Query(query): Query<TodoQuery>,
396) -> Result<Json<FileStatsResponse>, AppError> {
397    validation::validate_user_id(&query.user_id).map_validation_err("user_id")?;
398
399    let stats = state
400        .file_store
401        .stats(&query.user_id)
402        .map_err(AppError::Internal)?;
403
404    Ok(Json(FileStatsResponse {
405        success: true,
406        stats,
407    }))
408}