astmap-core 0.0.1

Core domain types and logic for astmap
Documentation
use std::path::Path;

use crate::port::ScanStore;

use super::hasher;

/// Compute a combined config hash from the given config files.
/// Returns None if no config files exist.
fn compute_config_hash(project_dir: &Path, config_files: &[&str]) -> Option<String> {
    let mut combined = String::new();
    for name in config_files {
        let path = project_dir.join(name);
        if let Ok(content) = std::fs::read_to_string(&path) {
            // Prefix each file's content with its name to avoid collisions
            combined.push_str(name);
            combined.push('\0');
            combined.push_str(&content);
            combined.push('\0');
        }
    }
    if combined.is_empty() {
        None
    } else {
        Some(hasher::sha256(&combined))
    }
}

/// Check if config files changed since the last scan.
/// Returns true if a full re-scan should be forced.
pub(super) fn config_changed(
    project_dir: &Path,
    db: &dyn ScanStore,
    config_files: &[&str],
) -> bool {
    let current_hash = compute_config_hash(project_dir, config_files);
    let stored = db.config_get("config_hash").ok().flatten();
    // Treat empty string as "no config" (same as None)
    let stored_hash = stored.filter(|s| !s.is_empty());

    match (&stored_hash, &current_hash) {
        (None, None) => false,   // no config before, no config now
        (None, Some(_)) => true, // new config file appeared
        (Some(_), None) => true, // config file was deleted
        (Some(old), Some(new)) => old != new,
    }
}

