directory-indexer 0.0.10

AI-powered directory indexing with semantic search for MCP servers
Documentation
use log::info;
use rusqlite::Connection;
use serde_json::Value;
use std::path::Path;

use crate::{error::Result, utils::normalize_path};

pub struct SqliteStore {
    conn: Connection,
}

#[derive(Debug, Clone)]
pub struct FileRecord {
    pub id: i64,
    pub path: String,
    pub size: i64,
    pub modified_time: i64,
    pub hash: String,
    pub parent_dirs: Vec<String>,
    pub chunks_json: Option<Value>,
    pub errors_json: Option<Value>,
}

#[derive(Debug, Clone)]
pub struct DirectoryRecord {
    pub id: i64,
    pub path: String,
    pub status: String,
    pub indexed_at: i64,
}

impl SqliteStore {
    pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self> {
        let conn = Connection::open(db_path)?;
        let store = SqliteStore { conn };
        store.initialize_schema()?;
        Ok(store)
    }

    fn initialize_schema(&self) -> Result<()> {
        self.conn.execute(
            "CREATE TABLE IF NOT EXISTS directories (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                path TEXT UNIQUE NOT NULL,
                status TEXT NOT NULL DEFAULT 'pending',
                indexed_at INTEGER NOT NULL DEFAULT 0
            )",
            [],
        )?;

        self.conn.execute(
            "CREATE TABLE IF NOT EXISTS files (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                path TEXT UNIQUE NOT NULL,
                size INTEGER NOT NULL,
                modified_time INTEGER NOT NULL,
                hash TEXT NOT NULL,
                parent_dirs TEXT NOT NULL,
                chunks_json TEXT,
                errors_json TEXT
            )",
            [],
        )?;

        self.conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)",
            [],
        )?;

        self.conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_files_parent_dirs ON files(parent_dirs)",
            [],
        )?;

        Ok(())
    }

    pub fn add_directory(&self, path: &str) -> Result<i64> {
        let normalized_path = normalize_path(path)?;
        let mut stmt = self.conn.prepare(
            "INSERT OR REPLACE INTO directories (path, status, indexed_at) 
             VALUES (?1, 'pending', strftime('%s', 'now'))",
        )?;

        stmt.execute([&normalized_path])?;
        Ok(self.conn.last_insert_rowid())
    }

    pub fn update_directory_status(&self, path: &str, status: &str) -> Result<()> {
        let normalized_path = normalize_path(path)?;
        let mut stmt = self.conn.prepare(
            "UPDATE directories SET status = ?1, indexed_at = strftime('%s', 'now') WHERE path = ?2"
        )?;

        stmt.execute([status, &normalized_path])?;
        Ok(())
    }

    pub fn get_directories(&self) -> Result<Vec<DirectoryRecord>> {
        let mut stmt = self
            .conn
            .prepare("SELECT id, path, status, indexed_at FROM directories ORDER BY path")?;

        let rows = stmt.query_map([], |row| {
            Ok(DirectoryRecord {
                id: row.get(0)?,
                path: row.get(1)?,
                status: row.get(2)?,
                indexed_at: row.get(3)?,
            })
        })?;

        let mut directories = Vec::new();
        for row in rows {
            directories.push(row?);
        }

        Ok(directories)
    }

    pub fn add_file(&self, record: &FileRecord) -> Result<i64> {
        let parent_dirs_json = serde_json::to_string(&record.parent_dirs)?;
        let chunks_json = record
            .chunks_json
            .as_ref()
            .map(serde_json::to_string)
            .transpose()?;
        let errors_json = record
            .errors_json
            .as_ref()
            .map(serde_json::to_string)
            .transpose()?;

        let mut stmt = self.conn.prepare(
            "INSERT OR REPLACE INTO files 
             (path, size, modified_time, hash, parent_dirs, chunks_json, errors_json)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
        )?;

        stmt.execute(rusqlite::params![
            record.path,
            record.size,
            record.modified_time,
            record.hash,
            parent_dirs_json,
            chunks_json,
            errors_json
        ])?;

        Ok(self.conn.last_insert_rowid())
    }

    pub fn get_file_by_path(&self, path: &str) -> Result<Option<FileRecord>> {
        let normalized_path = normalize_path(path)?;
        let mut stmt = self.conn.prepare(
            "SELECT id, path, size, modified_time, hash, parent_dirs, chunks_json, errors_json 
             FROM files WHERE path = ?1",
        )?;

        let mut rows = stmt.query_map([&normalized_path], |row| {
            let parent_dirs_str: String = row.get(5)?;
            let parent_dirs: Vec<String> = serde_json::from_str(&parent_dirs_str).map_err(|e| {
                rusqlite::Error::FromSqlConversionFailure(
                    5,
                    rusqlite::types::Type::Text,
                    Box::new(e),
                )
            })?;

            let chunks_json: Option<String> = row.get(6)?;
            let chunks = chunks_json
                .filter(|s| !s.is_empty())
                .map(|s| serde_json::from_str(&s))
                .transpose()
                .map_err(|e| {
                    rusqlite::Error::FromSqlConversionFailure(
                        6,
                        rusqlite::types::Type::Text,
                        Box::new(e),
                    )
                })?;

            let errors_json: Option<String> = row.get(7)?;
            let errors = errors_json
                .filter(|s| !s.is_empty())
                .map(|s| serde_json::from_str(&s))
                .transpose()
                .map_err(|e| {
                    rusqlite::Error::FromSqlConversionFailure(
                        7,
                        rusqlite::types::Type::Text,
                        Box::new(e),
                    )
                })?;

            Ok(FileRecord {
                id: row.get(0)?,
                path: row.get(1)?,
                size: row.get(2)?,
                modified_time: row.get(3)?,
                hash: row.get(4)?,
                parent_dirs,
                chunks_json: chunks,
                errors_json: errors,
            })
        })?;

        match rows.next() {
            Some(row) => Ok(Some(row?)),
            None => Ok(None),
        }
    }

    pub fn delete_file(&self, path: &str) -> Result<()> {
        let normalized_path = normalize_path(path)?;
        let mut stmt = self.conn.prepare("DELETE FROM files WHERE path = ?1")?;
        stmt.execute([&normalized_path])?;
        Ok(())
    }

    pub fn clear_all_files(&self) -> Result<()> {
        info!("Clearing all file tracking data from SQLite");
        let mut stmt = self.conn.prepare("DELETE FROM files")?;
        stmt.execute([])?;
        Ok(())
    }

    pub fn get_stats(&self) -> Result<(i64, i64, i64)> {
        let directory_count: i64 =
            self.conn
                .query_row("SELECT COUNT(*) FROM directories", [], |row| row.get(0))?;

        let file_count: i64 = self
            .conn
            .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;

        let chunk_count: i64 = self.conn.query_row(
            "SELECT COUNT(*) FROM files WHERE chunks_json IS NOT NULL",
            [],
            |row| row.get(0),
        )?;

        Ok((directory_count, file_count, chunk_count))
    }
}