scryer-engine 0.2.0

Tree-sitter and stack-graphs AST indexing engine for Scryer code intelligence
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use scryer_db::{ScryerDb, SourceFile};

/// Computes the 256-bit BLAKE3 hex digest of a byte slice.
pub fn hash_bytes(bytes: &[u8]) -> String {
    blake3::hash(bytes).to_hex().to_string()
}

/// Reads a file from disk and computes its BLAKE3 hex digest.
pub fn hash_file(path: &Path) -> std::io::Result<String> {
    let bytes = fs::read(path)?;
    Ok(hash_bytes(&bytes))
}

/// Classification of a discovered file against the database's cached state.
#[derive(Debug, Clone)]
pub enum FileChange {
    /// Newly introduced file not yet present in the database.
    Added {
        rel_path: PathBuf,
        content_hash: String,
        content: Vec<u8>,
    },
    /// Existing file whose content hash has changed.
    Modified {
        file_id: u64,
        rel_path: PathBuf,
        content_hash: String,
        content: Vec<u8>,
    },
    /// Existing file whose content hash is identical to Turso.
    Unchanged { file_id: u64, rel_path: PathBuf },
    /// File present in database but no longer found on disk.
    Deleted { file_id: u64, rel_path: PathBuf },
}

/// Aggregated report of workspace incremental change detection.
#[derive(Debug, Clone, Default)]
pub struct ChangeReport {
    pub changes: Vec<FileChange>,
    pub added_count: usize,
    pub modified_count: usize,
    pub unchanged_count: usize,
    pub deleted_count: usize,
}

impl ChangeReport {
    pub fn is_empty(&self) -> bool {
        self.added_count == 0 && self.modified_count == 0 && self.deleted_count == 0
    }
}

/// Detector comparing workspace files against Turso's `SourceFile` table using BLAKE3 hashes.
pub struct ChangeDetector;

impl ChangeDetector {
    /// Compare a list of discovered relative paths against Turso database records for `project_id`.
    pub async fn detect_changes(
        db: &ScryerDb,
        project_id: u64,
        root: &Path,
        scanned_paths: &[PathBuf],
    ) -> anyhow::Result<ChangeReport> {
        // Query all existing SourceFiles for this project
        let existing_files = {
            let mut db_guard = db.lock().await;
            SourceFile::filter_by_project_id(project_id)
                .exec(&mut *db_guard)
                .await?
        };

        let mut db_map: HashMap<String, (u64, String)> = existing_files
            .into_iter()
            .map(|f| (f.path, (f.id, f.content_hash)))
            .collect();

        let mut changes = Vec::new();
        let mut added_count = 0;
        let mut modified_count = 0;
        let mut unchanged_count = 0;

        for rel in scanned_paths {
            let rel_str = rel.to_string_lossy().to_string();
            let full_path = root.join(rel);

            let content = match fs::read(&full_path) {
                Ok(c) => c,
                Err(err) => {
                    tracing::warn!("Failed to read file {}: {err}", full_path.display());
                    // Drop it from the DB map so a transient read failure is not
                    // reported as a deletion (which would purge its symbols and edges).
                    db_map.remove(&rel_str);
                    continue;
                }
            };

            let current_hash = hash_bytes(&content);

            if let Some((file_id, cached_hash)) = db_map.remove(&rel_str) {
                if cached_hash == current_hash {
                    changes.push(FileChange::Unchanged {
                        file_id,
                        rel_path: rel.clone(),
                    });
                    unchanged_count += 1;
                } else {
                    changes.push(FileChange::Modified {
                        file_id,
                        rel_path: rel.clone(),
                        content_hash: current_hash,
                        content,
                    });
                    modified_count += 1;
                }
            } else {
                changes.push(FileChange::Added {
                    rel_path: rel.clone(),
                    content_hash: current_hash,
                    content,
                });
                added_count += 1;
            }
        }

        // Remaining entries in db_map were deleted from disk
        let mut deleted_count = 0;
        for (del_path, (file_id, _)) in db_map {
            changes.push(FileChange::Deleted {
                file_id,
                rel_path: PathBuf::from(del_path),
            });
            deleted_count += 1;
        }

        Ok(ChangeReport {
            changes,
            added_count,
            modified_count,
            unchanged_count,
            deleted_count,
        })
    }
}