vyctor 0.1.0

A fast CLI tool for semantic file search using vector embeddings
Documentation
//! Implementation of the `vyctor browse` command
//!
//! Provides tools to explore and analyze indexed files and chunks.

use crate::config::{db_path, find_vyctor_root, load_config};
use crate::daemon::is_daemon_running;
use crate::storage::Storage;
use anyhow::{Context, Result};
use colored::Colorize;

/// Try to open storage, with helpful error message if daemon is holding the lock
fn open_storage_for_browse(root: &std::path::Path, dimensions: usize) -> Result<Storage> {
    let db_file = db_path()?;

    if !db_file.exists() {
        anyhow::bail!("No index found. Run 'vyctor sync' to create one.");
    }

    // Try read-only first
    match Storage::open_readonly(&db_file, dimensions) {
        Ok(storage) => Ok(storage),
        Err(e) => {
            // Check if daemon is running
            if let Some(pid) = is_daemon_running(root) {
                println!(
                    "{} The watcher daemon (PID {}) is holding the database lock.",
                    "!".yellow(),
                    pid
                );
                println!();
                println!("To browse the index, stop the daemon first:");
                println!("  {} vyctor watch --stop", "$".dimmed());
                println!();
                println!("You can restart it later with:");
                println!("  {} vyctor watch --daemon", "$".dimmed());
                println!();
                anyhow::bail!("Database locked by daemon");
            }
            Err(e).context("Failed to open database")
        }
    }
}

/// List all indexed files with their statistics
pub async fn list_files(filter: Option<&str>, show_hash: bool) -> Result<()> {
    let root = find_vyctor_root()?;
    let config = load_config()?;

    let storage = open_storage_for_browse(&root, config.embedding.dimensions)?;
    let files = storage.list_files_with_stats()?;

    if files.is_empty() {
        println!("{} No files indexed yet.", "!".yellow());
        return Ok(());
    }

    // Filter files if pattern provided
    let files: Vec<_> = if let Some(pattern) = filter {
        files
            .into_iter()
            .filter(|f| f.path.contains(pattern))
            .collect()
    } else {
        files
    };

    println!("{}", "Indexed Files".bold());
    println!();

    // Calculate column widths
    let max_path_len = files
        .iter()
        .map(|f| f.path.len())
        .max()
        .unwrap_or(20)
        .min(60);

    // Print header
    if show_hash {
        println!(
            "{:width$}  {:>8}  {:>10}  {:>12}  {}",
            "Path".bold(),
            "Chunks".bold(),
            "Size".bold(),
            "Hash".bold(),
            "Last Indexed".bold(),
            width = max_path_len
        );
        println!("{}", "-".repeat(max_path_len + 60));
    } else {
        println!(
            "{:width$}  {:>8}  {:>10}  {}",
            "Path".bold(),
            "Chunks".bold(),
            "Size".bold(),
            "Last Indexed".bold(),
            width = max_path_len
        );
        println!("{}", "-".repeat(max_path_len + 40));
    }

    for file in &files {
        let path_display = if file.path.len() > max_path_len {
            format!("...{}", &file.path[file.path.len() - max_path_len + 3..])
        } else {
            file.path.clone()
        };

        if show_hash {
            println!(
                "{:width$}  {:>8}  {:>10}  {:>12}  {}",
                path_display.cyan(),
                file.chunk_count.to_string().green(),
                format_bytes(file.total_size),
                &file.content_hash[..12],
                file.last_indexed.dimmed(),
                width = max_path_len
            );
        } else {
            println!(
                "{:width$}  {:>8}  {:>10}  {}",
                path_display.cyan(),
                file.chunk_count.to_string().green(),
                format_bytes(file.total_size),
                file.last_indexed.dimmed(),
                width = max_path_len
            );
        }
    }

    println!();
    println!(
        "Total: {} files, {} chunks",
        files.len().to_string().green(),
        files
            .iter()
            .map(|f| f.chunk_count)
            .sum::<usize>()
            .to_string()
            .cyan()
    );

    Ok(())
}

/// Show chunks for a specific file or browse all chunks
pub async fn show_chunks(
    file: Option<&str>,
    chunk_id: Option<i64>,
    page: usize,
    page_size: usize,
    full: bool,
) -> Result<()> {
    let root = find_vyctor_root()?;
    let config = load_config()?;

    let storage = open_storage_for_browse(&root, config.embedding.dimensions)?;

    // If a specific chunk ID is requested
    if let Some(id) = chunk_id {
        return show_single_chunk(&storage, id);
    }

    // Get chunks
    let chunks = if let Some(file_path) = file {
        storage.get_chunks_for_file(file_path)?
    } else {
        let offset = (page - 1) * page_size;
        storage.get_chunks_paginated(offset, page_size, None)?
    };

    if chunks.is_empty() {
        if file.is_some() {
            println!("{} No chunks found for this file.", "!".yellow());
        } else {
            println!("{} No chunks found.", "!".yellow());
        }
        return Ok(());
    }

    println!("{}", "Chunks".bold());
    if let Some(f) = file {
        println!("File: {}", f.cyan());
    } else {
        println!("Page: {} (showing {} chunks)", page, chunks.len());
    }
    println!();

    for chunk in &chunks {
        print_chunk(chunk, full);
    }

    if file.is_none() {
        println!();
        println!(
            "{} Use --page N to see more chunks, or --file PATH to filter by file",
            "Tip:".dimmed()
        );
    }

    Ok(())
}

