use std::path::Path;
use crate::port::ScanStore;
use super::hasher;
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) {
combined.push_str(name);
combined.push('\0');
combined.push_str(&content);
combined.push('\0');
}
}
if combined.is_empty() {
None
} else {
Some(hasher::sha256(&combined))
}
}
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();
let stored_hash = stored.filter(|s| !s.is_empty());
match (&stored_hash, ¤t_hash) {
(None, None) => false, (None, Some(_)) => true, (Some(_), None) => true, (Some(old), Some(new)) => old != new,
}
}
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"];
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();
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", "");
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()));
}
}