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