Skip to main content

pinto/
service.rs

1//! Use case (application service).
2//!
3//! High-level operations called from CLI/TUI. Combine the domain layer and persistence layer.
4
5mod board;
6mod burndown;
7mod commits;
8mod cycletime;
9mod dependency;
10mod dod;
11mod item;
12mod lifecycle;
13mod migrate;
14mod order;
15mod points;
16mod relations;
17mod search;
18mod settings;
19mod sprint;
20mod template;
21#[cfg(test)]
22mod test_support;
23mod velocity;
24mod wip;
25
26use crate::config::Config;
27use crate::error::{Error, Result};
28use crate::storage::{Backend, BoardLock};
29pub use board::{Board, BoardColumn, BoardQuery, SortKey, board};
30pub use burndown::{Burndown, BurndownDay, BurndownMetric, burndown};
31pub use commits::{LinkOutcome, ScanOutcome, link_commits, scan_commits, unlink_commits};
32pub use cycletime::{CycleTimeFilter, CycleTimeReport, DurationSummary, cycle_time};
33pub use dependency::{
34    DependencyOutcome, ItemDetail, add_dependency, item_detail, remove_dependency,
35};
36pub use dod::{clear_common_dod, common_dod, set_common_dod};
37pub use item::{
38    AddItemOutcome, EditOutcome, ItemEdit, ListFilter, NewItem, RebalanceOutcome, RemoveOutcome,
39    ReorderTarget, add_item, add_item_with_outcome, apply_item_edit, edit_item, item_edit_template,
40    list_items, move_item, rebalance, remove_item, reorder_item, show_item,
41};
42pub use lifecycle::{InitOutcome, init_board};
43pub use migrate::{MigrateOutcome, migrate_storage};
44pub use order::{Forest, build_forest, hierarchical, hierarchical_order};
45pub(crate) use points::apply_effective_points;
46pub use search::{SearchFilter, SearchMode};
47pub use settings::{DisplaySettings, TuiSettings, display_settings, tui_settings};
48pub(crate) use sprint::validate_sprint_assignment;
49pub use sprint::{
50    assign_sprint, assign_sprint_by_status, assign_sprint_raw, close_sprint, create_sprint,
51    delete_sprint, edit_sprint, list_sprints, set_sprint_capacity, sprint_capacity, start_sprint,
52    unassign_sprint,
53};
54use std::path::{Path, PathBuf};
55pub use template::template_body;
56use tokio::fs;
57pub use velocity::{VelocityReport, VelocitySprint, velocity};
58pub use wip::{WipViolation, check_wip, wip_violations};
59
60/// Matching mode for a multi-label filter.
61#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
62pub enum LabelMatch {
63    /// Match an item carrying at least one requested label (OR).
64    #[default]
65    Any,
66    /// Match an item carrying every requested label (AND).
67    All,
68}
69
70impl LabelMatch {
71    /// Return whether `item_labels` satisfy the requested labels in this mode.
72    #[must_use]
73    pub fn matches(self, item_labels: &[String], requested: &[String]) -> bool {
74        match self {
75            Self::Any => requested
76                .iter()
77                .any(|requested| item_labels.iter().any(|label| label == requested)),
78            Self::All => requested
79                .iter()
80                .all(|requested| item_labels.iter().any(|label| label == requested)),
81        }
82    }
83}
84
85/// Acquire the board write lock for a caller that needs a consistent board snapshot.
86///
87/// The returned guard releases `.pinto/.lock` when dropped. Read-only commands normally do not
88/// need this helper; snapshot-style workflows use it to keep their copy operation coherent with
89/// ordinary writers.
90pub async fn lock_board(project_dir: &Path) -> Result<BoardLock> {
91    let (board_dir, _) = initialized_board_paths(project_dir).await?;
92    BoardLock::acquire(&board_dir).await
93}
94
95/// Open an initialized board, returning its directory (`.pinto/`), the [`Backend`] selected by
96/// configuration, and the loaded [`Config`].
97///
98/// Returns [`Error::NotInitialized`] when `.pinto/config.toml` is missing. Centralizing this check
99/// (and backend selection) here keeps every service function from repeating it and keeps the service
100/// layer independent of the persistence implementation (file/git/sqlite).
101///
102/// `config.toml` is read exactly once per command and the [`Config`] is returned as-is. Not calling
103/// `Config::load` again in callers halves the I/O and avoids inconsistencies from settings changing
104/// between loads.
105async fn open_board(project_dir: &Path) -> Result<(PathBuf, Backend, Config)> {
106    let (board_dir, config_path) = initialized_board_paths(project_dir).await?;
107    let config = Config::load(&config_path).await?;
108    let repo = Backend::open(&board_dir, config.storage.backend).await?;
109    Ok((board_dir, repo, config))
110}
111
112/// Return the board and configuration paths after the minimal initialization check.
113async fn initialized_board_paths(project_dir: &Path) -> Result<(PathBuf, PathBuf)> {
114    let board_dir = project_dir.join(".pinto");
115    let config_path = board_dir.join("config.toml");
116    if !fs::try_exists(&config_path)
117        .await
118        .map_err(|e| Error::io(&config_path, &e))?
119    {
120        return Err(Error::NotInitialized { path: board_dir });
121    }
122    Ok((board_dir, config_path))
123}
124
125/// Open an initialized board for writing. Returns everything [`open_board`] does, plus the advisory
126/// [`BoardLock`] that serializes writes.
127///
128/// Other processes are excluded only while the returned lock guard is alive, so the caller must keep
129/// it bound until `save` completes (binding it for the rest of the function is enough). This stops
130/// concurrent `pinto` processes (CLI/TUI) from interleaving read-modify-write and losing updates
131/// (last-writer-wins). Read-only operations take no lock and use [`open_board`] instead.
132async fn open_board_locked(project_dir: &Path) -> Result<(PathBuf, Backend, Config, BoardLock)> {
133    open_board_locked_with_hook(project_dir, || {}).await
134}
135
136/// Open a board for writing after an optional pre-lock hook.
137///
138/// The hook is a synchronous test seam placed after the minimal initialization check and before
139/// lock acquisition. It lets concurrency tests prove that a writer is waiting at the lock while
140/// another operation changes the selected backend, without adding timing-based sleeps to the
141/// production path.
142async fn open_board_locked_with_hook<F>(
143    project_dir: &Path,
144    before_lock: F,
145) -> Result<(PathBuf, Backend, Config, BoardLock)>
146where
147    F: FnOnce(),
148{
149    let (board_dir, config_path) = initialized_board_paths(project_dir).await?;
150    before_lock();
151    let lock = BoardLock::acquire(&board_dir).await?;
152    // Backend selection is intentionally inside the lock. A migration may switch config while a
153    // writer is waiting; opening the repository before acquiring the lock would cache the old
154    // backend and write a successful update where the new configuration no longer reads it.
155    let config = Config::load(&config_path).await?;
156    let repo = Backend::open_for_write(&board_dir, config.storage.backend).await?;
157    Ok((board_dir, repo, config, lock))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    #[cfg(feature = "sqlite")]
164    use crate::backlog::{BacklogItem, ItemId, Status};
165    #[cfg(feature = "sqlite")]
166    use crate::rank::Rank;
167    use crate::service::init_board;
168    #[cfg(feature = "sqlite")]
169    use crate::storage::BacklogItemRepository;
170    use crate::storage::StorageBackend;
171    #[cfg(feature = "sqlite")]
172    use chrono::Utc;
173    use std::sync::Arc;
174    use tempfile::TempDir;
175    use tokio::sync::Notify;
176    use tokio::time::{Duration, timeout};
177
178    #[tokio::test]
179    async fn writer_selects_backend_after_waiting_for_the_board_lock() {
180        let dir = TempDir::new().expect("temp dir");
181        init_board(dir.path()).await.expect("init");
182        let board_dir = dir.path().join(".pinto");
183        let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
184
185        let project_dir = dir.path().to_path_buf();
186        let ready = Arc::new(Notify::new());
187        let ready_for_writer = Arc::clone(&ready);
188        let writer = tokio::spawn(async move {
189            open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one()).await
190        });
191        ready.notified().await;
192
193        let config_path = board_dir.join("config.toml");
194        let mut config = Config::load(&config_path).await.expect("config");
195        config.storage.backend = StorageBackend::Git;
196        config.save(&config_path).await.expect("switch backend");
197        drop(held);
198
199        let (_, backend, loaded, _) = writer
200            .await
201            .expect("writer task")
202            .expect("writer opens board");
203        assert!(matches!(backend, Backend::Git(_)));
204        assert_eq!(loaded.storage.backend, StorageBackend::Git);
205    }
206
207    #[tokio::test]
208    async fn read_only_board_open_does_not_wait_for_write_lock() {
209        let dir = TempDir::new().expect("temp dir");
210        init_board(dir.path()).await.expect("init");
211        let board_dir = dir.path().join(".pinto");
212        let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
213
214        let opened = timeout(Duration::from_secs(1), open_board(dir.path()))
215            .await
216            .expect("read-only open should not wait for the write lock")
217            .expect("open board");
218        assert!(matches!(opened.1, Backend::File(_)));
219        drop(held);
220    }
221
222    #[cfg(feature = "sqlite")]
223    #[tokio::test]
224    async fn waiting_writer_saves_to_backend_selected_after_config_switch() {
225        let dir = TempDir::new().expect("temp dir");
226        init_board(dir.path()).await.expect("init");
227        let board_dir = dir.path().join(".pinto");
228        let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
229
230        let ready = Arc::new(Notify::new());
231        let ready_for_writer = Arc::clone(&ready);
232        let project_dir = dir.path().to_path_buf();
233        let writer = tokio::spawn(async move {
234            let (_board_dir, repo, config, _lock) =
235                open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one())
236                    .await?;
237            assert_eq!(config.storage.backend, StorageBackend::Sqlite);
238            let item = BacklogItem::new(
239                ItemId::new("T", 1),
240                "written after migration",
241                Status::new("todo"),
242                Rank::after(None),
243                Utc::now(),
244            )?;
245            BacklogItemRepository::save(&repo, &item).await?;
246            repo.commit("pinto: add T-1").await?;
247            Ok::<_, Error>(item)
248        });
249        ready.notified().await;
250
251        let config_path = board_dir.join("config.toml");
252        let mut config = Config::load(&config_path).await.expect("config");
253        // This is the migration boundary: its copy phase is covered by migrate.rs tests, while
254        // this test holds the lock at the exact point where the waiting writer is blocked.
255        config.storage.backend = StorageBackend::Sqlite;
256        config.save(&config_path).await.expect("switch backend");
257        drop(held);
258
259        let item = writer
260            .await
261            .expect("writer task")
262            .expect("writer saves item");
263        let (_, repo, loaded) = open_board(dir.path()).await.expect("open board");
264        assert_eq!(loaded.storage.backend, StorageBackend::Sqlite);
265        let persisted = BacklogItemRepository::load(&repo, &item.id)
266            .await
267            .expect("item should be visible in the selected backend");
268        assert_eq!(persisted, item);
269    }
270}