1use tokio::sync::{mpsc, oneshot};
8
9use crate::error::TuiError;
10use crate::models::audit::AuditEntry;
11use crate::persistence::index::IndexManager;
12
13#[derive(Debug)]
19pub enum DbCommand {
20 RecordScan {
22 workspace_path: String,
23 workspace_name: String,
24 last_scan_ts: String,
25 scan_duration_ms: i64,
26 asset_count: i64,
27 fs_mtime_at_scan: i64,
28 },
29 RecordGateResult {
31 gate_name: String,
32 status: String,
33 exit_code: Option<i32>,
34 duration_ms: i64,
35 timestamp: String,
36 catalog_hash: String,
37 output_excerpt: Option<String>,
38 },
39 RecordDrift {
41 workspace_path: String,
42 asset_id: String,
43 drift_type: String,
44 first_detected: String,
45 expected_hash: String,
46 actual_hash: String,
47 },
48 RecordCoverageScore {
50 workspace_path: String,
51 workspace_name: String,
52 coverage_score: f64,
53 computed_at: String,
54 },
55 AppendAudit(AuditEntry),
57 UpdateContentHash { path: String, hash: String },
59 Flush(oneshot::Sender<()>),
61 Shutdown,
63}
64
65pub type WriterHandle = mpsc::Sender<DbCommand>;
71
72pub fn spawn_writer(
85 db_path: Option<&str>,
86 channel_capacity: usize,
87) -> Result<WriterHandle, TuiError> {
88 let mgr = match db_path {
89 Some(p) => IndexManager::open(p)?,
90 None => IndexManager::open_in_memory()?,
91 };
92
93 let (tx, mut rx) = mpsc::channel::<DbCommand>(channel_capacity);
94
95 tokio::task::spawn_blocking(move || {
100 let conn = mgr.write_conn();
103
104 while let Some(cmd) = rx.blocking_recv() {
105 match cmd {
106 DbCommand::RecordScan {
107 workspace_path,
108 workspace_name,
109 last_scan_ts,
110 scan_duration_ms,
111 asset_count,
112 fs_mtime_at_scan,
113 } => {
114 let _ = conn.execute(
115 "INSERT OR REPLACE INTO workspace_scans \
116 (workspace_path, workspace_name, last_scan_ts, \
117 scan_duration_ms, asset_count, fs_mtime_at_scan) \
118 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
119 rusqlite::params![
120 workspace_path,
121 workspace_name,
122 last_scan_ts,
123 scan_duration_ms,
124 asset_count,
125 fs_mtime_at_scan,
126 ],
127 );
128 }
129
130 DbCommand::RecordGateResult {
131 gate_name,
132 status,
133 exit_code,
134 duration_ms,
135 timestamp,
136 catalog_hash,
137 output_excerpt,
138 } => {
139 let _ = conn.execute(
140 "INSERT INTO gate_history \
141 (gate_name, status, exit_code, duration_ms, timestamp, \
142 catalog_hash, output_excerpt) \
143 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
144 rusqlite::params![
145 gate_name,
146 status,
147 exit_code,
148 duration_ms,
149 timestamp,
150 catalog_hash,
151 output_excerpt,
152 ],
153 );
154 }
155
156 DbCommand::RecordDrift {
157 workspace_path,
158 asset_id,
159 drift_type,
160 first_detected,
161 expected_hash,
162 actual_hash,
163 } => {
164 let _ = conn.execute(
165 "INSERT INTO drift_history \
166 (workspace_path, asset_id, drift_type, first_detected, \
167 resolved_at, expected_hash, actual_hash) \
168 VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6)",
169 rusqlite::params![
170 workspace_path,
171 asset_id,
172 drift_type,
173 first_detected,
174 expected_hash,
175 actual_hash,
176 ],
177 );
178 }
179
180 DbCommand::RecordCoverageScore {
181 workspace_path,
182 workspace_name,
183 coverage_score,
184 computed_at,
185 } => {
186 let _ = conn.execute(
187 "INSERT OR REPLACE INTO coverage_cache \
188 (workspace_path, workspace_name, coverage_score, computed_at) \
189 VALUES (?1, ?2, ?3, ?4)",
190 rusqlite::params![
191 workspace_path,
192 workspace_name,
193 coverage_score,
194 computed_at,
195 ],
196 );
197 }
198
199 DbCommand::AppendAudit(entry) => {
200 let event_type_str =
201 serde_json::to_string(&entry.event_type).unwrap_or_default();
202 let details_str =
203 serde_json::to_string(&entry.details).unwrap_or_else(|_| "{}".to_string());
204 let _ = conn.execute(
205 "INSERT INTO audit_log \
206 (timestamp, event_type, subject, details, operator, \
207 entry_hash, prev_hash) \
208 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
209 rusqlite::params![
210 entry.timestamp,
211 event_type_str.trim_matches('"'),
212 entry.subject,
213 details_str,
214 entry.operator,
215 entry.entry_hash,
216 entry.prev_hash,
217 ],
218 );
219 }
220
221 DbCommand::UpdateContentHash { path, hash } => {
222 let _ = conn.execute(
223 "INSERT OR REPLACE INTO content_hashes (path, hash, last_checked) \
224 VALUES (?1, ?2, datetime('now'))",
225 rusqlite::params![path, hash],
226 );
227 }
228
229 DbCommand::Flush(ack) => {
230 let _ = ack.send(());
231 }
232
233 DbCommand::Shutdown => {
234 break;
235 }
236 }
237 }
238 });
239
240 Ok(tx)
241}
242
243#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::models::audit::AuditEventType;
251 use crate::persistence::index::IndexManager;
252
253 fn make_audit_entry(id: i64, entry_hash: &str, prev_hash: &str) -> AuditEntry {
254 AuditEntry {
255 id,
256 timestamp: "2025-01-01T00:00:00.000Z".to_string(),
257 event_type: AuditEventType::PolicyEvaluation,
258 subject: "ws".to_string(),
259 details: serde_json::json!({}),
260 operator: "system".to_string(),
261 entry_hash: entry_hash.to_string(),
262 prev_hash: prev_hash.to_string(),
263 }
264 }
265
266 #[tokio::test]
267 async fn spawn_writer_inmemory_flush_roundtrip() {
268 let tx = spawn_writer(None, 32).expect("spawn_writer");
269
270 tx.send(DbCommand::RecordScan {
272 workspace_path: "/ws/test".to_string(),
273 workspace_name: "test".to_string(),
274 last_scan_ts: "2025-01-01T00:00:00Z".to_string(),
275 scan_duration_ms: 100,
276 asset_count: 5,
277 fs_mtime_at_scan: 1000,
278 })
279 .await
280 .unwrap();
281
282 let (ack_tx, ack_rx) = oneshot::channel();
284 tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
285 ack_rx.await.expect("flush ack");
286 }
287
288 #[tokio::test]
289 async fn spawn_writer_sends_audit_entry() {
290 let tx = spawn_writer(None, 32).expect("spawn_writer");
291
292 let entry = make_audit_entry(0, "ehash", "");
293 tx.send(DbCommand::AppendAudit(entry)).await.unwrap();
294
295 let (ack_tx, ack_rx) = oneshot::channel();
296 tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
297 ack_rx.await.expect("flush ack");
298 }
299
300 #[tokio::test]
301 async fn spawn_writer_shutdown_drops_channel() {
302 let tx = spawn_writer(None, 32).expect("spawn_writer");
303 tx.send(DbCommand::Shutdown).await.unwrap();
304 tokio::task::yield_now().await;
307 }
308
309 #[tokio::test]
311 async fn spawn_writer_update_content_hash() {
312 let tx = spawn_writer(None, 32).expect("spawn_writer");
313
314 tx.send(DbCommand::UpdateContentHash {
315 path: "/catalog/agents.json".to_string(),
316 hash: "abc123".to_string(),
317 })
318 .await
319 .unwrap();
320
321 let (ack_tx, ack_rx) = oneshot::channel();
322 tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
323 ack_rx.await.expect("flush ack");
324 }
325
326 #[tokio::test]
328 async fn spawn_writer_record_gate_result() {
329 let tx = spawn_writer(None, 32).expect("spawn_writer");
330
331 tx.send(DbCommand::RecordGateResult {
332 gate_name: "lint".to_string(),
333 status: "pass".to_string(),
334 exit_code: Some(0),
335 duration_ms: 1200,
336 timestamp: "2025-01-01T00:00:00Z".to_string(),
337 catalog_hash: "chash".to_string(),
338 output_excerpt: None,
339 })
340 .await
341 .unwrap();
342
343 let (ack_tx, ack_rx) = oneshot::channel();
344 tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
345 ack_rx.await.expect("flush ack");
346 }
347
348 #[tokio::test]
350 async fn spawn_writer_file_based() {
351 let dir = tempfile::tempdir().expect("tempdir");
352 let path = dir.path().join("writer_test.db");
353 let path_str = path.to_str().expect("utf8 path").to_string();
354
355 let _mgr = IndexManager::open(&path_str).expect("open");
357
358 let tx = spawn_writer(Some(&path_str), 32).expect("spawn writer");
359
360 tx.send(DbCommand::RecordScan {
361 workspace_path: "/ws/file".to_string(),
362 workspace_name: "file-ws".to_string(),
363 last_scan_ts: "2025-01-01T00:00:00Z".to_string(),
364 scan_duration_ms: 200,
365 asset_count: 3,
366 fs_mtime_at_scan: 5000,
367 })
368 .await
369 .unwrap();
370
371 let (ack_tx, ack_rx) = oneshot::channel();
372 tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
373 ack_rx.await.expect("flush ack");
374 }
375}