1mod 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
71pub enum LabelMatch {
72 #[default]
74 Any,
75 All,
77}
78
79impl LabelMatch {
80 #[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
94pub 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
104async 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
121async 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
134async fn open_board_locked(project_dir: &Path) -> Result<(PathBuf, Backend, Config, BoardLock)> {
142 open_board_locked_with_hook(project_dir, || {}).await
143}
144
145async 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 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 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}