1use 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum MigrateOutcome {
30 Migrated {
32 from: StorageBackend,
34 to: StorageBackend,
36 items: usize,
38 sprints: usize,
40 },
41 AlreadyUsing(StorageBackend),
43}
44
45pub 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 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 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 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 config.storage.backend = target;
100 config.save(&config_path).await?;
101 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 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 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 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 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"); add_item(dir.path(), "Archive me", NewItem::default())
212 .await
213 .expect("add"); migrate_storage(dir.path(), StorageBackend::Sqlite)
216 .await
217 .expect("to sqlite");
218 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_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}