/// Save the current config hash to the DB for next incremental scan.
pub(super) fn save_config_hash(project_dir: &Path, db: &dyn ScanStore, config_files: &[&str]) {
    match compute_config_hash(project_dir, config_files) {
        Some(hash) => {
            let _ = db.config_set("config_hash", &hash);
        }
        None => {
            let _ = db.config_set("config_hash", "");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::DbError;
    use crate::model::{Config, DepLevel, DepType, ParseStatus, Symbol, SymbolKind, SymbolResult};
    use std::cell::RefCell;
    use std::collections::HashMap;

    const TEST_CONFIG_FILES: &[&str] = &["tsconfig.json", "pyproject.toml", "go.mod"];

    // --- Minimal mock ScanStore ---

    struct MockScanStore {
        configs: RefCell<HashMap<String, String>>,
    }

    impl MockScanStore {
        fn new() -> Self {
            Self {
                configs: RefCell::new(HashMap::new()),
            }
        }
        fn with_config(key: &str, value: &str) -> Self {
            let mut m = HashMap::new();
            m.insert(key.to_string(), value.to_string());
            Self {
                configs: RefCell::new(m),
            }
        }
    }

    impl crate::port::ConfigStore for MockScanStore {
        fn config_get(&self, key: &str) -> Result<Option<String>, DbError> {
            Ok(self.configs.borrow().get(key).cloned())
        }
        fn config_set(&self, key: &str, value: &str) -> Result<(), DbError> {
            self.configs
                .borrow_mut()
                .insert(key.to_string(), value.to_string());
            Ok(())
        }
        fn config_list(&self) -> Result<Vec<Config>, DbError> {
            Ok(vec![])
        }
    }

    impl crate::SymbolLookup for MockScanStore {
        fn get_file_id(&self, _: &str) -> Result<Option<i64>, DbError> {
            Ok(None)
        }
        fn get_symbol_by_id(&self, _: i64) -> Result<Option<Symbol>, DbError> {
            Ok(None)
        }
        fn search_symbols_by_name(&self, _: &str, _: i64) -> Result<Vec<SymbolResult>, DbError> {
            Ok(vec![])
        }
        fn get_symbols_for_file(&self, _: i64) -> Result<Vec<Symbol>, DbError> {
            Ok(vec![])
        }
        fn get_children(&self, _: i64) -> Result<Vec<Symbol>, DbError> {
            Ok(vec![])
        }
        fn impact_analysis(&self, _: i64, _: i64) -> Result<Vec<crate::ImpactEntry>, DbError> {
            Ok(vec![])
        }
        fn stats(&self) -> Result<(i64, i64, i64), DbError> {
            Ok((0, 0, 0))
        }
    }

    impl ScanStore for MockScanStore {
        fn begin_scan(&self) -> Result<i64, DbError> {
            Ok(1)
        }
        fn complete_scan(&self, _: i64, _: i64, _: i64, _: i64) -> Result<(), DbError> {
            Ok(())
        }
        fn upsert_file(
            &self,
            _: i64,
            _: &str,
            _: &str,
            _: i64,
            _: &str,
            _: u64,
            _: ParseStatus,
        ) -> Result<i64, DbError> {
            Ok(1)
        }
        fn get_file_hash(&self, _: &str) -> Result<Option<String>, DbError> {
            Ok(None)
        }
        fn get_all_file_paths(&self) -> Result<HashMap<String, i64>, DbError> {
            Ok(HashMap::new())
        }
        fn delete_file(&self, _: i64) -> Result<(), DbError> {
            Ok(())
        }
        fn insert_symbol(
            &self,
            _: i64,
            _: Option<i64>,
            _: &str,
            _: SymbolKind,
            _: Option<&str>,
            _: i64,
            _: i64,
            _: i64,
            _: i64,
        ) -> Result<i64, DbError> {
            Ok(1)
        }
        fn delete_symbols_for_file(&self, _: i64) -> Result<(), DbError> {
            Ok(())
        }
        fn insert_dependency(
            &self,
            _: i64,
            _: Option<i64>,
            _: Option<i64>,
            _: Option<i64>,
            _: DepType,
            _: DepLevel,
            _: Option<&str>,
        ) -> Result<i64, DbError> {
            Ok(1)
        }
        fn delete_deps_for_file(&self, _: i64) -> Result<(), DbError> {
            Ok(())
        }
    }

    #[test]
    fn test_compute_config_hash_no_configs() {
        let tmp = tempfile::tempdir().unwrap();
        assert_eq!(compute_config_hash(tmp.path(), TEST_CONFIG_FILES), None);
    }

    #[test]
    fn test_compute_config_hash_with_tsconfig() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("tsconfig.json"), r#"{"strict": true}"#).unwrap();
        let hash = compute_config_hash(tmp.path(), TEST_CONFIG_FILES);
        assert!(hash.is_some());
        assert!(!hash.unwrap().is_empty());
    }

    #[test]
    fn test_compute_config_hash_with_gomod() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(
            tmp.path().join("go.mod"),
            "module example.com/foo\n\ngo 1.21\n",
        )
        .unwrap();
        let hash = compute_config_hash(tmp.path(), TEST_CONFIG_FILES);
        assert!(hash.is_some());
        assert!(!hash.unwrap().is_empty());
    }

    #[test]
    fn test_compute_config_hash_with_pyproject() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("pyproject.toml"), "[tool.pytest]\n").unwrap();
        let hash = compute_config_hash(tmp.path(), TEST_CONFIG_FILES);
        assert!(hash.is_some());
        assert!(!hash.unwrap().is_empty());
    }

    #[test]
    fn test_compute_config_hash_combined() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("tsconfig.json"), r#"{"strict": true}"#).unwrap();
        std::fs::write(tmp.path().join("go.mod"), "module example.com/foo\n").unwrap();
        let combined = compute_config_hash(tmp.path(), TEST_CONFIG_FILES).unwrap();
        // Hash should differ from tsconfig-only
        let tmp2 = tempfile::tempdir().unwrap();
        std::fs::write(tmp2.path().join("tsconfig.json"), r#"{"strict": true}"#).unwrap();
        let single = compute_config_hash(tmp2.path(), TEST_CONFIG_FILES).unwrap();
        assert_ne!(
            combined, single,
            "combined hash should differ from single-file hash"
        );
    }

    #[test]
    fn test_compute_config_hash_deterministic() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("tsconfig.json"), r#"{"strict": true}"#).unwrap();
        let h1 = compute_config_hash(tmp.path(), TEST_CONFIG_FILES).unwrap();
        let h2 = compute_config_hash(tmp.path(), TEST_CONFIG_FILES).unwrap();
        assert_eq!(h1, h2);
    }

    #[test]
    fn test_config_changed_no_config_no_stored() {
        let tmp = tempfile::tempdir().unwrap();
        let db = MockScanStore::new();
        assert!(!config_changed(tmp.path(), &db, TEST_CONFIG_FILES));
    }

    #[test]
    fn test_config_changed_new_config_appeared() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("tsconfig.json"), "{}").unwrap();
        let db = MockScanStore::new();
        assert!(config_changed(tmp.path(), &db, TEST_CONFIG_FILES));
    }

    #[test]
    fn test_config_changed_config_deleted() {
        let tmp = tempfile::tempdir().unwrap();
        let db = MockScanStore::with_config("config_hash", "old_hash_value");
        assert!(config_changed(tmp.path(), &db, TEST_CONFIG_FILES));
    }

    #[test]
    fn test_config_changed_same_hash() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("tsconfig.json"), "{}").unwrap();
        let hash = compute_config_hash(tmp.path(), TEST_CONFIG_FILES).unwrap();
        let db = MockScanStore::with_config("config_hash", &hash);
        assert!(!config_changed(tmp.path(), &db, TEST_CONFIG_FILES));
    }

    #[test]
    fn test_config_changed_different_hash() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("tsconfig.json"), "{}").unwrap();
        let db = MockScanStore::with_config("config_hash", "different_hash");
        assert!(config_changed(tmp.path(), &db, TEST_CONFIG_FILES));
    }

    #[test]
    fn test_config_changed_empty_stored_treated_as_none() {
        let tmp = tempfile::tempdir().unwrap();
        let db = MockScanStore::with_config("config_hash", "");
        // empty stored = None, no tsconfig = None → no change
        assert!(!config_changed(tmp.path(), &db, TEST_CONFIG_FILES));
    }

    #[test]
    fn test_save_config_hash_with_tsconfig() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("tsconfig.json"), r#"{"strict": true}"#).unwrap();
        let db = MockScanStore::new();

        save_config_hash(tmp.path(), &db, TEST_CONFIG_FILES);

        let saved = db.configs.borrow().get("config_hash").cloned();
        assert!(saved.is_some());
        assert!(!saved.unwrap().is_empty());
    }

    #[test]
    fn test_save_config_hash_without_tsconfig() {
        let tmp = tempfile::tempdir().unwrap();
        let db = MockScanStore::new();

        save_config_hash(tmp.path(), &db, TEST_CONFIG_FILES);

        let saved = db.configs.borrow().get("config_hash").cloned();
        assert_eq!(saved, Some("".to_string()));
    }
}