1use crate::config;
2use crate::AppConfig;
3use kanban_domain::{DataStore, KanbanError};
4use kanban_persistence::{
5 snapshot_from_json_bytes, PersistenceStore, StoreRegistry, StoreSnapshot,
6};
7use std::collections::HashSet;
8use std::sync::Arc;
9
10pub struct StoreManager {
16 registry: Arc<StoreRegistry>,
17}
18
19impl StoreManager {
20 pub fn new(registry: StoreRegistry) -> Self {
23 Self {
24 registry: Arc::new(registry),
25 }
26 }
27
28 pub fn registry(&self) -> &StoreRegistry {
31 &self.registry
32 }
33
34 pub fn has_backends(&self) -> bool {
36 !self.registry.is_empty()
37 }
38
39 pub fn backend_names(&self) -> Vec<&str> {
41 self.registry.backend_names()
42 }
43
44 pub fn is_sqlite(&self, locator: &str) -> bool {
48 match self.detect_backend(locator).as_deref() {
49 Some("sqlite") => true,
50 None => {
51 locator.ends_with(".sqlite")
52 || locator.ends_with(".sqlite3")
53 || locator.ends_with(".db")
54 }
55 _ => false,
56 }
57 }
58
59 pub fn detect_backend(&self, locator: &str) -> Option<String> {
63 if let Some(name) = self.registry.detect_backend(locator) {
64 return Some(name.to_string());
65 }
66 #[cfg(feature = "sqlite")]
67 {
68 let path = std::path::Path::new(locator);
69 if path.exists() {
70 if let Ok(mut f) = std::fs::File::open(path) {
71 use std::io::Read;
72 let mut hdr = [0u8; 16];
73 let n = f.read(&mut hdr).unwrap_or(0);
74 if hdr[..n].starts_with(b"SQLite format 3\0") {
75 return Some("sqlite".to_string());
76 }
77 }
78 }
79 }
80 None
81 }
82
83 pub fn sync_backend_with_file(&self, locator: &str, config: &mut AppConfig) -> bool {
86 if let Some(detected) = self.detect_backend(locator) {
87 if detected != config.effective_storage_backend() {
88 config.storage_backend = Some(detected);
89 return true;
90 }
91 }
92 false
93 }
94
95 pub async fn make_backend(
98 &self,
99 locator: &str,
100 config: &AppConfig,
101 ) -> Result<std::sync::Arc<dyn crate::backend::KanbanBackend>, KanbanError> {
102 if self.is_sqlite(locator) {
103 #[cfg(feature = "sqlite")]
104 {
105 let backend = crate::sqlite_backend::SqliteBackend::open(locator)
106 .await
107 .map_err(|e| KanbanError::Database(e.to_string()))?;
108 return Ok(std::sync::Arc::new(backend));
109 }
110 #[cfg(not(feature = "sqlite"))]
111 return Err(KanbanError::Internal(format!(
112 "path '{}' requires the sqlite feature which is not compiled in",
113 locator
114 )));
115 }
116 let store = self.make_store(config.effective_storage_backend(), locator)?;
117 #[cfg(feature = "json")]
118 return Ok(std::sync::Arc::new(
119 crate::json_backend::JsonDataStore::new(store),
120 ));
121 #[cfg(not(feature = "json"))]
122 Err(KanbanError::Internal(format!(
123 "path '{}' requires the json feature which is not compiled in",
124 locator
125 )))
126 }
127
128 pub fn make_store(
131 &self,
132 backend: &str,
133 locator: &str,
134 ) -> Result<Arc<dyn PersistenceStore + Send + Sync>, KanbanError> {
135 Ok(self.registry.create_store(backend, locator)?)
136 }
137
138 pub fn make_store_with_config(
143 &self,
144 file: Option<&str>,
145 config: &AppConfig,
146 ) -> Result<Arc<dyn PersistenceStore + Send + Sync>, KanbanError> {
147 let locator = match file {
148 Some(path) => path.to_string(),
149 None => config::resolve_storage_location(config),
150 };
151 let backend = self
152 .detect_backend(&locator)
153 .unwrap_or_else(|| config.effective_storage_backend().to_string());
154 self.make_store(&backend, &locator)
155 }
156
157 pub async fn validate_and_load_store(
164 &self,
165 backend: &str,
166 path: &str,
167 ) -> Result<kanban_domain::Snapshot, KanbanError> {
168 if matches!(backend, "sqlite" | "sqlite3" | "db") {
169 #[cfg(feature = "sqlite")]
170 {
171 if !std::path::Path::new(path).exists() {
172 return Err(std::io::Error::new(
173 std::io::ErrorKind::NotFound,
174 format!("Storage file does not exist: {}", path),
175 )
176 .into());
177 }
178 let store = kanban_persistence_sqlite::SqliteStore::open(path).await?;
179 return store.snapshot();
180 }
181 #[cfg(not(feature = "sqlite"))]
182 return Err(KanbanError::validation("sqlite feature not compiled in"));
183 }
184 let store = self.make_store(backend, path)?;
185 if !store.exists().await {
186 return Err(std::io::Error::new(
187 std::io::ErrorKind::NotFound,
188 format!("Storage file does not exist: {}", path),
189 )
190 .into());
191 }
192 let (snapshot, _metadata) = store.load().await?;
193 let data = snapshot_from_json_bytes(&snapshot.data)?;
194 Ok(data)
195 }
196
197 pub async fn export_to_sqlite(
202 &self,
203 export: kanban_domain::export::AllBoardsExport,
204 filename: &str,
205 ) -> Result<(), KanbanError> {
206 #[cfg(feature = "sqlite")]
207 {
208 use kanban_domain::export::BoardImporter;
209 use kanban_domain::{DependencyGraph, Snapshot};
210
211 let entities = BoardImporter::extract_entities(export);
212 let snapshot = Snapshot {
213 boards: entities.boards,
214 columns: entities.columns,
215 cards: entities.cards,
216 archived_cards: entities.archived_cards,
217 sprints: entities.sprints,
218 graph: DependencyGraph::default(),
219 };
220 let store = kanban_persistence_sqlite::SqliteStore::open(filename).await?;
221 store.apply_snapshot(snapshot)?;
222 Ok(())
223 }
224 #[cfg(not(feature = "sqlite"))]
225 {
226 let _ = export;
227 let _ = filename;
228 Err(KanbanError::validation("sqlite feature not compiled in"))
229 }
230 }
231
232 pub async fn migrate_store(
239 &self,
240 from_backend: &str,
241 from_path: &str,
242 to_backend: &str,
243 to_path: &str,
244 ) -> Result<(), KanbanError> {
245 let from = std::path::Path::new(from_path);
246 let to = std::path::Path::new(to_path);
247 if !from.exists() {
248 return Err(std::io::Error::new(
249 std::io::ErrorKind::NotFound,
250 format!("Source file not found: {}", from.display()),
251 )
252 .into());
253 }
254 if to.exists() {
255 return Err(std::io::Error::new(
256 std::io::ErrorKind::AlreadyExists,
257 format!(
258 "Destination already exists: {}. Remove it first or use a different path.",
259 to.display()
260 ),
261 )
262 .into());
263 }
264
265 let mut store_snapshot: StoreSnapshot = match from_backend {
267 "sqlite" | "sqlite3" | "db" => {
268 #[cfg(feature = "sqlite")]
269 {
270 use kanban_persistence::PersistenceMetadata;
271 let store = kanban_persistence_sqlite::SqliteStore::open(from_path).await?;
272 let snapshot = store.snapshot()?;
273 let data = kanban_persistence::snapshot_to_json_bytes(&snapshot)?;
274 StoreSnapshot {
275 data,
276 metadata: PersistenceMetadata::new(uuid::Uuid::new_v4()),
277 }
278 }
279 #[cfg(not(feature = "sqlite"))]
280 return Err(KanbanError::validation("sqlite feature not compiled in"));
281 }
282 _ => {
283 let source = self.make_store(from_backend, from_path)?;
284 let (snap, _) = source.load().await?;
285 snap
286 }
287 };
288
289 repair_snapshot_fks(&mut store_snapshot)?;
290
291 match to_backend {
293 "sqlite" | "sqlite3" | "db" => {
294 #[cfg(feature = "sqlite")]
295 {
296 let repaired = snapshot_from_json_bytes(&store_snapshot.data)?;
297 let store = kanban_persistence_sqlite::SqliteStore::open(to_path).await?;
298 let outcome = store.apply_snapshot(repaired.clone());
299 store.close().await;
300 drop(store);
301 if let Err(e) = outcome {
302 cleanup_destination_files(to_path).await;
303 return Err(e);
304 }
305 }
306 #[cfg(not(feature = "sqlite"))]
307 return Err(KanbanError::validation("sqlite feature not compiled in"));
308 }
309 _ => {
310 let target = self.make_store(to_backend, to_path)?;
311 let outcome = target.save(store_snapshot).await;
312 target.close().await;
313 drop(target);
314 if let Err(e) = outcome {
315 cleanup_destination_files(to_path).await;
316 return Err(e.into());
317 }
318 }
319 }
320 Ok(())
321 }
322}
323
324impl Clone for StoreManager {
325 fn clone(&self) -> Self {
326 Self {
327 registry: Arc::clone(&self.registry),
328 }
329 }
330}
331
332async fn remove_file_with_windows_retry(path: &std::path::Path) {
337 for delay_ms in [0u64, 50, 100, 200, 400] {
338 if delay_ms > 0 {
339 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
340 }
341 if std::fs::remove_file(path).is_ok() {
342 return;
343 }
344 if !path.exists() {
345 return;
346 }
347 }
348 tracing::warn!(
349 path = %path.display(),
350 "failed to remove file after retry backoff; orphan may remain on disk"
351 );
352}
353
354async fn cleanup_destination_files(to_path: &str) {
359 remove_file_with_windows_retry(std::path::Path::new(to_path)).await;
360 let wal = format!("{}-wal", to_path);
361 let shm = format!("{}-shm", to_path);
362 remove_file_with_windows_retry(std::path::Path::new(&wal)).await;
363 remove_file_with_windows_retry(std::path::Path::new(&shm)).await;
364}
365
366fn repair_snapshot_fks(snapshot: &mut StoreSnapshot) -> Result<(), KanbanError> {
367 let mut data: serde_json::Value = serde_json::from_slice(&snapshot.data).map_err(|e| {
368 KanbanError::validation(format!("Failed to parse snapshot for FK repair: {e}"))
369 })?;
370
371 let valid_columns: HashSet<String> = data["columns"]
372 .as_array()
373 .map(|arr| {
374 arr.iter()
375 .filter_map(|c| c["id"].as_str().map(String::from))
376 .collect()
377 })
378 .unwrap_or_default();
379
380 let valid_sprints: HashSet<String> = data["sprints"]
381 .as_array()
382 .map(|arr| {
383 arr.iter()
384 .filter_map(|s| s["id"].as_str().map(String::from))
385 .collect()
386 })
387 .unwrap_or_default();
388
389 let fallback_column: Option<String> = data["columns"].as_array().and_then(|arr| {
390 arr.iter()
391 .min_by_key(|c| c["position"].as_i64().unwrap_or(i64::MAX))
392 .and_then(|c| c["id"].as_str())
393 .map(String::from)
394 });
395
396 if let Some(cards) = data["cards"].as_array_mut() {
397 for card in cards.iter_mut() {
398 fix_card_fks(
399 card,
400 &valid_columns,
401 &valid_sprints,
402 fallback_column.as_deref(),
403 );
404 }
405 }
406
407 if let Some(archived) = data["archived_cards"].as_array_mut() {
408 for entry in archived.iter_mut() {
409 if let Some(card) = entry.get_mut("card") {
410 fix_card_fks(
411 card,
412 &valid_columns,
413 &valid_sprints,
414 fallback_column.as_deref(),
415 );
416 }
417 }
418 }
419
420 snapshot.data = serde_json::to_vec(&data).map_err(|e| {
421 KanbanError::validation(format!("Failed to serialize repaired snapshot: {e}"))
422 })?;
423
424 Ok(())
425}
426
427fn fix_card_fks(
428 card: &mut serde_json::Value,
429 valid_columns: &HashSet<String>,
430 valid_sprints: &HashSet<String>,
431 fallback_column: Option<&str>,
432) {
433 if let Some(sprint_id) = card["sprint_id"].as_str() {
434 if !valid_sprints.contains(sprint_id) {
435 card["sprint_id"] = serde_json::Value::Null;
436 }
437 }
438 if let Some(col_id) = card["column_id"].as_str() {
439 if !valid_columns.contains(col_id) {
440 if let Some(fb) = fallback_column {
441 card["column_id"] = serde_json::Value::String(fb.to_string());
442 }
443 }
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use kanban_persistence::StoreRegistry;
451 use tempfile::tempdir;
452
453 fn make_sm() -> StoreManager {
454 let mut registry = StoreRegistry::new();
455 #[cfg(feature = "sqlite")]
456 registry.register(Box::new(kanban_persistence_sqlite::SqliteStoreFactory));
457 #[cfg(feature = "json")]
458 registry.register(Box::new(kanban_persistence_json::JsonStoreFactory));
459 StoreManager::new(registry)
460 }
461
462 #[tokio::test(flavor = "multi_thread")]
463 async fn test_make_backend_json_path_returns_json_data_store() {
464 let dir = tempdir().unwrap();
465 let path = dir.path().join("test.json");
466 let sm = make_sm();
467 let cfg = AppConfig::default();
468 let backend = sm.make_backend(path.to_str().unwrap(), &cfg).await.unwrap();
469 assert!(!backend.needs_flush(), "new JSON backend starts clean");
470 assert!(
471 backend.needs_save_worker(),
472 "JSON backend requires a background flush worker"
473 );
474 }
475
476 #[cfg(feature = "sqlite")]
477 mod sqlite_backend_tests {
478 use super::*;
479
480 #[tokio::test(flavor = "multi_thread")]
481 async fn test_make_backend_sqlite_path_returns_sqlite_store() {
482 let dir = tempdir().unwrap();
483 let path = dir.path().join("test.sqlite");
484 let sm = make_sm();
485 let cfg = AppConfig::default();
486 let backend = sm.make_backend(path.to_str().unwrap(), &cfg).await.unwrap();
487 assert!(!backend.needs_flush(), "new SQLite backend starts clean");
488 assert!(
489 !backend.needs_save_worker(),
490 "SQLite backend is write-through and does not need a save worker"
491 );
492 }
493
494 #[tokio::test(flavor = "multi_thread")]
495 async fn test_make_backend_detects_sqlite_by_magic_bytes() {
496 let dir = tempdir().unwrap();
497 let path = dir.path().join("noext");
498
499 kanban_persistence_sqlite::SqliteStore::open(path.to_str().unwrap())
502 .await
503 .unwrap();
504
505 let sm = make_sm();
506 let cfg = AppConfig::default();
507 let backend = sm.make_backend(path.to_str().unwrap(), &cfg).await.unwrap();
508 assert!(
509 !backend.needs_save_worker(),
510 "magic-byte SQLite detection should yield a write-through backend"
511 );
512 let boards = backend.list_boards().unwrap();
513 assert!(boards.is_empty());
514 }
515
516 #[tokio::test(flavor = "multi_thread")]
517 async fn test_make_backend_detects_json_by_content() {
518 use kanban_persistence::{PersistenceMetadata, PersistenceStore, StoreSnapshot};
519 let dir = tempdir().unwrap();
520 let path = dir.path().join("noext");
521
522 {
525 let jfs = kanban_persistence_json::JsonFileStore::new(&path);
526 let snap = kanban_domain::Snapshot::new();
527 let data = kanban_persistence::snapshot_to_json_bytes(&snap).unwrap();
528 let meta = PersistenceMetadata::new(uuid::Uuid::new_v4());
529 jfs.save(StoreSnapshot {
530 data,
531 metadata: meta,
532 })
533 .await
534 .unwrap();
535 }
536
537 let sm = make_sm();
538 let cfg = AppConfig::default();
539 let backend = sm.make_backend(path.to_str().unwrap(), &cfg).await.unwrap();
540 assert!(
541 backend.needs_save_worker(),
542 "content-sniffed JSON backend requires a save worker"
543 );
544 let boards = backend.list_boards().unwrap();
545 assert!(boards.is_empty());
546 }
547 }
548}