Skip to main content

pinto/service/
import.rs

1//! Restore a complete board from an `export --json` snapshot.
2//!
3//! Import is the inverse of [`crate::service::export_snapshot`]. It rebuilds a board's PBIs,
4//! Sprints, configuration, and common Definition of Done from a [`BoardSnapshot`], the same
5//! structure the export produces. The CLI parses the JSON contract back into that structure, so
6//! this service is agnostic to the wire format.
7//!
8//! **Fail fast on a populated board**: importing into a board that already holds active PBIs or
9//! Sprints returns [`ImportOutcome::Refused`] unless the caller opts into replacement. Replacement
10//! mirrors the snapshot: existing active PBIs and Sprints are removed before the snapshot is
11//! written, so the resulting board reflects the snapshot exactly.
12//!
13//! **Serialized like migration**: the whole operation runs under the board write lock, and the
14//! configuration is switched only after the item and sprint writes succeed.
15
16use 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
24/// File name of the common DoD, stored directly under `.pinto/` (mirrors [`crate::service::dod`]).
25const DOD_FILE: &str = "dod.md";
26
27/// Result of [`import_board`].
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ImportOutcome {
30    /// The snapshot was written. Reports how many PBIs and Sprints were restored.
31    Imported {
32        /// Number of PBIs written from the snapshot.
33        items: usize,
34        /// Number of Sprints written from the snapshot.
35        sprints: usize,
36    },
37    /// The board already held data and no replacement was requested. Reports the existing counts
38    /// that blocked the import so the caller can explain what would be overwritten.
39    Refused {
40        /// Number of active PBIs already on the board.
41        items: usize,
42        /// Number of Sprints already on the board.
43        sprints: usize,
44    },
45}
46
47/// Restore the board in `project_dir` from `snapshot`.
48///
49/// Return [`Error::NotInitialized`] when the board is uninitialized. When the board already holds
50/// active PBIs or Sprints and `force` is false, return [`ImportOutcome::Refused`] without changing
51/// anything. Otherwise mirror the snapshot: remove existing active PBIs and Sprints, write the
52/// snapshot's items and Sprints, overwrite `config.toml`, and set or clear the common DoD.
53pub 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    // Reject unknown fields and structurally invalid configuration before touching the board, so a
62    // malformed snapshot never leaves a half-written restore behind.
63    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    // Emptiness is measured against the board as it is configured now. Refuse to clobber a
68    // populated board unless replacement was explicitly requested.
69    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    // Write to the backend the snapshot's configuration selects. In the common same-backend case
79    // this is the current backend; otherwise the configuration switch below points future reads at
80    // the restored data (like `migrate`).
81    let target = Backend::open_for_write(&board_dir, config.storage.backend).await?;
82
83    // Mirror the snapshot: drop the target's existing active PBIs and Sprints first so items absent
84    // from the snapshot do not survive the restore.
85    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    // Saving an item records its issued ID, so a later `add` never reuses a restored ID.
93    target.save_item_batch(&snapshot.items).await?;
94    for sprint in &snapshot.sprints {
95        SprintRepository::save(&target, sprint).await?;
96    }
97
98    // Switch the save destination only after the writes succeed, keeping the pre-import backend
99    // usable if a write failed.
100    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
129/// Remove a file, treating an already-absent file as success.
130async 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    /// Build a populated source board and return its export snapshot.
149    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        // The board is untouched by a refused import.
227        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}