1use std::path::Path;
7use std::time::Instant;
8
9use colored::Colorize;
10use indicatif::{ProgressBar, ProgressStyle};
11
12use crate::config::SeekrConfig;
13use crate::embedder::batch::{BatchEmbedder, DummyEmbedder};
14use crate::embedder::traits::Embedder;
15use crate::error::SeekrError;
16use crate::index::incremental::IncrementalState;
17use crate::index::store::SeekrIndex;
18use crate::parser::chunker::chunk_file_from_path;
19use crate::parser::summary::generate_summary;
20use crate::parser::CodeChunk;
21use crate::scanner::filter::should_index_file;
22use crate::scanner::walker::walk_directory;
23use crate::search::ast_pattern::search_ast_pattern;
24use crate::search::fusion::{fuse_ast_only, fuse_semantic_only, fuse_text_only, rrf_fuse, rrf_fuse_three};
25use crate::search::semantic::{search_semantic, SemanticSearchOptions};
26use crate::search::text::{search_text_regex, TextSearchOptions};
27use crate::search::{SearchMode, SearchQuery, SearchResponse, SearchResult};
28
29pub fn cmd_index(
36 project_path: &str,
37 force: bool,
38 config: &SeekrConfig,
39 json_output: bool,
40) -> Result<(), SeekrError> {
41 let project_path = Path::new(project_path)
42 .canonicalize()
43 .unwrap_or_else(|_| Path::new(project_path).to_path_buf());
44
45 let start = Instant::now();
46 let index_dir = config.project_index_dir(&project_path);
47 let state_path = index_dir.join("incremental_state.json");
48
49 if !json_output {
51 eprintln!("{} Scanning project...", "→".blue());
52 }
53
54 let scan_result = walk_directory(&project_path, config)?;
55 let entries: Vec<_> = scan_result
56 .entries
57 .iter()
58 .filter(|e| should_index_file(&e.path, e.size, config.max_file_size))
59 .collect();
60
61 if !json_output {
62 eprintln!(
63 " {} {} files found ({} skipped)",
64 "✓".green(),
65 entries.len(),
66 scan_result.skipped,
67 );
68 }
69
70 let all_file_paths: Vec<_> = entries.iter().map(|e| e.path.clone()).collect();
72 let mut incr_state = if force {
73 if !json_output {
74 eprintln!(" {} Force mode: full rebuild", "ℹ".blue());
75 }
76 IncrementalState::default()
77 } else {
78 IncrementalState::load(&state_path).unwrap_or_default()
79 };
80
81 let changes = incr_state.detect_changes(&all_file_paths);
82 let files_to_process = if force {
83 all_file_paths.clone()
84 } else {
85 changes.changed.clone()
86 };
87
88 let mut existing_index = if !force {
90 SeekrIndex::load(&index_dir).ok()
91 } else {
92 None
93 };
94
95 if !changes.deleted.is_empty() {
96 if let Some(ref mut idx) = existing_index {
97 let removed_ids = incr_state.apply_deletions(&changes.deleted);
98 idx.remove_chunks(&removed_ids);
99 if !json_output {
100 eprintln!(
101 " {} Removed {} chunks from {} deleted files",
102 "✓".green(),
103 removed_ids.len(),
104 changes.deleted.len(),
105 );
106 }
107 }
108 }
109
110 if !force && files_to_process.is_empty() && changes.deleted.is_empty() {
111 if !json_output {
112 eprintln!(
113 "{} Index is up to date ({} files unchanged).",
114 "✓".green(),
115 changes.unchanged.len(),
116 );
117 }
118 if json_output {
119 let status = serde_json::json!({
120 "status": "up_to_date",
121 "project": project_path.display().to_string(),
122 "unchanged_files": changes.unchanged.len(),
123 });
124 println!("{}", serde_json::to_string_pretty(&status).unwrap_or_default());
125 }
126 return Ok(());
127 }
128
129 if !json_output && !force {
130 eprintln!(
131 " {} {} changed, {} unchanged, {} deleted",
132 "ℹ".blue(),
133 files_to_process.len(),
134 changes.unchanged.len(),
135 changes.deleted.len(),
136 );
137 }
138
139 if !json_output {
141 eprintln!("{} Parsing source files...", "→".blue());
142 }
143
144 let pb = if !json_output {
145 let pb = ProgressBar::new(files_to_process.len() as u64);
146 pb.set_style(
147 ProgressStyle::with_template(" {bar:40.cyan/blue} {pos}/{len} {msg}")
148 .unwrap()
149 .progress_chars("██░"),
150 );
151 Some(pb)
152 } else {
153 None
154 };
155
156 let mut new_chunks: Vec<CodeChunk> = Vec::new();
157 let mut parsed_files = 0;
158
159 if let Some(ref mut idx) = existing_index {
161 for file_path in &files_to_process {
162 let old_chunk_ids = incr_state.chunk_ids_for_file(file_path);
163 if !old_chunk_ids.is_empty() {
164 idx.remove_chunks(&old_chunk_ids);
165 }
166 }
167 }
168
169 for file_path in &files_to_process {
170 match chunk_file_from_path(file_path) {
171 Ok(Some(parse_result)) => {
172 new_chunks.extend(parse_result.chunks);
173 parsed_files += 1;
174 }
175 Ok(None) => {}
176 Err(e) => {
177 tracing::debug!(path = %file_path.display(), error = %e, "Failed to parse file");
178 }
179 }
180
181 if let Some(ref pb) = pb {
182 pb.inc(1);
183 }
184 }
185
186 if let Some(pb) = pb {
187 pb.finish_and_clear();
188 }
189
190 if !json_output {
191 eprintln!(
192 " {} {} new chunks from {} files",
193 "✓".green(),
194 new_chunks.len(),
195 parsed_files,
196 );
197 }
198
199 if new_chunks.is_empty() && existing_index.is_none() {
200 if !json_output {
201 eprintln!("{} No code chunks found. Nothing to index.", "⚠".yellow());
202 }
203 return Ok(());
204 }
205
206 let summaries: Vec<String> = new_chunks
208 .iter()
209 .map(|chunk| generate_summary(chunk))
210 .collect();
211
212 if !json_output && !new_chunks.is_empty() {
214 eprintln!("{} Generating embeddings...", "→".blue());
215 }
216
217 let embeddings = if new_chunks.is_empty() {
218 Vec::new()
219 } else {
220 match create_embedder(config) {
221 Ok(embedder) => {
222 let batch = BatchEmbedder::new(embedder, config.embedding.batch_size);
223 let pb_embed = if !json_output {
224 let pb = ProgressBar::new(summaries.len() as u64);
225 pb.set_style(
226 ProgressStyle::with_template(" {bar:40.green/blue} {pos}/{len} embeddings")
227 .unwrap()
228 .progress_chars("██░"),
229 );
230 Some(pb)
231 } else {
232 None
233 };
234
235 let result = batch.embed_all_with_progress(&summaries, |completed, _total| {
236 if let Some(ref pb) = pb_embed {
237 pb.set_position(completed as u64);
238 }
239 })?;
240
241 if let Some(pb) = pb_embed {
242 pb.finish_and_clear();
243 }
244
245 result
246 }
247 Err(e) => {
248 tracing::warn!("ONNX embedder unavailable ({}), using dummy embedder", e);
249 if !json_output {
250 eprintln!(
251 " {} ONNX model unavailable, using placeholder embeddings",
252 "⚠".yellow()
253 );
254 }
255 let dummy = DummyEmbedder::new(384);
256 let batch = BatchEmbedder::new(dummy, config.embedding.batch_size);
257 batch.embed_all(&summaries)?
258 }
259 }
260 };
261
262 let embedding_dim = embeddings.first().map(|e: &Vec<f32>| e.len())
263 .or_else(|| existing_index.as_ref().map(|idx| idx.embedding_dim))
264 .unwrap_or(384);
265
266 if !json_output && !new_chunks.is_empty() {
267 eprintln!(
268 " {} {} embeddings generated (dim={})",
269 "✓".green(),
270 embeddings.len(),
271 embedding_dim,
272 );
273 }
274
275 if !json_output {
277 eprintln!("{} Building index...", "→".blue());
278 }
279
280 let index = if let Some(mut idx) = existing_index {
281 for (chunk, embedding) in new_chunks.iter().zip(embeddings.iter()) {
283 let text_tokens = crate::index::store::tokenize_for_index_pub(&chunk.body);
284 let entry = crate::index::IndexEntry {
285 chunk_id: chunk.id,
286 embedding: embedding.clone(),
287 text_tokens,
288 };
289 idx.add_entry(entry, chunk.clone());
290 }
291 idx
292 } else {
293 SeekrIndex::build_from(&new_chunks, &embeddings, embedding_dim)
294 };
295
296 index.save(&index_dir)?;
298
299 for file_path in &files_to_process {
301 let chunk_ids: Vec<u64> = new_chunks
302 .iter()
303 .filter(|c| c.file_path == *file_path)
304 .map(|c| c.id)
305 .collect();
306 if let Ok(content) = std::fs::read(file_path) {
307 incr_state.update_file(file_path.clone(), &content, chunk_ids);
308 }
309 }
310 let _ = incr_state.save(&state_path);
311
312 let elapsed = start.elapsed();
313
314 if json_output {
315 let status = serde_json::json!({
316 "status": "ok",
317 "project": project_path.display().to_string(),
318 "chunks": index.chunk_count,
319 "files_parsed": parsed_files,
320 "embedding_dim": embedding_dim,
321 "incremental": !force,
322 "changed_files": files_to_process.len(),
323 "deleted_files": changes.deleted.len(),
324 "index_dir": index_dir.display().to_string(),
325 "duration_ms": elapsed.as_millis(),
326 });
327 println!("{}", serde_json::to_string_pretty(&status).unwrap_or_default());
328 } else {
329 eprintln!(
330 " {} Index built: {} chunks in {:.1}s{}",
331 "✓".green(),
332 index.chunk_count,
333 elapsed.as_secs_f64(),
334 if !force { " (incremental)" } else { "" },
335 );
336 eprintln!(
337 " {} Saved to {}",
338 "✓".green(),
339 index_dir.display(),
340 );
341 }
342
343 Ok(())
344}
345
346pub fn cmd_search(
348 query: &str,
349 mode: &str,
350 top_k: usize,
351 project_path: &str,
352 config: &SeekrConfig,
353 json_output: bool,
354) -> Result<(), SeekrError> {
355 let project_path = Path::new(project_path)
356 .canonicalize()
357 .unwrap_or_else(|_| Path::new(project_path).to_path_buf());
358
359 let start = Instant::now();
360
361 let search_mode: SearchMode = mode.parse().map_err(|e: String| {
363 SeekrError::Search(crate::error::SearchError::InvalidRegex(e))
364 })?;
365
366 let index_dir = config.project_index_dir(&project_path);
368 let index = SeekrIndex::load(&index_dir).map_err(|e| {
369 tracing::error!(
370 "Failed to load index from {}. Run `seekr-code index` first.",
371 index_dir.display()
372 );
373 e
374 })?;
375
376 let fused_results = match &search_mode {
378 SearchMode::Text => {
379 let options = TextSearchOptions {
380 case_sensitive: false,
381 context_lines: config.search.context_lines,
382 top_k,
383 };
384 let text_results = search_text_regex(&index, query, &options)?;
385 fuse_text_only(&text_results, top_k)
386 }
387 SearchMode::Semantic => {
388 let embedder = create_embedder_for_search(config)?;
389 let options = SemanticSearchOptions {
390 top_k,
391 score_threshold: config.search.score_threshold,
392 };
393 let semantic_results = search_semantic(&index, query, embedder.as_ref(), &options)?;
394 fuse_semantic_only(&semantic_results, top_k)
395 }
396 SearchMode::Hybrid => {
397 let text_options = TextSearchOptions {
399 case_sensitive: false,
400 context_lines: config.search.context_lines,
401 top_k,
402 };
403 let text_results = search_text_regex(&index, query, &text_options)?;
404
405 let embedder = create_embedder_for_search(config)?;
406 let semantic_options = SemanticSearchOptions {
407 top_k,
408 score_threshold: config.search.score_threshold,
409 };
410 let semantic_results =
411 search_semantic(&index, query, embedder.as_ref(), &semantic_options)?;
412
413 let ast_results = search_ast_pattern(&index, query, top_k).unwrap_or_default();
415
416 if ast_results.is_empty() {
417 rrf_fuse(&text_results, &semantic_results, config.search.rrf_k, top_k)
419 } else {
420 rrf_fuse_three(&text_results, &semantic_results, &ast_results, config.search.rrf_k, top_k)
422 }
423 }
424 SearchMode::Ast => {
425 let ast_results = search_ast_pattern(&index, query, top_k)?;
426 if ast_results.is_empty() && !json_output {
427 eprintln!(
428 "{} No AST pattern matches found for '{}'",
429 "⚠".yellow(),
430 query,
431 );
432 eprintln!(
433 " {} Pattern syntax: fn(string) -> number, async fn(*) -> Result, struct *Config",
434 "ℹ".blue(),
435 );
436 }
437 fuse_ast_only(&ast_results, top_k)
438 }
439 };
440
441 let elapsed = start.elapsed();
442
443 let results: Vec<SearchResult> = fused_results
445 .iter()
446 .filter_map(|fused| {
447 index.get_chunk(fused.chunk_id).map(|chunk| SearchResult {
448 chunk: chunk.clone(),
449 score: fused.fused_score,
450 source: search_mode.clone(),
451 matched_lines: fused.matched_lines.clone(),
452 })
453 })
454 .collect();
455
456 let total = results.len();
457
458 if json_output {
459 let response = SearchResponse {
460 results,
461 total,
462 duration_ms: elapsed.as_millis() as u64,
463 query: SearchQuery {
464 query: query.to_string(),
465 mode: search_mode,
466 top_k,
467 project_path: project_path.display().to_string(),
468 },
469 };
470 println!(
471 "{}",
472 serde_json::to_string_pretty(&response).unwrap_or_default()
473 );
474 } else {
475 print_results_colored(&results, &elapsed);
476 }
477
478 Ok(())
479}
480
481pub fn cmd_status(
483 project_path: &str,
484 config: &SeekrConfig,
485 json_output: bool,
486) -> Result<(), SeekrError> {
487 let project_path = Path::new(project_path)
488 .canonicalize()
489 .unwrap_or_else(|_| Path::new(project_path).to_path_buf());
490
491 let index_dir = config.project_index_dir(&project_path);
492
493 let index_path = index_dir.join("index.json");
494 let exists = index_path.exists();
495
496 if json_output {
497 let status = if exists {
498 match SeekrIndex::load(&index_dir) {
499 Ok(index) => serde_json::json!({
500 "indexed": true,
501 "project": project_path.display().to_string(),
502 "index_dir": index_dir.display().to_string(),
503 "chunks": index.chunk_count,
504 "embedding_dim": index.embedding_dim,
505 "version": index.version,
506 }),
507 Err(e) => serde_json::json!({
508 "indexed": true,
509 "project": project_path.display().to_string(),
510 "index_dir": index_dir.display().to_string(),
511 "error": e.to_string(),
512 }),
513 }
514 } else {
515 serde_json::json!({
516 "indexed": false,
517 "project": project_path.display().to_string(),
518 "index_dir": index_dir.display().to_string(),
519 "message": "No index found. Run `seekr-code index` to build one.",
520 })
521 };
522 println!("{}", serde_json::to_string_pretty(&status).unwrap_or_default());
523 } else if exists {
524 match SeekrIndex::load(&index_dir) {
525 Ok(index) => {
526 eprintln!("{} Index status for {}", "📊".to_string(), project_path.display());
527 eprintln!(" {} Project: {}", "•".blue(), project_path.display());
528 eprintln!(" {} Index dir: {}", "•".blue(), index_dir.display());
529 eprintln!(
530 " {} Chunks: {}",
531 "•".blue(),
532 index.chunk_count.to_string().green()
533 );
534 eprintln!(
535 " {} Embedding dim: {}",
536 "•".blue(),
537 index.embedding_dim,
538 );
539 eprintln!(" {} Version: {}", "•".blue(), index.version);
540 }
541 Err(e) => {
542 eprintln!(
543 "{} Index found but could not load: {}",
544 "⚠".yellow(),
545 e
546 );
547 }
548 }
549 } else {
550 eprintln!(
551 "{} No index found for {}",
552 "⚠".yellow(),
553 project_path.display()
554 );
555 eprintln!(" Run `seekr-code index {}` to build one.", project_path.display());
556 }
557
558 Ok(())
559}
560
561fn print_results_colored(results: &[SearchResult], elapsed: &std::time::Duration) {
563 if results.is_empty() {
564 eprintln!("{} No results found.", "⚠".yellow());
565 return;
566 }
567
568 eprintln!(
569 "\n{} {} results in {:.1}ms\n",
570 "🔍".to_string(),
571 results.len(),
572 elapsed.as_secs_f64() * 1000.0,
573 );
574
575 for (i, result) in results.iter().enumerate() {
576 let file_path = result.chunk.file_path.display();
577 let kind = &result.chunk.kind;
578 let name = result.chunk.name.as_deref().unwrap_or("<unnamed>");
579 let score = result.score;
580
581 println!(
583 "{} {} {} {} (score: {:.4})",
584 format!("[{}]", i + 1).dimmed(),
585 file_path.to_string().cyan(),
586 format!("{}", kind).dimmed(),
587 name.yellow().bold(),
588 score,
589 );
590
591 let line_start = result.chunk.line_range.start + 1; let line_end = result.chunk.line_range.end;
594 println!(
595 " {} L{}-L{}",
596 "│".dimmed(),
597 line_start,
598 line_end,
599 );
600
601 if let Some(ref sig) = result.chunk.signature {
603 println!(" {} {}", "│".dimmed(), sig.green());
604 } else {
605 for (j, line) in result.chunk.body.lines().take(3).enumerate() {
607 let trimmed = line.trim();
608 if !trimmed.is_empty() {
609 println!(" {} {}", "│".dimmed(), trimmed);
610 }
611 if j == 2 && result.chunk.body.lines().count() > 3 {
612 println!(" {} {}", "│".dimmed(), "...".dimmed());
613 }
614 }
615 }
616
617 println!();
618 }
619}
620
621fn create_embedder(config: &SeekrConfig) -> Result<Box<dyn Embedder>, SeekrError> {
623 match crate::embedder::onnx::OnnxEmbedder::new(&config.model_dir) {
624 Ok(embedder) => Ok(Box::new(embedder)),
625 Err(e) => Err(SeekrError::Embedder(
626 crate::error::EmbedderError::OnnxError(format!(
627 "Failed to create ONNX embedder: {}",
628 e
629 )),
630 )),
631 }
632}
633
634fn create_embedder_for_search(config: &SeekrConfig) -> Result<Box<dyn Embedder>, SeekrError> {
637 match crate::embedder::onnx::OnnxEmbedder::new(&config.model_dir) {
638 Ok(embedder) => Ok(Box::new(embedder)),
639 Err(_e) => {
640 tracing::warn!("ONNX embedder unavailable for search, using dummy embedder");
641 Ok(Box::new(DummyEmbedder::new(384)))
642 }
643 }
644}