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, 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
76pub enum LabelMatch {
77 #[default]
79 Any,
80 All,
82}
83
84impl LabelMatch {
85 #[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
99pub 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
109async 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
126async 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
139async fn open_board_locked(project_dir: &Path) -> Result<(PathBuf, Backend, Config, BoardLock)> {
147 open_board_locked_with_hook(project_dir, || {}).await
148}
149
150async 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 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 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}