Skip to main content

pinto/service/
migrate.rs

1//! Migrate board data between the file, Git, and SQLite backends.
2//!
3//! Copy active backlog items and sprints from the configured source backend to the target backend,
4//! then switch the backend setting. Shared serialization preserves the data without loss.
5//!
6//! **The source is non-destructive**: migration never deletes source data; it only changes which
7//! backend the configuration points to. To switch back, migrate in the opposite direction.
8//!
9//! **Recoverable by re-execution**: migration is not transactional, so a mid-run failure can leave
10//! a partial destination. The configuration changes only after all writes succeed, leaving the
11//! current backend usable. Rerunning migration replaces the destination with a fresh mirror. The
12//! whole operation is serialized by `.pinto/.lock`.
13//!
14//! **The destination is replaced as a mirror**: items absent from the source are removed from the
15//! destination. This prevents stale destination data (for example, leftover `tasks/` files after a
16//! file-to-SQLite migration) from reappearing later.
17//!
18//! **Scope**: migrate only active items and sprints. Archived items remain in the source backend;
19//! after switching, pinto cannot reference them through the target backend.
20
21use crate::config::{Config, StorageBackend};
22use crate::error::{Error, Result};
23use crate::storage::{Backend, BacklogItemRepository, BoardLock, SprintRepository};
24use std::path::Path;
25use tokio::fs;
26
27/// Result of [`migrate_storage`].
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum MigrateOutcome {
30    /// Actually migrated (migration source, migration destination, and number of items copied).
31    Migrated {
32        /// Backend to migrate from.
33        from: StorageBackend,
34        /// Backend to migrate to.
35        to: StorageBackend,
36        /// Number of PBIs copied.
37        items: usize,
38        /// Number of sprints copied.
39        sprints: usize,
40    },
41    /// No migration was needed because the target backend is already active.
42    AlreadyUsing(StorageBackend),
43}
44
45/// Migrate from the current backend to `target` and switch the save location of `config.toml`.
46///
47/// Return [`Error::NotInitialized`] when `.pinto/config.toml` does not exist. If `target` is
48/// already active, return [`MigrateOutcome::AlreadyUsing`] without changing anything.
49pub async fn migrate_storage(project_dir: &Path, target: StorageBackend) -> Result<MigrateOutcome> {
50    let board_dir = project_dir.join(".pinto");
51    let config_path = board_dir.join("config.toml");
52    if !fs::try_exists(&config_path)
53        .await
54        .map_err(|e| Error::io(&config_path, &e))?
55    {
56        return Err(Error::NotInitialized { path: board_dir });
57    }
58
59    // Migration rewrites both backends and switches where settings are saved. Serialize the
60    // entire operation so it cannot overlap with another write.
61    let _lock = BoardLock::acquire(&board_dir).await?;
62
63    let mut config = Config::load(&config_path).await?;
64    let from = config.storage.backend;
65    if from == target {
66        return Ok(MigrateOutcome::AlreadyUsing(from));
67    }
68
69    let source = Backend::open_for_write(&board_dir, from).await?;
70    let dest = Backend::open_for_write(&board_dir, target).await?;
71
72    let items = BacklogItemRepository::list(&source).await?;
73    let sprints = SprintRepository::list(&source).await?;
74
75    // Mirror the source's active set. A simple upsert would leave stale destination entries that
76    // could reappear during a later reverse migration, so remove IDs absent from the source first.
77    let keep_items: std::collections::HashSet<_> = items.iter().map(|i| i.id.clone()).collect();
78    for existing in BacklogItemRepository::list(&dest).await? {
79        if !keep_items.contains(&existing.id) {
80            BacklogItemRepository::delete(&dest, &existing.id).await?;
81        }
82    }
83    let keep_sprints: std::collections::HashSet<_> = sprints.iter().map(|s| s.id.clone()).collect();
84    for existing in SprintRepository::list(&dest).await? {
85        if !keep_sprints.contains(&existing.id) {
86            SprintRepository::delete(&dest, &existing.id).await?;
87        }
88    }
89
90    // Copy active PBIs and sprints to the destination. Saving the same IDs makes this idempotent.
91    for item in &items {
92        BacklogItemRepository::save(&dest, item).await?;
93    }
94    for sprint in &sprints {
95        SprintRepository::save(&dest, sprint).await?;
96    }
97
98    // Switch the save destination. Subsequent commands open the target backend.
99    config.storage.backend = target;
100    config.save(&config_path).await?;
101    // The Git repository that owns the board tree must commit the complete migration. When Git is
102    // the source (Git → file/SQLite), committing through `dest` would be a no-op and leave the
103    // backend switch uncommitted; when Git is the target, the destination owns the new tree.
104    let commit_backend = if matches!(&source, Backend::Git(_)) {
105        &source
106    } else {
107        &dest
108    };
109    commit_backend
110        .commit(&format!("pinto: migrate {from} to {target}"))
111        .await?;
112
113    Ok(MigrateOutcome::Migrated {
114        from,
115        to: target,
116        items: items.len(),
117        sprints: sprints.len(),
118    })
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::service::init_board;
125    #[cfg(feature = "sqlite")]
126    use crate::{
127        backlog::ItemId,
128        service::{NewItem, add_item, create_sprint, remove_item},
129        sprint::SprintId,
130    };
131    use tempfile::TempDir;
132
133    #[tokio::test]
134    async fn migrate_uninitialized_board_errors() {
135        let dir = TempDir::new().expect("temp dir");
136        let err = migrate_storage(dir.path(), StorageBackend::Git)
137            .await
138            .expect_err("uninitialized");
139        assert!(matches!(err, Error::NotInitialized { .. }), "got {err:?}");
140    }
141
142    #[tokio::test]
143    async fn migrate_to_current_backend_is_noop() {
144        let dir = TempDir::new().expect("temp dir");
145        init_board(dir.path()).await.expect("init");
146        // Default is file. Transitioning to file does nothing.
147        let outcome = migrate_storage(dir.path(), StorageBackend::File)
148            .await
149            .expect("noop");
150        assert_eq!(outcome, MigrateOutcome::AlreadyUsing(StorageBackend::File));
151    }
152
153    #[cfg(feature = "sqlite")]
154    #[tokio::test]
155    async fn migrate_file_to_sqlite_copies_items_and_sprints_and_flips_config() {
156        let dir = TempDir::new().expect("temp dir");
157        init_board(dir.path()).await.expect("init");
158        add_item(dir.path(), "First", NewItem::default())
159            .await
160            .expect("add");
161        add_item(dir.path(), "Second", NewItem::default())
162            .await
163            .expect("add");
164        create_sprint(
165            dir.path(),
166            &SprintId::new("S-1").unwrap(),
167            "Sprint 1",
168            None,
169            None,
170        )
171        .await
172        .expect("sprint");
173
174        let outcome = migrate_storage(dir.path(), StorageBackend::Sqlite)
175            .await
176            .expect("migrate");
177        assert_eq!(
178            outcome,
179            MigrateOutcome::Migrated {
180                from: StorageBackend::File,
181                to: StorageBackend::Sqlite,
182                items: 2,
183                sprints: 1,
184            }
185        );
186
187        // The configuration now selects SQLite.
188        let config = Config::load(&dir.path().join(".pinto").join("config.toml"))
189            .await
190            .expect("load config");
191        assert_eq!(config.storage.backend, StorageBackend::Sqlite);
192
193        // Subsequent commands open SQLite and can read the migrated data.
194        let items = crate::service::list_items(dir.path(), &crate::service::ListFilter::default())
195            .await
196            .expect("list");
197        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
198        assert_eq!(titles, ["First", "Second"]);
199    }
200
201    #[cfg(feature = "sqlite")]
202    #[tokio::test]
203    async fn migrate_back_does_not_resurrect_archived_items() {
204        // During a file→SQLite→file round trip, an archived item is absent from the active SQLite
205        // set, so the stale file-side task is removed instead of being revived.
206        let dir = TempDir::new().expect("temp dir");
207        init_board(dir.path()).await.expect("init");
208        add_item(dir.path(), "Keep", NewItem::default())
209            .await
210            .expect("add"); // T-1
211        add_item(dir.path(), "Archive me", NewItem::default())
212            .await
213            .expect("add"); // T-2
214
215        migrate_storage(dir.path(), StorageBackend::Sqlite)
216            .await
217            .expect("to sqlite");
218        // Archive T-2 in SQLite; the file-side tasks/T-2.md remains for now.
219        remove_item(dir.path(), &ItemId::new("T", 2), false)
220            .await
221            .expect("archive");
222
223        migrate_storage(dir.path(), StorageBackend::File)
224            .await
225            .expect("back to file");
226
227        let items = crate::service::list_items(dir.path(), &crate::service::ListFilter::default())
228            .await
229            .expect("list");
230        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
231        assert_eq!(titles, ["Keep"], "退避済み T-2 は file 側で復活しない");
232    }
233
234    #[cfg(feature = "sqlite")]
235    #[tokio::test]
236    async fn migrate_sqlite_back_to_file_roundtrips() {
237        let dir = TempDir::new().expect("temp dir");
238        init_board(dir.path()).await.expect("init");
239        add_item(dir.path(), "Only", NewItem::default())
240            .await
241            .expect("add");
242
243        migrate_storage(dir.path(), StorageBackend::Sqlite)
244            .await
245            .expect("to sqlite");
246        // Add one more item in SQLite, then migrate back to the file backend.
247        add_item(dir.path(), "Added on sqlite", NewItem::default())
248            .await
249            .expect("add on sqlite");
250        let outcome = migrate_storage(dir.path(), StorageBackend::File)
251            .await
252            .expect("back to file");
253        assert_eq!(
254            outcome,
255            MigrateOutcome::Migrated {
256                from: StorageBackend::Sqlite,
257                to: StorageBackend::File,
258                items: 2,
259                sprints: 0,
260            }
261        );
262
263        let items = crate::service::list_items(dir.path(), &crate::service::ListFilter::default())
264            .await
265            .expect("list");
266        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
267        assert_eq!(titles, ["Only", "Added on sqlite"]);
268    }
269
270    #[cfg(feature = "sqlite")]
271    #[tokio::test]
272    async fn migrate_rejects_corrupt_sqlite_item_before_writing_to_file() {
273        let dir = TempDir::new().expect("temp dir");
274        init_board(dir.path()).await.expect("init");
275        add_item(dir.path(), "Only", NewItem::default())
276            .await
277            .expect("add");
278        migrate_storage(dir.path(), StorageBackend::Sqlite)
279            .await
280            .expect("to sqlite");
281
282        let outside = dir.path().join("outside-1.md");
283        std::fs::write(&outside, "must survive\n").expect("write sentinel");
284        let db_path = dir.path().join(".pinto/board.sqlite3");
285        let conn = rusqlite::Connection::open(&db_path).expect("open sqlite database");
286        conn.execute("PRAGMA ignore_check_constraints = ON", [])
287            .expect("disable checks for corruption fixture");
288        conn.execute(
289            "UPDATE items SET id = ?1, prefix = ?2 WHERE id = 'T-1'",
290            rusqlite::params!["../outside-1", "../outside"],
291        )
292        .expect("corrupt sqlite row");
293
294        let err = migrate_storage(dir.path(), StorageBackend::File)
295            .await
296            .expect_err("corrupt SQLite item must stop file migration");
297        assert!(
298            err.to_string().contains("invalid item id prefix"),
299            "got {err}"
300        );
301        assert_eq!(
302            std::fs::read_to_string(&outside).expect("sentinel remains readable"),
303            "must survive\n"
304        );
305        assert!(
306            dir.path().join(".pinto/tasks/T-1.md").is_file(),
307            "source file data must remain untouched after rejected migration"
308        );
309    }
310
311    #[cfg(feature = "sqlite")]
312    #[tokio::test]
313    async fn migrate_rejects_file_destination_archive_collision_before_overwriting() {
314        let dir = TempDir::new().expect("temp dir");
315        init_board(dir.path()).await.expect("init");
316        add_item(dir.path(), "Only", NewItem::default())
317            .await
318            .expect("add");
319        migrate_storage(dir.path(), StorageBackend::Sqlite)
320            .await
321            .expect("to sqlite");
322
323        let board = dir.path().join(".pinto");
324        std::fs::create_dir_all(board.join("archive")).expect("create archive");
325        std::fs::copy(board.join("tasks/T-1.md"), board.join("archive/T-1.md"))
326            .expect("create archive collision");
327
328        let err = migrate_storage(dir.path(), StorageBackend::File)
329            .await
330            .expect_err("destination archive collision must stop migration");
331        assert!(err.to_string().contains("duplicate item ID"), "got {err}");
332
333        let config = Config::load(&board.join("config.toml"))
334            .await
335            .expect("load config");
336        assert_eq!(config.storage.backend, StorageBackend::Sqlite);
337        assert!(board.join("tasks/T-1.md").is_file());
338        assert!(board.join("archive/T-1.md").is_file());
339    }
340}