astmap-core 0.0.2

Core domain types and logic for astmap
Documentation
// Symbol-level structural diff across git refs.
// Compares parsed symbols from old git blobs against current DB symbols.

use std::path::Path;

use serde::Serialize;
use tracing::warn;

use crate::error::DiffError;
use crate::model::{ExtractedSymbol, FileChangeStatus, ImpactEntry};
use crate::port::{DiffStore, LanguageRegistry, VcsProvider};

use super::change::{
    compare_extracted_symbols, symbol_change_from_current, symbol_change_from_old, ChangeType,
    SymbolChange,
};

#[derive(Debug, Serialize)]
pub struct SinceResult {
    pub reference: String,
    pub commit_count: usize,
    pub added: Vec<SymbolChange>,
    pub modified: Vec<SymbolChange>,
    pub removed: Vec<SymbolChange>,
    pub downstream_impacts: Vec<ImpactEntry>,
    pub summary: SinceSummary,
}

#[derive(Debug, Serialize)]
pub struct SinceSummary {
    pub added_count: usize,
    pub modified_count: usize,
    pub removed_count: usize,
    pub impact_count: usize,
    pub files_changed: usize,
}

/// Show structural changes (added/removed/modified symbols) since a git reference.
///
/// Expects the caller to have run a scan first to ensure the DB matches the current working tree.
/// Parses old file content from git history and compares against current DB symbols.
pub fn since_against_ref(
    vcs: &dyn VcsProvider,
    reference: &str,
    db: &dyn DiffStore,
    impact_depth: i64,
    supported_ext: &[&str],
    lang: &dyn LanguageRegistry,
) -> Result<SinceResult, DiffError> {
    // Phase 1: get changes and commit count from VCS
    let vcs_changes = vcs.changes_between_refs(reference, "HEAD")?;
    let commit_count = vcs.commit_count(reference, "HEAD")?;

    // Phase 2: filter by supported extensions and process deltas
    let (mut added, mut modified, mut removed, mut modified_sym_ids) =
        (Vec::new(), Vec::new(), Vec::new(), Vec::new());

    for change in &vcs_changes {
        // Filter by supported extensions
        let primary_path = if change.status == FileChangeStatus::Deleted {
            change.old_path.as_deref().unwrap_or(&change.path)
        } else {
            &change.path
        };
        let is_supported = Path::new(primary_path)
            .extension()
            .and_then(|e| e.to_str())
            .is_some_and(|ext| supported_ext.contains(&ext));
        if !is_supported {
            continue;
        }

        match change.status {
            FileChangeStatus::Added => {
                let current = db.all_symbols_for_file(&change.path).unwrap_or_default();
                for sym in current {
                    added.push(symbol_change_from_current(sym, ChangeType::Added));
                }
            }
            FileChangeStatus::Deleted => {
                let old_path = change.old_path.as_deref().unwrap_or(&change.path);
                let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
                    warn!("skipping unparseable deleted file: {}", old_path);
                    continue;
                };
                for sym in &old_symbols {
                    removed.push(symbol_change_from_old(sym, &old_symbols, old_path));
                }
            }
            FileChangeStatus::Modified | FileChangeStatus::Renamed => {
                let old_path = change.old_path.as_deref().unwrap_or(&change.path);
                let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
                    warn!("skipping unparseable file: {}", old_path);
                    continue;
                };
                let current = db.all_symbols_for_file(&change.path).unwrap_or_default();

                let (changes, file_mod_ids) =
                    compare_extracted_symbols(&old_symbols, &current, &change.path);
                for c in changes {
                    match c.change_type {
                        ChangeType::Added => added.push(c),
                        ChangeType::Modified => modified.push(c),
                        ChangeType::Removed => removed.push(c),
                    }
                }
                modified_sym_ids.extend(file_mod_ids);
            }
        }
    }

    // Phase 3: impact analysis for modified symbols
    let mut downstream_impacts = Vec::new();
    let mut seen_ids = std::collections::HashSet::new();
    for sym_id in &modified_sym_ids {
        seen_ids.insert(*sym_id);
    }
    for sym_id in &modified_sym_ids {
        if let Ok(entries) = db.impact_analysis(*sym_id, impact_depth) {
            for entry in entries {
                if seen_ids.insert(entry.symbol_id) {
                    downstream_impacts.push(entry);
                }
            }
        }
    }

    // Count files that passed the extension filter
    let files_changed = vcs_changes
        .iter()
        .filter(|c| {
            let primary_path = if c.status == FileChangeStatus::Deleted {
                c.old_path.as_deref().unwrap_or(&c.path)
            } else {
                &c.path
            };
            Path::new(primary_path)
                .extension()
                .and_then(|e| e.to_str())
                .is_some_and(|ext| supported_ext.contains(&ext))
        })
        .count();

    let summary = SinceSummary {
        added_count: added.len(),
        modified_count: modified.len(),
        removed_count: removed.len(),
        impact_count: downstream_impacts.len(),
        files_changed,
    };

    Ok(SinceResult {
        reference: reference.to_string(),
        commit_count,
        added,
        modified,
        removed,
        downstream_impacts,
        summary,
    })
}

/// Parse old file content from a git ref and extract symbols with tree-sitter.
/// Returns `None` on failure (unsupported language, missing blob, binary content)
/// so callers can distinguish "parse failed" from "file has no symbols".
fn parse_old_blob(
    vcs: &dyn VcsProvider,
    reference: &str,
    file_path: &str,
    lang: &dyn LanguageRegistry,
) -> Option<Vec<ExtractedSymbol>> {
    let ext = Path::new(file_path).extension().and_then(|e| e.to_str());
    let parser = match ext.and_then(|e| lang.parser_for(e)) {
        Some(p) => p,
        None => {
            warn!("no parser for old file: {}", file_path);
            return None;
        }
    };

    let source = match vcs.read_file_at_ref(file_path, reference) {
        Ok(Some(s)) => s,
        Ok(None) => {
            warn!("old blob not found or binary for {}", file_path);
            return None;
        }
        Err(e) => {
            warn!("failed to read old blob for {}: {}", file_path, e);
            return None;
        }
    };

    let result = parser.parse(&source, Path::new(file_path));
    Some(result.symbols)
}

// Tests for compare_symbols are in change.rs.
// Tests for since_against_ref require VcsProvider mock (integration tests).