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    use crate::backlog::{BacklogItem, ItemId, Status};
164    use crate::rank::Rank;
165    use crate::service::init_board;
166    use crate::storage::{BacklogItemRepository, StorageBackend};
167    use chrono::Utc;
168    use std::sync::Arc;
169    use tempfile::TempDir;
170    use tokio::sync::Notify;
171    use tokio::time::{Duration, timeout};
172
173    #[tokio::test]
174    async fn writer_selects_backend_after_waiting_for_the_board_lock() {
175        let dir = TempDir::new().expect("temp dir");
176        init_board(dir.path()).await.expect("init");
177        let board_dir = dir.path().join(".pinto");
178        let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
179
180        let project_dir = dir.path().to_path_buf();
181        let ready = Arc::new(Notify::new());
182        let ready_for_writer = Arc::clone(&ready);
183        let writer = tokio::spawn(async move {
184            open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one()).await
185        });
186        ready.notified().await;
187
188        let config_path = board_dir.join("config.toml");
189        let mut config = Config::load(&config_path).await.expect("config");
190        config.storage.backend = StorageBackend::Git;
191        config.save(&config_path).await.expect("switch backend");
192        drop(held);
193
194        let (_, backend, loaded, _) = writer
195            .await
196            .expect("writer task")
197            .expect("writer opens board");
198        assert!(matches!(backend, Backend::Git(_)));
199        assert_eq!(loaded.storage.backend, StorageBackend::Git);
200    }
201
202    #[tokio::test]
203    async fn read_only_board_open_does_not_wait_for_write_lock() {
204        let dir = TempDir::new().expect("temp dir");
205        init_board(dir.path()).await.expect("init");
206        let board_dir = dir.path().join(".pinto");
207        let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
208
209        let opened = timeout(Duration::from_secs(1), open_board(dir.path()))
210            .await
211            .expect("read-only open should not wait for the write lock")
212            .expect("open board");
213        assert!(matches!(opened.1, Backend::File(_)));
214        drop(held);
215    }
216
217    #[cfg(feature = "sqlite")]
218    #[tokio::test]
219    async fn waiting_writer_saves_to_backend_selected_after_config_switch() {
220        let dir = TempDir::new().expect("temp dir");
221        init_board(dir.path()).await.expect("init");
222        let board_dir = dir.path().join(".pinto");
223        let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
224
225        let ready = Arc::new(Notify::new());
226        let ready_for_writer = Arc::clone(&ready);
227        let project_dir = dir.path().to_path_buf();
228        let writer = tokio::spawn(async move {
229            let (_board_dir, repo, config, _lock) =
230                open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one())
231                    .await?;
232            assert_eq!(config.storage.backend, StorageBackend::Sqlite);
233            let item = BacklogItem::new(
234                ItemId::new("T", 1),
235                "written after migration",
236                Status::new("todo"),
237                Rank::after(None),
238                Utc::now(),
239            )?;
240            BacklogItemRepository::save(&repo, &item).await?;
241            repo.commit("pinto: add T-1").await?;
242            Ok::<_, Error>(item)
243        });
244        ready.notified().await;
245
246        let config_path = board_dir.join("config.toml");
247        let mut config = Config::load(&config_path).await.expect("config");
248        // This is the migration boundary: its copy phase is covered by migrate.rs tests, while
249        // this test holds the lock at the exact point where the waiting writer is blocked.
250        config.storage.backend = StorageBackend::Sqlite;
251        config.save(&config_path).await.expect("switch backend");
252        drop(held);
253
254        let item = writer
255            .await
256            .expect("writer task")
257            .expect("writer saves item");
258        let (_, repo, loaded) = open_board(dir.path()).await.expect("open board");
259        assert_eq!(loaded.storage.backend, StorageBackend::Sqlite);
260        let persisted = BacklogItemRepository::load(&repo, &item.id)
261            .await
262            .expect("item should be visible in the selected backend");
263        assert_eq!(persisted, item);
264    }
265}