Skip to main content

seekr_code/server/
cli.rs

1//! CLI subcommand implementations.
2//!
3//! Handles search result formatting (colored terminal + JSON output),
4//! index building orchestration, and status display.
5
6use 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
29/// Execute the `seekr-code index` command.
30///
31/// Scans the project directory, parses source files into chunks,
32/// generates embeddings, and builds + persists the search index.
33/// Supports incremental indexing: only re-processes changed files
34/// unless `--force` is specified.
35pub 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    // Step 1: Scan files
50    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    // Step 2: Incremental change detection
71    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    // Handle deletions from existing index
89    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    // Step 3: Parse & chunk changed files
140    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 incremental, remove old chunks for changed files from existing index
160    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    // Step 4: Generate summaries for embedding
207    let summaries: Vec<String> = new_chunks
208        .iter()
209        .map(|chunk| generate_summary(chunk))
210        .collect();
211
212    // Step 5: Generate embeddings
213    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    // Step 6: Build or merge index
276    if !json_output {
277        eprintln!("{} Building index...", "→".blue());
278    }
279
280    let index = if let Some(mut idx) = existing_index {
281        // Merge new chunks into existing index
282        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    // Save index
297    index.save(&index_dir)?;
298
299    // Step 7: Update incremental state
300    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
346/// Execute the `seekr-code search` command.
347pub 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    // Parse search mode
362    let search_mode: SearchMode = mode.parse().map_err(|e: String| {
363        SeekrError::Search(crate::error::SearchError::InvalidRegex(e))
364    })?;
365
366    // Load index
367    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    // Execute search based on mode
377    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            // Run text, semantic, and AST search, then fuse with 3-way RRF
398            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            // Try AST pattern search — it's fine if the query doesn't parse as an AST pattern
414            let ast_results = search_ast_pattern(&index, query, top_k).unwrap_or_default();
415
416            if ast_results.is_empty() {
417                // 2-way fusion when no AST matches
418                rrf_fuse(&text_results, &semantic_results, config.search.rrf_k, top_k)
419            } else {
420                // 3-way fusion with AST
421                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    // Build response — propagate matched_lines from fusion results
444    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
481/// Execute the `seekr-code status` command.
482pub 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
561/// Print search results with colored terminal output.
562fn 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        // Header line
582        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        // Show line range
592        let line_start = result.chunk.line_range.start + 1; // 1-indexed
593        let line_end = result.chunk.line_range.end;
594        println!(
595            "    {} L{}-L{}",
596            "│".dimmed(),
597            line_start,
598            line_end,
599        );
600
601        // Show signature or first few lines of body
602        if let Some(ref sig) = result.chunk.signature {
603            println!("    {} {}", "│".dimmed(), sig.green());
604        } else {
605            // Show first 3 lines
606            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
621/// Create an embedder for indexing (OnnxEmbedder or fall back to DummyEmbedder).
622fn 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
634/// Create an embedder for search queries.
635/// Falls back to DummyEmbedder if OnnxEmbedder is unavailable.
636fn 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}