1use 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#[derive(Debug, Deserialize)]
28pub struct ListFilesRequest {
29 pub user_id: String,
30 #[serde(default)]
31 pub limit: Option<usize>,
32}
33
34#[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#[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#[derive(Debug, Serialize)]
54pub struct FileListResponse {
55 pub success: bool,
56 pub files: Vec<FileMemorySummary>,
57 pub total: usize,
58}
59
60#[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#[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#[derive(Debug, Serialize)]
89pub struct IndexResponse {
90 pub success: bool,
91 pub result: IndexingResult,
92 pub message: String,
93}
94
95#[derive(Debug, Serialize)]
97pub struct FileStatsResponse {
98 pub success: bool,
99 pub stats: FileMemoryStats,
100}
101
102pub 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
158pub 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
230pub 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
323pub 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
392pub 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}