1mod 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
75pub enum LabelMatch {
76 #[default]
78 Any,
79 All,
81}
82
83impl LabelMatch {
84 #[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
98pub 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
108async 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
125async 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
138async fn open_board_locked(project_dir: &Path) -> Result<(PathBuf, Backend, Config, BoardLock)> {
146 open_board_locked_with_hook(project_dir, || {}).await
147}
148
149async 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 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 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}