1use super::export::BoardSnapshot;
17use super::open_board_locked;
18use crate::config::Config;
19use crate::error::{Error, Result};
20use crate::storage::{Backend, BacklogItemRepository, SprintRepository, atomic_write};
21use std::path::Path;
22use tokio::fs;
23
24const DOD_FILE: &str = "dod.md";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ImportOutcome {
30 Imported {
32 items: usize,
34 sprints: usize,
36 },
37 Refused {
40 items: usize,
42 sprints: usize,
44 },
45}
46
47pub async fn import_board(
54 project_dir: &Path,
55 snapshot: BoardSnapshot,
56 force: bool,
57) -> Result<ImportOutcome> {
58 let (board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
59 let config_path = board_dir.join("config.toml");
60
61 let config: Config = serde_json::from_value(snapshot.config.clone())
64 .map_err(|error| Error::parse(&config_path, error.to_string()))?;
65 config.validate(&config_path)?;
66
67 let existing_items = BacklogItemRepository::list(&repo).await?;
70 let existing_sprints = SprintRepository::list(&repo).await?;
71 if !force && (!existing_items.is_empty() || !existing_sprints.is_empty()) {
72 return Ok(ImportOutcome::Refused {
73 items: existing_items.len(),
74 sprints: existing_sprints.len(),
75 });
76 }
77
78 let target = Backend::open_for_write(&board_dir, config.storage.backend).await?;
82
83 for item in BacklogItemRepository::list(&target).await? {
86 BacklogItemRepository::delete(&target, &item.id).await?;
87 }
88 for sprint in SprintRepository::list(&target).await? {
89 SprintRepository::delete(&target, &sprint.id).await?;
90 }
91
92 target.save_item_batch(&snapshot.items).await?;
94 for sprint in &snapshot.sprints {
95 SprintRepository::save(&target, sprint).await?;
96 }
97
98 config.save(&config_path).await?;
101
102 let dod_path = board_dir.join(DOD_FILE);
103 match &snapshot.dod {
104 Some(dod) => {
105 let trimmed = dod.trim();
106 if trimmed.is_empty() {
107 remove_if_present(&dod_path).await?;
108 } else {
109 atomic_write(&dod_path, &format!("{trimmed}\n")).await?;
110 }
111 }
112 None => remove_if_present(&dod_path).await?,
113 }
114
115 target
116 .commit(&format!(
117 "pinto: import board ({} items, {} sprints)",
118 snapshot.items.len(),
119 snapshot.sprints.len()
120 ))
121 .await?;
122
123 Ok(ImportOutcome::Imported {
124 items: snapshot.items.len(),
125 sprints: snapshot.sprints.len(),
126 })
127}
128
129async fn remove_if_present(path: &Path) -> Result<()> {
131 match fs::remove_file(path).await {
132 Ok(()) => Ok(()),
133 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
134 Err(error) => Err(Error::io(path, &error)),
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::service::{
142 ListFilter, NewItem, add_item, create_sprint, export_snapshot, init_board, list_items,
143 list_sprints, set_common_dod,
144 };
145 use crate::sprint::SprintId;
146 use tempfile::TempDir;
147
148 async fn populated_snapshot() -> (TempDir, BoardSnapshot) {
150 let dir = TempDir::new().expect("temp dir");
151 init_board(dir.path()).await.expect("init source");
152 add_item(dir.path(), "First", NewItem::default())
153 .await
154 .expect("add first");
155 add_item(dir.path(), "Second", NewItem::default())
156 .await
157 .expect("add second");
158 create_sprint(
159 dir.path(),
160 &SprintId::new("S-1").unwrap(),
161 "Sprint 1",
162 None,
163 None,
164 )
165 .await
166 .expect("sprint");
167 set_common_dod(dir.path(), "- [ ] tests pass")
168 .await
169 .expect("dod");
170 let snapshot = export_snapshot(dir.path()).await.expect("export");
171 (dir, snapshot)
172 }
173
174 #[tokio::test]
175 async fn import_into_empty_board_restores_items_sprints_and_dod() {
176 let (_source, snapshot) = populated_snapshot().await;
177
178 let dest = TempDir::new().expect("temp dir");
179 init_board(dest.path()).await.expect("init dest");
180 let outcome = import_board(dest.path(), snapshot, false)
181 .await
182 .expect("import");
183 assert_eq!(
184 outcome,
185 ImportOutcome::Imported {
186 items: 2,
187 sprints: 1
188 }
189 );
190
191 let items = list_items(dest.path(), &ListFilter::default())
192 .await
193 .expect("list");
194 let titles: Vec<&str> = items.iter().map(|item| item.title.as_str()).collect();
195 assert_eq!(titles, ["First", "Second"]);
196
197 let sprints = list_sprints(dest.path()).await.expect("sprints");
198 assert_eq!(sprints.len(), 1);
199 assert_eq!(sprints[0].id.as_str(), "S-1");
200
201 let dod = crate::service::common_dod(dest.path()).await.expect("dod");
202 assert_eq!(dod.as_deref(), Some("- [ ] tests pass"));
203 }
204
205 #[tokio::test]
206 async fn import_into_non_empty_board_is_refused_without_force() {
207 let (_source, snapshot) = populated_snapshot().await;
208
209 let dest = TempDir::new().expect("temp dir");
210 init_board(dest.path()).await.expect("init dest");
211 add_item(dest.path(), "Existing", NewItem::default())
212 .await
213 .expect("add existing");
214
215 let outcome = import_board(dest.path(), snapshot, false)
216 .await
217 .expect("import call succeeds");
218 assert_eq!(
219 outcome,
220 ImportOutcome::Refused {
221 items: 1,
222 sprints: 0
223 }
224 );
225
226 let items = list_items(dest.path(), &ListFilter::default())
228 .await
229 .expect("list");
230 let titles: Vec<&str> = items.iter().map(|item| item.title.as_str()).collect();
231 assert_eq!(titles, ["Existing"]);
232 }
233
234 #[tokio::test]
235 async fn force_replaces_existing_board_data() {
236 let (_source, snapshot) = populated_snapshot().await;
237
238 let dest = TempDir::new().expect("temp dir");
239 init_board(dest.path()).await.expect("init dest");
240 add_item(dest.path(), "Existing", NewItem::default())
241 .await
242 .expect("add existing");
243
244 let outcome = import_board(dest.path(), snapshot, true)
245 .await
246 .expect("forced import");
247 assert_eq!(
248 outcome,
249 ImportOutcome::Imported {
250 items: 2,
251 sprints: 1
252 }
253 );
254
255 let items = list_items(dest.path(), &ListFilter::default())
256 .await
257 .expect("list");
258 let titles: Vec<&str> = items.iter().map(|item| item.title.as_str()).collect();
259 assert_eq!(titles, ["First", "Second"], "snapshot replaces prior data");
260 }
261
262 #[tokio::test]
263 async fn import_removes_an_existing_dod_when_snapshot_has_none() {
264 let (_source, mut snapshot) = populated_snapshot().await;
265 snapshot.dod = None;
266
267 let dest = TempDir::new().expect("temp dir");
268 init_board(dest.path()).await.expect("init dest");
269 set_common_dod(dest.path(), "- [x] old DoD")
270 .await
271 .expect("old dod");
272
273 import_board(dest.path(), snapshot, true)
274 .await
275 .expect("forced import");
276 assert_eq!(crate::service::common_dod(dest.path()).await.unwrap(), None);
277 }
278
279 #[tokio::test]
280 async fn import_treats_blank_snapshot_dod_as_absent() {
281 let (_source, mut snapshot) = populated_snapshot().await;
282 snapshot.dod = Some(" \n\t ".to_string());
283
284 let dest = TempDir::new().expect("temp dir");
285 init_board(dest.path()).await.expect("init dest");
286 import_board(dest.path(), snapshot, false)
287 .await
288 .expect("import");
289 assert_eq!(crate::service::common_dod(dest.path()).await.unwrap(), None);
290 }
291
292 #[tokio::test]
293 async fn import_uninitialized_board_errors() {
294 let (_source, snapshot) = populated_snapshot().await;
295 let dir = TempDir::new().expect("temp dir");
296 let error = import_board(dir.path(), snapshot, false)
297 .await
298 .expect_err("uninitialized");
299 assert!(
300 matches!(error, Error::NotInitialized { .. }),
301 "got {error:?}"
302 );
303 }
304}