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