Skip to main content

pinto/service/
dod.rs

1//! Common DoD (Definition of Done) services.
2//!
3//! Manage the Definition of Done shared by all backlog items. It complements each item's
4//! Acceptance Criteria and is stored as plain Markdown in `.pinto/dod.md` (without frontmatter).
5
6use super::{open_board, open_board_locked};
7use crate::error::{Error, Result};
8use crate::storage::atomic_write;
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12/// File name to save the common DoD (directly under `.pinto/`).
13const DOD_FILE: &str = "dod.md";
14
15/// Load the board's common DoD, returning `None` when the file is absent or empty.
16///
17/// Return the content trimmed of leading and trailing whitespace. Return [`Error::NotInitialized`]
18/// when the board is uninitialized.
19pub async fn common_dod(project_dir: &Path) -> Result<Option<String>> {
20    let (board_dir, _repo, _config) = open_board(project_dir).await?;
21    read_common_dod(&board_dir).await
22}
23
24/// Load the common DoD from an already opened board directory.
25pub(crate) async fn read_common_dod(board_dir: &Path) -> Result<Option<String>> {
26    let path = board_dir.join(DOD_FILE);
27    match fs::read_to_string(&path).await {
28        Ok(text) => {
29            let trimmed = text.trim();
30            Ok((!trimmed.is_empty()).then(|| trimmed.to_string()))
31        }
32        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
33        Err(e) => Err(Error::io(&path, &e)),
34    }
35}
36
37/// Replace the board's common DoD and return the written file path.
38///
39/// Return [`Error::EmptyDod`] when `text` is blank or [`Error::NotInitialized`] when the board is
40/// uninitialized.
41pub async fn set_common_dod(project_dir: &Path, text: &str) -> Result<PathBuf> {
42    let trimmed = text.trim();
43    if trimmed.is_empty() {
44        return Err(Error::EmptyDod);
45    }
46    let (board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
47    let path = board_dir.join(DOD_FILE);
48    // Add a newline at the end to make it Git-friendly (same as existing task files).
49    let body = format!("{trimmed}\n");
50    atomic_write(&path, &body).await?;
51    repo.commit("pinto: update common DoD").await?;
52    Ok(path)
53}
54
55/// Delete a board's common DoD. `true` if it exists, `false` (idempotent) if it does not exist.
56///
57/// [`Error::NotInitialized`] if the board is uninitialized.
58pub async fn clear_common_dod(project_dir: &Path) -> Result<bool> {
59    let (board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
60    let path = board_dir.join(DOD_FILE);
61    match fs::remove_file(&path).await {
62        Ok(()) => {
63            repo.commit("pinto: remove common DoD").await?;
64            Ok(true)
65        }
66        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
67        Err(e) => Err(Error::io(&path, &e)),
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::service::test_support::init_temp;
75
76    #[tokio::test]
77    async fn common_dod_is_none_when_unset() {
78        let dir = init_temp().await;
79        assert_eq!(common_dod(dir.path()).await.unwrap(), None);
80    }
81
82    #[tokio::test]
83    async fn set_then_load_roundtrips() {
84        let dir = init_temp().await;
85        set_common_dod(dir.path(), "- [ ] tests pass\n- [ ] reviewed")
86            .await
87            .expect("set succeeds");
88        assert_eq!(
89            common_dod(dir.path()).await.unwrap().as_deref(),
90            Some("- [ ] tests pass\n- [ ] reviewed"),
91        );
92    }
93
94    #[tokio::test]
95    async fn set_replaces_previous_value() {
96        let dir = init_temp().await;
97        set_common_dod(dir.path(), "old").await.unwrap();
98        set_common_dod(dir.path(), "new").await.unwrap();
99        assert_eq!(
100            common_dod(dir.path()).await.unwrap().as_deref(),
101            Some("new")
102        );
103    }
104
105    #[tokio::test]
106    async fn set_persists_as_plain_markdown_file() {
107        let dir = init_temp().await;
108        let path = set_common_dod(dir.path(), "- [ ] done").await.unwrap();
109        assert_eq!(path, dir.path().join(".pinto").join("dod.md"));
110        let raw = std::fs::read_to_string(&path).expect("file exists");
111        // Plain Markdown with no front matter (one trailing newline).
112        assert!(!raw.starts_with("+++"), "no frontmatter: {raw:?}");
113        assert_eq!(raw, "- [ ] done\n");
114    }
115
116    #[tokio::test]
117    async fn set_rejects_empty_text() {
118        let dir = init_temp().await;
119        let err = set_common_dod(dir.path(), "   \n\t").await.unwrap_err();
120        assert!(matches!(err, Error::EmptyDod), "got {err:?}");
121    }
122
123    #[tokio::test]
124    async fn clear_removes_and_is_idempotent() {
125        let dir = init_temp().await;
126        set_common_dod(dir.path(), "x").await.unwrap();
127        assert!(clear_common_dod(dir.path()).await.unwrap(), "removed");
128        assert_eq!(common_dod(dir.path()).await.unwrap(), None);
129        assert!(
130            !clear_common_dod(dir.path()).await.unwrap(),
131            "already absent"
132        );
133    }
134
135    #[tokio::test]
136    async fn common_dod_without_init_errors() {
137        let dir = tempfile::TempDir::new().unwrap();
138        let err = common_dod(dir.path()).await.unwrap_err();
139        assert!(matches!(err, Error::NotInitialized { .. }), "got {err:?}");
140    }
141}