1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
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,
/// Supported files that could not be read, in path order.
pub unreadable: Vec<PathBuf>,
}
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;
let mut unreadable = Vec::new();
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);
unreadable.push(rel.clone());
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;
// Sorted, so the order deletions reach the writer does not depend on hash order.
let mut deleted: Vec<_> = db_map.into_iter().collect();
deleted.sort_by(|a, b| a.0.cmp(&b.0));
for (del_path, (file_id, _)) in deleted {
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,
unreadable,
})
}
}