Skip to main content

kanban_service/
store_manager.rs

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
10/// Owns the `StoreRegistry` and exposes the high-level operations that used
11/// to live as free functions in `kanban_service`. Callers (the CLI, TUI, MCP)
12/// construct a `StoreManager` with whichever factories they want available,
13/// then thread it through request handlers — inverting the old model where
14/// `kanban-service` hard-coded `default_registry()`.
15pub struct StoreManager {
16    registry: Arc<StoreRegistry>,
17}
18
19impl StoreManager {
20    /// Wraps `registry` in an `Arc`. Cloning a `StoreManager` is cheap —
21    /// all clones share the same underlying registry.
22    pub fn new(registry: StoreRegistry) -> Self {
23        Self {
24            registry: Arc::new(registry),
25        }
26    }
27
28    /// Returns a reference to the underlying `StoreRegistry`.
29    /// Useful for introspection and testing.
30    pub fn registry(&self) -> &StoreRegistry {
31        &self.registry
32    }
33
34    /// Returns `true` if at least one backend factory is registered.
35    pub fn has_backends(&self) -> bool {
36        !self.registry.is_empty()
37    }
38
39    /// Returns the names of all registered factories in registration order.
40    pub fn backend_names(&self) -> Vec<&str> {
41        self.registry.backend_names()
42    }
43
44    /// Returns `true` if `locator` points to a SQLite database — either
45    /// because `detect_backend` recognised it as `"sqlite"`, or because the
46    /// file extension matches one of the conventional SQLite extensions.
47    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    /// Pattern-matches `locator` against all registered factories and returns
60    /// the name of the first match. For existing SQLite files, detects by
61    /// magic bytes even when no SQLite factory is in the registry.
62    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    /// Updates `config.storage_backend` to match the backend inferred from
84    /// `locator`. Returns `true` if the config value changed.
85    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    /// Creates a [`KanbanBackend`] for `locator`, selecting SQLite or JSON
96    /// automatically from the file content / extension.
97    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    /// Creates a `PersistenceStore` for the named `backend` at `locator`.
129    /// Returns an error if `backend` is not registered in this manager.
130    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    /// Creates a store from an explicit file locator, or falls back to the
139    /// storage location in `config` when `file` is `None`. The backend is
140    /// inferred from the locator; if no factory matches, `config`'s backend
141    /// is used as a fallback.
142    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    /// Creates a store for `path`, verifies the file exists, then loads and
158    /// deserializes the snapshot. Returns an error if the file is missing or
159    /// the data cannot be parsed.
160    ///
161    /// For `.sqlite`/`.db` files, bypasses the registry and uses `SqliteStore`
162    /// directly.
163    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    /// Exports a board selection to a new SQLite file via `SqliteStore`.
198    ///
199    /// **Note:** The dependency graph is not part of the `AllBoardsExport` format
200    /// and will not be present in the exported file.
201    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    /// Copies a snapshot from one backend/path pair to another, repairing
233    /// any dangling foreign keys in the process. Rolls back (deletes the
234    /// partial destination file) on failure.
235    ///
236    /// SQLite source/destination are handled directly via `SqliteStore`;
237    /// JSON and other registry-backed backends go through the `StoreRegistry`.
238    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        // Load snapshot from source into a StoreSnapshot (JSON bytes) for FK repair.
266        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        // Save to destination
292        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
332/// Best-effort `remove_file` that retries with linear backoff. SQLite on
333/// Windows can briefly hold a file handle even after `PersistenceStore::close`
334/// returns, because the OS-level handle release is asynchronous and lags the
335/// pool's synchronization. POSIX always succeeds on the first try.
336async 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
354/// Remove the main destination and its SQLite WAL/SHM sidecars (best-effort).
355/// The sidecars are named `<path>-wal` and `<path>-shm` regardless of the
356/// main file's extension, so they're constructed by appending to the raw
357/// path string rather than via `Path::with_extension`.
358async 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            // Create a real SQLite file with no extension so the registry can
500            // detect it via magic bytes.
501            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            // Write a valid JSON envelope file with no extension so the registry
523            // detects it as JSON via content sniffing (first byte is '{').
524            {
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}