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