/// Show a single chunk by ID
fn show_single_chunk(storage: &Storage, chunk_id: i64) -> Result<()> {
    match storage.get_chunk_by_id(chunk_id)? {
        Some(chunk) => {
            println!("{}", "Chunk Details".bold());
            println!();
            print_chunk(&chunk, true);
            Ok(())
        }
        None => {
            println!("{} Chunk with ID {} not found.", "!".yellow(), chunk_id);
            Ok(())
        }
    }
}

/// Print a chunk with formatting
fn print_chunk(chunk: &crate::storage::ChunkWithMetadata, full: bool) {
    println!(
        "{} {} [ID: {}, Lines {}-{}]",
        format!("#{}", chunk.chunk_index).bold(),
        chunk.file_path.cyan(),
        chunk.id.to_string().dimmed(),
        chunk.start_line,
        chunk.end_line
    );

    if full {
        println!();
        for line in chunk.content.lines() {
            println!("  {}", line.dimmed());
        }
    } else {
        // Show preview (first 5 lines)
        let preview_lines: Vec<&str> = chunk.content.lines().take(5).collect();
        println!();
        for line in &preview_lines {
            let truncated = if line.len() > 100 {
                format!("{}...", &line[..97])
            } else {
                line.to_string()
            };
            println!("  {}", truncated.dimmed());
        }
        let total_lines = chunk.content.lines().count();
        if total_lines > 5 {
            println!(
                "  {} more lines...",
                format!("(+{})", total_lines - 5).dimmed()
            );
        }
    }
    println!();
}

/// Show statistics grouped by file extension
pub async fn show_stats() -> Result<()> {
    let root = find_vyctor_root()?;
    let config = load_config()?;

    let storage = open_storage_for_browse(&root, config.embedding.dimensions)?;

    // Overall stats
    let stats = storage.get_stats()?;
    println!("{}", "Index Overview".bold());
    println!();
    println!("  Files indexed:  {}", stats.file_count.to_string().green());
    println!("  Total chunks:   {}", stats.chunk_count.to_string().cyan());
    println!(
        "  Content size:   {}",
        format_bytes(stats.total_content_size)
    );
    if let Some(ref last) = stats.last_indexed {
        println!("  Last indexed:   {}", last.dimmed());
    }

    // Stats by extension
    let ext_stats = storage.get_stats_by_extension()?;

    if !ext_stats.is_empty() {
        println!();
        println!("{}", "By File Type".bold());
        println!();
        println!(
            "  {:>12}  {:>8}  {:>10}  {:>12}",
            "Extension".bold(),
            "Files".bold(),
            "Chunks".bold(),
            "Size".bold()
        );
        println!("  {}", "-".repeat(46));

        for stat in &ext_stats {
            let ext_display = if stat.extension.len() > 12 {
                format!("{}...", &stat.extension[..9])
            } else {
                stat.extension.clone()
            };
            println!(
                "  {:>12}  {:>8}  {:>10}  {:>12}",
                ext_display.cyan(),
                stat.file_count.to_string().green(),
                stat.chunk_count,
                format_bytes(stat.total_size)
            );
        }
    }

    // Chunk size distribution
    println!();
    println!("{}", "Chunk Statistics".bold());
    println!();

    if stats.chunk_count > 0 {
        let avg_chunk_size = stats.total_content_size / stats.chunk_count;
        let avg_chunks_per_file = if stats.file_count > 0 {
            stats.chunk_count as f64 / stats.file_count as f64
        } else {
            0.0
        };

        println!("  Avg chunk size:      {}", format_bytes(avg_chunk_size));
        println!("  Avg chunks per file: {:.1}", avg_chunks_per_file);
    }

    Ok(())
}

/// Format bytes into human-readable form
fn format_bytes(bytes: usize) -> String {
    const KB: usize = 1024;
    const MB: usize = KB * 1024;
    const GB: usize = MB * 1024;

    if bytes >= GB {
        format!("{:.2} GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.2} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.2} KB", bytes as f64 / KB as f64)
    } else {
        format!("{} B", bytes)
    }
}