1use dashmap::DashMap;
8use dk_core::Result;
9use sha2::{Digest, Sha256};
10use sqlx::PgPool;
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
17pub enum OverlayEntry {
18 Modified { content: Vec<u8>, hash: String },
20 Added { content: Vec<u8>, hash: String },
22 Deleted,
24}
25
26impl OverlayEntry {
27 pub fn content(&self) -> Option<&[u8]> {
29 match self {
30 Self::Modified { content, .. } | Self::Added { content, .. } => Some(content),
31 Self::Deleted => None,
32 }
33 }
34
35 pub fn hash(&self) -> Option<&str> {
37 match self {
38 Self::Modified { hash, .. } | Self::Added { hash, .. } => Some(hash),
39 Self::Deleted => None,
40 }
41 }
42
43 fn change_type_str(&self) -> &'static str {
45 match self {
46 Self::Modified { .. } => "modified",
47 Self::Added { .. } => "added",
48 Self::Deleted => "deleted",
49 }
50 }
51}
52
53pub struct FileOverlay {
60 entries: DashMap<String, OverlayEntry>,
61 workspace_id: Uuid,
62 db: PgPool,
63}
64
65impl FileOverlay {
66 pub fn new(workspace_id: Uuid, db: PgPool) -> Self {
68 Self {
69 entries: DashMap::new(),
70 workspace_id,
71 db,
72 }
73 }
74
75 pub fn get(&self, path: &str) -> Option<dashmap::mapref::one::Ref<'_, String, OverlayEntry>> {
77 self.entries.get(path)
78 }
79
80 pub fn contains(&self, path: &str) -> bool {
82 self.entries.contains_key(path)
83 }
84
85 pub async fn write(&self, path: &str, content: Vec<u8>, is_new: bool) -> Result<String> {
92 let hash = format!("{:x}", Sha256::digest(&content));
93
94 let entry = if is_new {
95 OverlayEntry::Added {
96 content: content.clone(),
97 hash: hash.clone(),
98 }
99 } else {
100 OverlayEntry::Modified {
101 content: content.clone(),
102 hash: hash.clone(),
103 }
104 };
105
106 let change_type = entry.change_type_str();
107
108 sqlx::query(
110 r#"
111 INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
112 VALUES ($1, $2, $3, $4, $5)
113 ON CONFLICT (workspace_id, file_path) DO UPDATE
114 SET content = EXCLUDED.content,
115 content_hash = EXCLUDED.content_hash,
116 change_type = EXCLUDED.change_type,
117 updated_at = NOW()
118 "#,
119 )
120 .bind(self.workspace_id)
121 .bind(path)
122 .bind(&content)
123 .bind(&hash)
124 .bind(change_type)
125 .execute(&self.db)
126 .await?;
127
128 self.entries.insert(path.to_string(), entry);
129 Ok(hash)
130 }
131
132 pub async fn delete(&self, path: &str) -> Result<()> {
136 let entry = OverlayEntry::Deleted;
137 let change_type = entry.change_type_str();
138
139 sqlx::query(
140 r#"
141 INSERT INTO session_overlay_files (workspace_id, file_path, content, content_hash, change_type)
142 VALUES ($1, $2, '', '', $3)
143 ON CONFLICT (workspace_id, file_path) DO UPDATE
144 SET content = EXCLUDED.content,
145 content_hash = EXCLUDED.content_hash,
146 change_type = EXCLUDED.change_type,
147 updated_at = NOW()
148 "#,
149 )
150 .bind(self.workspace_id)
151 .bind(path)
152 .bind(change_type)
153 .execute(&self.db)
154 .await?;
155
156 self.entries.insert(path.to_string(), entry);
157 Ok(())
158 }
159
160 pub async fn revert(&self, path: &str) -> Result<()> {
162 self.entries.remove(path);
163
164 sqlx::query("DELETE FROM session_overlay_files WHERE workspace_id = $1 AND file_path = $2")
165 .bind(self.workspace_id)
166 .bind(path)
167 .execute(&self.db)
168 .await?;
169
170 Ok(())
171 }
172
173 pub fn list_changes(&self) -> Vec<(String, OverlayEntry)> {
175 self.entries
176 .iter()
177 .map(|r| (r.key().clone(), r.value().clone()))
178 .collect()
179 }
180
181 pub fn list_paths(&self) -> Vec<String> {
183 self.entries.iter().map(|r| r.key().clone()).collect()
184 }
185
186 pub fn len(&self) -> usize {
188 self.entries.len()
189 }
190
191 pub fn is_empty(&self) -> bool {
193 self.entries.is_empty()
194 }
195
196 pub fn total_bytes(&self) -> usize {
198 self.entries
199 .iter()
200 .filter_map(|r| r.value().content().map(|c| c.len()))
201 .sum()
202 }
203
204 pub async fn restore_from_db(&self) -> Result<()> {
208 let rows: Vec<(String, Vec<u8>, String, String)> = sqlx::query_as(
209 r#"
210 SELECT file_path, content, content_hash, change_type
211 FROM session_overlay_files
212 WHERE workspace_id = $1
213 "#,
214 )
215 .bind(self.workspace_id)
216 .fetch_all(&self.db)
217 .await?;
218
219 for (path, content, hash, change_type) in rows {
220 let entry = match change_type.as_str() {
221 "added" => OverlayEntry::Added { content, hash },
222 "deleted" => OverlayEntry::Deleted,
223 _ => OverlayEntry::Modified { content, hash },
224 };
225 self.entries.insert(path, entry);
226 }
227
228 Ok(())
229 }
230}
231
232impl FileOverlay {
235 #[doc(hidden)]
240 pub fn new_inmemory(workspace_id: Uuid) -> Self {
241 let opts = sqlx::postgres::PgConnectOptions::new()
245 .host("__nsi_test_dummy__")
246 .port(1);
247 let pool = sqlx::PgPool::connect_lazy_with(opts);
248 Self {
249 entries: DashMap::new(),
250 workspace_id,
251 db: pool,
252 }
253 }
254
255 #[doc(hidden)]
259 pub fn write_local(&self, path: &str, content: Vec<u8>, is_new: bool) -> String {
260 let hash = format!("{:x}", Sha256::digest(&content));
261
262 let entry = if is_new {
263 OverlayEntry::Added {
264 content,
265 hash: hash.clone(),
266 }
267 } else {
268 OverlayEntry::Modified {
269 content,
270 hash: hash.clone(),
271 }
272 };
273
274 self.entries.insert(path.to_string(), entry);
275 hash
276 }
277
278 #[doc(hidden)]
280 pub fn delete_local(&self, path: &str) {
281 self.entries.insert(path.to_string(), OverlayEntry::Deleted);
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn overlay_entry_content_and_hash() {
291 let entry = OverlayEntry::Modified {
292 content: b"hello".to_vec(),
293 hash: "abc".into(),
294 };
295 assert_eq!(entry.content(), Some(b"hello".as_slice()));
296 assert_eq!(entry.hash(), Some("abc"));
297
298 let deleted = OverlayEntry::Deleted;
299 assert!(deleted.content().is_none());
300 assert!(deleted.hash().is_none());
301 }
302
303 #[test]
304 fn overlay_entry_change_type() {
305 assert_eq!(
306 OverlayEntry::Modified {
307 content: vec![],
308 hash: String::new()
309 }
310 .change_type_str(),
311 "modified"
312 );
313 assert_eq!(
314 OverlayEntry::Added {
315 content: vec![],
316 hash: String::new()
317 }
318 .change_type_str(),
319 "added"
320 );
321 assert_eq!(OverlayEntry::Deleted.change_type_str(), "deleted");
322 }
323}