astmap-core 0.0.1

Core domain types and logic for astmap
Documentation
use std::collections::HashMap;

use tracing::debug;

use crate::error::ScanError;
use crate::model::DepLevel;
use crate::port::ScanStore;

use super::types::{CrossFileMatch, ImportResolutionMap, UnresolvedRef};

/// Classify a single unresolved reference via named imports or wildcard fallback.
///
/// Pure function — no DB access.
fn classify_ref(
    uref: &UnresolvedRef,
    file_import_names: &HashMap<(i64, String), String>,
    import_resolution: &ImportResolutionMap,
) -> Option<CrossFileMatch> {
    // Strategy 1: Find which named import brought this name into scope
    if let Some(imp_path) = file_import_names.get(&(uref.source_file_id, uref.target_name.clone()))
    {
        if let Some((target_fid, name_map)) =
            import_resolution.get(&(uref.source_file_id, imp_path.clone()))
        {
            if let Some(&target_sym_id) = name_map.get(&uref.target_name) {
                return Some(CrossFileMatch {
                    source_file_id: uref.source_file_id,
                    source_symbol_id: uref.source_symbol_id,
                    target_file_id: *target_fid,
                    target_symbol_id: target_sym_id,
                    dep_type: uref.dep_type,
                });
            }
        }
    }

    // Strategy 2: Fallback — check wildcard-expanded import_resolution entries
    for ((src_fid, _), (target_fid, name_map)) in import_resolution {
        if *src_fid == uref.source_file_id {
            if let Some(&target_sym_id) = name_map.get(&uref.target_name) {
                return Some(CrossFileMatch {
                    source_file_id: uref.source_file_id,
                    source_symbol_id: uref.source_symbol_id,
                    target_file_id: *target_fid,
                    target_symbol_id: target_sym_id,
                    dep_type: uref.dep_type,
                });
            }
        }
    }

    None
}

/// Resolve unresolved cross-file references using import resolution.
///
/// Phase 1 (pure): classify all refs. Phase 2 (effect): insert dependencies.
/// Returns the number of cross-file dependencies resolved.
pub(super) fn resolve_cross_file(
    unresolved_refs: &[UnresolvedRef],
    file_import_names: &HashMap<(i64, String), String>,
    import_resolution: &ImportResolutionMap,
    db: &dyn ScanStore,
) -> Result<i64, ScanError> {
    // Phase 1: pure classification (single pass)
    let mut resolved = Vec::new();
    for uref in unresolved_refs {
        match classify_ref(uref, file_import_names, import_resolution) {
            Some(m) => resolved.push(m),
            None => debug!(
                "unresolved cross-file ref: {} in file_id={} (dep_type={})",
                uref.target_name, uref.source_file_id, uref.dep_type
            ),
        }
    }

    // Phase 2: effect — insert dependencies
    resolved.iter().try_fold(0i64, |count, m| {
        db.insert_dependency(
            m.source_file_id,
            Some(m.source_symbol_id),
            Some(m.target_file_id),
            Some(m.target_symbol_id),
            m.dep_type,
            DepLevel::Symbol,
            None,
        )?;
        Ok(count + 1)
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::DepType;

    fn make_uref(file_id: i64, sym_id: i64, name: &str, dep_type: DepType) -> UnresolvedRef {
        UnresolvedRef {
            source_file_id: file_id,
            source_symbol_id: sym_id,
            target_name: name.to_string(),
            dep_type,
        }
    }

    #[test]
    fn test_classify_ref_named_import_hit() {
        let mut file_import_names = HashMap::new();
        file_import_names.insert((1, "Config".to_string()), "./config".to_string());

        let mut name_map = HashMap::new();
        name_map.insert("Config".to_string(), 42i64);
        let mut import_resolution: ImportResolutionMap = HashMap::new();
        import_resolution.insert((1, "./config".to_string()), (2, name_map));

        let uref = make_uref(1, 10, "Config", DepType::TypeRef);
        let result = classify_ref(&uref, &file_import_names, &import_resolution);

        assert!(result.is_some());
        let m = result.unwrap();
        assert_eq!(m.target_file_id, 2);
        assert_eq!(m.target_symbol_id, 42);
        assert_eq!(m.dep_type, DepType::TypeRef);
    }

    #[test]
    fn test_classify_ref_named_import_miss() {
        let mut file_import_names = HashMap::new();
        file_import_names.insert((1, "Config".to_string()), "./config".to_string());

        // import_resolution has the path but NOT the symbol name
        let mut name_map = HashMap::new();
        name_map.insert("OtherSymbol".to_string(), 42i64);
        let mut import_resolution: ImportResolutionMap = HashMap::new();
        import_resolution.insert((1, "./config".to_string()), (2, name_map));

        let uref = make_uref(1, 10, "Config", DepType::TypeRef);
        let result = classify_ref(&uref, &file_import_names, &import_resolution);

        assert!(result.is_none());
    }

    #[test]
    fn test_classify_ref_wildcard_fallback() {
        // No named import entry for this symbol
        let file_import_names: HashMap<(i64, String), String> = HashMap::new();

        // But wildcard import_resolution has it
        let mut name_map = HashMap::new();
        name_map.insert("Helper".to_string(), 99i64);
        let mut import_resolution: ImportResolutionMap = HashMap::new();
        import_resolution.insert((1, "crate::utils::*".to_string()), (3, name_map));

        let uref = make_uref(1, 10, "Helper", DepType::Calls);
        let result = classify_ref(&uref, &file_import_names, &import_resolution);

        assert!(result.is_some());
        let m = result.unwrap();
        assert_eq!(m.target_file_id, 3);
        assert_eq!(m.target_symbol_id, 99);
    }

    #[test]
    fn test_classify_ref_no_match() {
        let file_import_names: HashMap<(i64, String), String> = HashMap::new();
        let import_resolution: ImportResolutionMap = HashMap::new();

        let uref = make_uref(1, 10, "Unknown", DepType::Calls);
        let result = classify_ref(&uref, &file_import_names, &import_resolution);

        assert!(result.is_none());
    }
}