use crate::app::App;
use kanban_domain::Snapshot;
pub trait TuiSnapshot {
fn from_app(app: &App) -> kanban_domain::KanbanResult<Self>
where
Self: Sized;
fn apply_to_app(&self, app: &mut App) -> kanban_domain::KanbanResult<()>;
}
impl TuiSnapshot for Snapshot {
fn from_app(app: &App) -> kanban_domain::KanbanResult<Self> {
app.ctx.snapshot()
}
fn apply_to_app(&self, app: &mut App) -> kanban_domain::KanbanResult<()> {
app.ctx.apply_snapshot(self.clone())?;
if let Some(board_id) = app.selection.active_board_id {
if let Some(board) = self.boards.iter().find(|b| b.id == board_id) {
app.filter.current_sort_field = Some(board.task_sort_field);
app.filter.current_sort_order = Some(board.task_sort_order);
}
}
Ok(())
}
}
#[cfg(test)]
struct AlwaysFailsSnapshotBackend {
inner: kanban_backend_memory::InMemoryStore,
}
#[cfg(test)]
impl AlwaysFailsSnapshotBackend {
fn as_backend() -> std::sync::Arc<dyn kanban_backend::KanbanBackend> {
std::sync::Arc::new(Self {
inner: kanban_backend_memory::InMemoryStore::new(),
})
}
}
#[cfg(test)]
impl kanban_domain::DataStore for AlwaysFailsSnapshotBackend {
fn get_prefix(&self, name: &str) -> kanban_domain::KanbanResult<Option<kanban_domain::Prefix>> {
self.inner.get_prefix(name)
}
fn list_prefixes(&self) -> kanban_domain::KanbanResult<Vec<kanban_domain::Prefix>> {
self.inner.list_prefixes()
}
fn upsert_prefix(&self, prefix: kanban_domain::Prefix) -> kanban_domain::KanbanResult<()> {
self.inner.upsert_prefix(prefix)
}
fn get_board(
&self,
id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Option<kanban_domain::Board>> {
self.inner.get_board(id)
}
fn list_boards(&self) -> kanban_domain::KanbanResult<Vec<kanban_domain::Board>> {
self.inner.list_boards()
}
fn upsert_board(&self, board: kanban_domain::Board) -> kanban_domain::KanbanResult<()> {
self.inner.upsert_board(board)
}
fn delete_board(&self, id: uuid::Uuid) -> kanban_domain::KanbanResult<()> {
self.inner.delete_board(id)
}
fn get_column(
&self,
id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Option<kanban_domain::Column>> {
self.inner.get_column(id)
}
fn list_columns_by_board(
&self,
board_id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Vec<kanban_domain::Column>> {
self.inner.list_columns_by_board(board_id)
}
fn list_all_columns(&self) -> kanban_domain::KanbanResult<Vec<kanban_domain::Column>> {
self.inner.list_all_columns()
}
fn upsert_column(&self, column: kanban_domain::Column) -> kanban_domain::KanbanResult<()> {
self.inner.upsert_column(column)
}
fn delete_column(&self, id: uuid::Uuid) -> kanban_domain::KanbanResult<()> {
self.inner.delete_column(id)
}
fn delete_columns_by_board(&self, board_id: uuid::Uuid) -> kanban_domain::KanbanResult<()> {
self.inner.delete_columns_by_board(board_id)
}
fn get_card(&self, id: uuid::Uuid) -> kanban_domain::KanbanResult<Option<kanban_domain::Card>> {
self.inner.get_card(id)
}
fn list_all_cards(&self) -> kanban_domain::KanbanResult<Vec<kanban_domain::Card>> {
self.inner.list_all_cards()
}
fn list_cards_by_column(
&self,
column_id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Vec<kanban_domain::Card>> {
self.inner.list_cards_by_column(column_id)
}
fn list_cards_by_sprint(
&self,
sprint_id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Vec<kanban_domain::Card>> {
self.inner.list_cards_by_sprint(sprint_id)
}
fn count_cards_in_column(&self, column_id: uuid::Uuid) -> kanban_domain::KanbanResult<usize> {
self.inner.count_cards_in_column(column_id)
}
fn upsert_card(&self, card: kanban_domain::Card) -> kanban_domain::KanbanResult<()> {
self.inner.upsert_card(card)
}
fn delete_card(&self, id: uuid::Uuid) -> kanban_domain::KanbanResult<()> {
self.inner.delete_card(id)
}
fn delete_cards_by_columns(
&self,
column_ids: &[uuid::Uuid],
) -> kanban_domain::KanbanResult<()> {
self.inner.delete_cards_by_columns(column_ids)
}
fn clear_sprint_from_cards(
&self,
sprint_id: uuid::Uuid,
cleared_at: chrono::DateTime<chrono::Utc>,
) -> kanban_domain::KanbanResult<()> {
self.inner.clear_sprint_from_cards(sprint_id, cleared_at)
}
fn count_cards_in_column_excluding(
&self,
column_id: uuid::Uuid,
exclude_ids: &[uuid::Uuid],
) -> kanban_domain::KanbanResult<usize> {
self.inner
.count_cards_in_column_excluding(column_id, exclude_ids)
}
fn get_archived_card(
&self,
card_id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Option<kanban_domain::ArchivedCard>> {
self.inner.get_archived_card(card_id)
}
fn list_archived_cards(&self) -> kanban_domain::KanbanResult<Vec<kanban_domain::ArchivedCard>> {
self.inner.list_archived_cards()
}
fn insert_archived_card(
&self,
ac: kanban_domain::ArchivedCard,
) -> kanban_domain::KanbanResult<()> {
self.inner.insert_archived_card(ac)
}
fn delete_archived_card(&self, card_id: uuid::Uuid) -> kanban_domain::KanbanResult<()> {
self.inner.delete_archived_card(card_id)
}
fn get_sprint(
&self,
id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Option<kanban_domain::Sprint>> {
self.inner.get_sprint(id)
}
fn list_sprints_by_board(
&self,
board_id: uuid::Uuid,
) -> kanban_domain::KanbanResult<Vec<kanban_domain::Sprint>> {
self.inner.list_sprints_by_board(board_id)
}
fn list_all_sprints(&self) -> kanban_domain::KanbanResult<Vec<kanban_domain::Sprint>> {
self.inner.list_all_sprints()
}
fn upsert_sprint(&self, sprint: kanban_domain::Sprint) -> kanban_domain::KanbanResult<()> {
self.inner.upsert_sprint(sprint)
}
fn delete_sprint(&self, id: uuid::Uuid) -> kanban_domain::KanbanResult<()> {
self.inner.delete_sprint(id)
}
fn delete_sprints_by_board(&self, board_id: uuid::Uuid) -> kanban_domain::KanbanResult<()> {
self.inner.delete_sprints_by_board(board_id)
}
fn get_graph(&self) -> kanban_domain::KanbanResult<kanban_domain::DependencyGraph> {
self.inner.get_graph()
}
fn set_graph(&self, graph: kanban_domain::DependencyGraph) -> kanban_domain::KanbanResult<()> {
self.inner.set_graph(graph)
}
fn snapshot(&self) -> kanban_domain::KanbanResult<Snapshot> {
Err(kanban_domain::KanbanError::Database(
"simulated transient read failure".to_string(),
))
}
fn apply_snapshot(&self, snapshot: Snapshot) -> kanban_domain::KanbanResult<()> {
self.inner.apply_snapshot(snapshot)
}
}
#[cfg(test)]
impl kanban_domain::CommandStore for AlwaysFailsSnapshotBackend {
fn append_batch(
&self,
batch: &kanban_domain::CommandBatch,
) -> kanban_domain::KanbanResult<u64> {
self.inner.append_batch(batch)
}
fn batch_count(&self) -> kanban_domain::KanbanResult<u64> {
self.inner.batch_count()
}
fn load_batches(
&self,
offset: u64,
limit: u64,
) -> kanban_domain::KanbanResult<Vec<kanban_domain::CommandBatch>> {
self.inner.load_batches(offset, limit)
}
}
#[cfg(test)]
impl kanban_backend::KanbanBackend for AlwaysFailsSnapshotBackend {
fn as_data_store(&self) -> &dyn kanban_domain::DataStore {
self
}
fn with_transaction(
&self,
f: kanban_backend::TransactionFn<'_>,
) -> kanban_domain::KanbanResult<()> {
self.inner.with_transaction(f)
}
}
#[cfg(test)]
mod tests {
use super::*;
use kanban_domain::{Board, DependencyGraph, SortField};
#[test]
fn test_from_app_propagates_a_failed_snapshot_read() {
let mut app = App::test_default();
app.ctx
.replace_backend(AlwaysFailsSnapshotBackend::as_backend());
let result = Snapshot::from_app(&app);
assert!(
result.is_err(),
"from_app must propagate a failed backend read, not fall back to Snapshot::default()"
);
}
#[test]
fn test_snapshot_serialization() {
use kanban_persistence::{snapshot_from_json_bytes, snapshot_to_json_bytes};
let snapshot = Snapshot {
archived_boards: Vec::new(),
boards: vec![],
columns: vec![],
cards: vec![],
archived_cards: vec![],
sprints: vec![],
graph: DependencyGraph::new(),
prefixes: Vec::new(),
};
let bytes = snapshot_to_json_bytes(&snapshot).unwrap();
let restored = snapshot_from_json_bytes(&bytes).unwrap();
assert_eq!(restored.boards.len(), 0);
}
#[test]
fn test_apply_to_app_syncs_sort_field_from_board() {
let mut board = Board::new("Test", None::<String>);
board.update_task_sort(SortField::Position, kanban_domain::SortOrder::Ascending);
let board_id = board.id;
let snapshot = Snapshot {
archived_boards: Vec::new(),
boards: vec![board],
columns: vec![],
cards: vec![],
archived_cards: vec![],
sprints: vec![],
graph: DependencyGraph::new(),
prefixes: Vec::new(),
};
let mut app = App::test_default();
app.selection.active_board_id = Some(board_id);
app.filter.current_sort_field = Some(SortField::Default);
snapshot.apply_to_app(&mut app).unwrap();
assert_eq!(
app.filter.current_sort_field,
Some(SortField::Position),
"apply_to_app should sync current_sort_field from active board"
);
}
}