Skip to main content

pinto/service/
undo.rs

1//! Undo the most recent completed board mutation.
2//!
3//! Recovery is backend-specific: only the Git backend records each mutation as a commit, so it can
4//! revert the last one. The historyless backends refuse with an actionable [`crate::error::Error::UndoUnsupported`]
5//! from the backend layer. See `docs/book/src/undo.md` for the feature decision record.
6
7use super::open_board_locked;
8use crate::error::Result;
9use std::path::Path;
10
11/// The result of a successful undo.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct UndoOutcome {
14    /// The subject of the board mutation that was reverted (e.g. `pinto: add T-1`).
15    pub reverted: String,
16}
17
18/// Revert the most recent completed board mutation.
19///
20/// The operation runs under the board write lock, serializing it against other writers just like an
21/// ordinary mutation. On the Git backend it creates a revert commit and returns the reverted
22/// subject; on backends without history it returns [`crate::error::Error::UndoUnsupported`].
23pub async fn undo_last_mutation(project_dir: &Path) -> Result<UndoOutcome> {
24    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
25    let reverted = repo.undo().await?;
26    Ok(UndoOutcome { reverted })
27}
28
29#[cfg(test)]
30mod tests {
31    use super::*;
32    use crate::config::{Config, StorageBackend};
33    use crate::error::Error;
34    use crate::service::{ListFilter, NewItem, add_item, init_board, list_items};
35    use tempfile::TempDir;
36
37    async fn init_with_backend(dir: &Path, backend: StorageBackend) {
38        init_board(dir).await.expect("init");
39        let config_path = dir.join(".pinto/config.toml");
40        let mut config = Config::load(&config_path).await.expect("config");
41        config.storage.backend = backend;
42        config.save(&config_path).await.expect("save config");
43    }
44
45    #[tokio::test]
46    async fn undo_reverts_the_last_git_mutation() {
47        let dir = TempDir::new().expect("temp dir");
48        init_with_backend(dir.path(), StorageBackend::Git).await;
49        let first = add_item(dir.path(), "First", NewItem::default())
50            .await
51            .expect("add");
52        let second = add_item(dir.path(), "Second", NewItem::default())
53            .await
54            .expect("add");
55
56        let outcome = undo_last_mutation(dir.path()).await.expect("undo");
57        assert_eq!(outcome.reverted, format!("pinto: add {}", second.id));
58
59        // The reverted item is gone; the earlier one remains listed.
60        let ids: Vec<_> = list_items(dir.path(), &ListFilter::default())
61            .await
62            .expect("list")
63            .into_iter()
64            .map(|item| item.id)
65            .collect();
66        assert!(ids.contains(&first.id), "first item remains");
67        assert!(!ids.contains(&second.id), "second item reverted");
68    }
69
70    #[tokio::test]
71    async fn undo_on_file_backend_is_unsupported() {
72        let dir = TempDir::new().expect("temp dir");
73        init_with_backend(dir.path(), StorageBackend::File).await;
74        add_item(dir.path(), "First", NewItem::default())
75            .await
76            .expect("add");
77
78        let err = undo_last_mutation(dir.path())
79            .await
80            .expect_err("file backend keeps no history");
81        assert!(
82            matches!(err, Error::UndoUnsupported { backend } if backend == "file"),
83            "expected UndoUnsupported(file)"
84        );
85    }
86}