use super::export::BoardSnapshot;
use super::open_board_locked;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::storage::{Backend, BacklogItemRepository, SprintRepository, atomic_write};
use std::path::Path;
use tokio::fs;
const DOD_FILE: &str = "dod.md";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportOutcome {
Imported {
items: usize,
sprints: usize,
},
Refused {
items: usize,
sprints: usize,
},
}
pub async fn import_board(
project_dir: &Path,
snapshot: BoardSnapshot,
force: bool,
) -> Result<ImportOutcome> {
let (board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let config_path = board_dir.join("config.toml");
let config: Config = serde_json::from_value(snapshot.config.clone())
.map_err(|error| Error::parse(&config_path, error.to_string()))?;
config.validate(&config_path)?;
let existing_items = BacklogItemRepository::list(&repo).await?;
let existing_sprints = SprintRepository::list(&repo).await?;
if !force && (!existing_items.is_empty() || !existing_sprints.is_empty()) {
return Ok(ImportOutcome::Refused {
items: existing_items.len(),
sprints: existing_sprints.len(),
});
}
let target = Backend::open_for_write(&board_dir, config.storage.backend).await?;
for item in BacklogItemRepository::list(&target).await? {
BacklogItemRepository::delete(&target, &item.id).await?;
}
for sprint in SprintRepository::list(&target).await? {
SprintRepository::delete(&target, &sprint.id).await?;
}
target.save_item_batch(&snapshot.items).await?;
for sprint in &snapshot.sprints {
SprintRepository::save(&target, sprint).await?;
}
config.save(&config_path).await?;
let dod_path = board_dir.join(DOD_FILE);
match &snapshot.dod {
Some(dod) => {
let trimmed = dod.trim();
if trimmed.is_empty() {
remove_if_present(&dod_path).await?;
} else {
atomic_write(&dod_path, &format!("{trimmed}\n")).await?;
}
}
None => remove_if_present(&dod_path).await?,
}
target
.commit(&format!(
"pinto: import board ({} items, {} sprints)",
snapshot.items.len(),
snapshot.sprints.len()
))
.await?;
Ok(ImportOutcome::Imported {
items: snapshot.items.len(),
sprints: snapshot.sprints.len(),
})
}
async fn remove_if_present(path: &Path) -> Result<()> {
match fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(Error::io(path, &error)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::{
ListFilter, NewItem, add_item, create_sprint, export_snapshot, init_board, list_items,
list_sprints, set_common_dod,
};
use crate::sprint::SprintId;
use tempfile::TempDir;
async fn populated_snapshot() -> (TempDir, BoardSnapshot) {
let dir = TempDir::new().expect("temp dir");
init_board(dir.path()).await.expect("init source");
add_item(dir.path(), "First", NewItem::default())
.await
.expect("add first");
add_item(dir.path(), "Second", NewItem::default())
.await
.expect("add second");
create_sprint(
dir.path(),
&SprintId::new("S-1").unwrap(),
"Sprint 1",
None,
None,
)
.await
.expect("sprint");
set_common_dod(dir.path(), "- [ ] tests pass")
.await
.expect("dod");
let snapshot = export_snapshot(dir.path()).await.expect("export");
(dir, snapshot)
}
#[tokio::test]
async fn import_into_empty_board_restores_items_sprints_and_dod() {
let (_source, snapshot) = populated_snapshot().await;
let dest = TempDir::new().expect("temp dir");
init_board(dest.path()).await.expect("init dest");
let outcome = import_board(dest.path(), snapshot, false)
.await
.expect("import");
assert_eq!(
outcome,
ImportOutcome::Imported {
items: 2,
sprints: 1
}
);
let items = list_items(dest.path(), &ListFilter::default())
.await
.expect("list");
let titles: Vec<&str> = items.iter().map(|item| item.title.as_str()).collect();
assert_eq!(titles, ["First", "Second"]);
let sprints = list_sprints(dest.path()).await.expect("sprints");
assert_eq!(sprints.len(), 1);
assert_eq!(sprints[0].id.as_str(), "S-1");
let dod = crate::service::common_dod(dest.path()).await.expect("dod");
assert_eq!(dod.as_deref(), Some("- [ ] tests pass"));
}
#[tokio::test]
async fn import_into_non_empty_board_is_refused_without_force() {
let (_source, snapshot) = populated_snapshot().await;
let dest = TempDir::new().expect("temp dir");
init_board(dest.path()).await.expect("init dest");
add_item(dest.path(), "Existing", NewItem::default())
.await
.expect("add existing");
let outcome = import_board(dest.path(), snapshot, false)
.await
.expect("import call succeeds");
assert_eq!(
outcome,
ImportOutcome::Refused {
items: 1,
sprints: 0
}
);
let items = list_items(dest.path(), &ListFilter::default())
.await
.expect("list");
let titles: Vec<&str> = items.iter().map(|item| item.title.as_str()).collect();
assert_eq!(titles, ["Existing"]);
}
#[tokio::test]
async fn force_replaces_existing_board_data() {
let (_source, snapshot) = populated_snapshot().await;
let dest = TempDir::new().expect("temp dir");
init_board(dest.path()).await.expect("init dest");
add_item(dest.path(), "Existing", NewItem::default())
.await
.expect("add existing");
let outcome = import_board(dest.path(), snapshot, true)
.await
.expect("forced import");
assert_eq!(
outcome,
ImportOutcome::Imported {
items: 2,
sprints: 1
}
);
let items = list_items(dest.path(), &ListFilter::default())
.await
.expect("list");
let titles: Vec<&str> = items.iter().map(|item| item.title.as_str()).collect();
assert_eq!(titles, ["First", "Second"], "snapshot replaces prior data");
}
#[tokio::test]
async fn import_removes_an_existing_dod_when_snapshot_has_none() {
let (_source, mut snapshot) = populated_snapshot().await;
snapshot.dod = None;
let dest = TempDir::new().expect("temp dir");
init_board(dest.path()).await.expect("init dest");
set_common_dod(dest.path(), "- [x] old DoD")
.await
.expect("old dod");
import_board(dest.path(), snapshot, true)
.await
.expect("forced import");
assert_eq!(crate::service::common_dod(dest.path()).await.unwrap(), None);
}
#[tokio::test]
async fn import_treats_blank_snapshot_dod_as_absent() {
let (_source, mut snapshot) = populated_snapshot().await;
snapshot.dod = Some(" \n\t ".to_string());
let dest = TempDir::new().expect("temp dir");
init_board(dest.path()).await.expect("init dest");
import_board(dest.path(), snapshot, false)
.await
.expect("import");
assert_eq!(crate::service::common_dod(dest.path()).await.unwrap(), None);
}
#[tokio::test]
async fn import_uninitialized_board_errors() {
let (_source, snapshot) = populated_snapshot().await;
let dir = TempDir::new().expect("temp dir");
let error = import_board(dir.path(), snapshot, false)
.await
.expect_err("uninitialized");
assert!(
matches!(error, Error::NotInitialized { .. }),
"got {error:?}"
);
}
}