1mod board;
6mod burndown;
7mod commits;
8mod cycletime;
9mod dependency;
10mod dod;
11mod item;
12mod lifecycle;
13mod migrate;
14mod order;
15mod points;
16mod relations;
17mod search;
18mod settings;
19mod sprint;
20mod template;
21#[cfg(test)]
22mod test_support;
23mod velocity;
24mod wip;
25
26use crate::config::Config;
27use crate::error::{Error, Result};
28use crate::storage::{Backend, BoardLock};
29pub use board::{Board, BoardColumn, BoardQuery, SortKey, board};
30pub use burndown::{Burndown, BurndownDay, BurndownMetric, burndown};
31pub use commits::{LinkOutcome, ScanOutcome, link_commits, scan_commits, unlink_commits};
32pub use cycletime::{CycleTimeFilter, CycleTimeReport, DurationSummary, cycle_time};
33pub use dependency::{
34 DependencyOutcome, ItemDetail, add_dependency, item_detail, remove_dependency,
35};
36pub use dod::{clear_common_dod, common_dod, set_common_dod};
37pub use item::{
38 AddItemOutcome, EditOutcome, ItemEdit, ListFilter, NewItem, RebalanceOutcome, RemoveOutcome,
39 ReorderTarget, add_item, add_item_with_outcome, apply_item_edit, edit_item, item_edit_template,
40 list_items, move_item, rebalance, remove_item, reorder_item, show_item,
41};
42pub use lifecycle::{InitOutcome, init_board};
43pub use migrate::{MigrateOutcome, migrate_storage};
44pub use order::{Forest, build_forest, hierarchical, hierarchical_order};
45pub(crate) use points::apply_effective_points;
46pub use search::{SearchFilter, SearchMode};
47pub use settings::{DisplaySettings, TuiSettings, display_settings, tui_settings};
48pub(crate) use sprint::validate_sprint_assignment;
49pub use sprint::{
50 assign_sprint, assign_sprint_by_status, assign_sprint_raw, close_sprint, create_sprint,
51 delete_sprint, edit_sprint, list_sprints, set_sprint_capacity, sprint_capacity, start_sprint,
52 unassign_sprint,
53};
54use std::path::{Path, PathBuf};
55pub use template::template_body;
56use tokio::fs;
57pub use velocity::{VelocityReport, VelocitySprint, velocity};
58pub use wip::{WipViolation, check_wip, wip_violations};
59
60#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
62pub enum LabelMatch {
63 #[default]
65 Any,
66 All,
68}
69
70impl LabelMatch {
71 #[must_use]
73 pub fn matches(self, item_labels: &[String], requested: &[String]) -> bool {
74 match self {
75 Self::Any => requested
76 .iter()
77 .any(|requested| item_labels.iter().any(|label| label == requested)),
78 Self::All => requested
79 .iter()
80 .all(|requested| item_labels.iter().any(|label| label == requested)),
81 }
82 }
83}
84
85pub async fn lock_board(project_dir: &Path) -> Result<BoardLock> {
91 let (board_dir, _) = initialized_board_paths(project_dir).await?;
92 BoardLock::acquire(&board_dir).await
93}
94
95async fn open_board(project_dir: &Path) -> Result<(PathBuf, Backend, Config)> {
106 let (board_dir, config_path) = initialized_board_paths(project_dir).await?;
107 let config = Config::load(&config_path).await?;
108 let repo = Backend::open(&board_dir, config.storage.backend).await?;
109 Ok((board_dir, repo, config))
110}
111
112async fn initialized_board_paths(project_dir: &Path) -> Result<(PathBuf, PathBuf)> {
114 let board_dir = project_dir.join(".pinto");
115 let config_path = board_dir.join("config.toml");
116 if !fs::try_exists(&config_path)
117 .await
118 .map_err(|e| Error::io(&config_path, &e))?
119 {
120 return Err(Error::NotInitialized { path: board_dir });
121 }
122 Ok((board_dir, config_path))
123}
124
125async fn open_board_locked(project_dir: &Path) -> Result<(PathBuf, Backend, Config, BoardLock)> {
133 open_board_locked_with_hook(project_dir, || {}).await
134}
135
136async fn open_board_locked_with_hook<F>(
143 project_dir: &Path,
144 before_lock: F,
145) -> Result<(PathBuf, Backend, Config, BoardLock)>
146where
147 F: FnOnce(),
148{
149 let (board_dir, config_path) = initialized_board_paths(project_dir).await?;
150 before_lock();
151 let lock = BoardLock::acquire(&board_dir).await?;
152 let config = Config::load(&config_path).await?;
156 let repo = Backend::open_for_write(&board_dir, config.storage.backend).await?;
157 Ok((board_dir, repo, config, lock))
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::backlog::{BacklogItem, ItemId, Status};
164 use crate::rank::Rank;
165 use crate::service::init_board;
166 use crate::storage::{BacklogItemRepository, StorageBackend};
167 use chrono::Utc;
168 use std::sync::Arc;
169 use tempfile::TempDir;
170 use tokio::sync::Notify;
171 use tokio::time::{Duration, timeout};
172
173 #[tokio::test]
174 async fn writer_selects_backend_after_waiting_for_the_board_lock() {
175 let dir = TempDir::new().expect("temp dir");
176 init_board(dir.path()).await.expect("init");
177 let board_dir = dir.path().join(".pinto");
178 let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
179
180 let project_dir = dir.path().to_path_buf();
181 let ready = Arc::new(Notify::new());
182 let ready_for_writer = Arc::clone(&ready);
183 let writer = tokio::spawn(async move {
184 open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one()).await
185 });
186 ready.notified().await;
187
188 let config_path = board_dir.join("config.toml");
189 let mut config = Config::load(&config_path).await.expect("config");
190 config.storage.backend = StorageBackend::Git;
191 config.save(&config_path).await.expect("switch backend");
192 drop(held);
193
194 let (_, backend, loaded, _) = writer
195 .await
196 .expect("writer task")
197 .expect("writer opens board");
198 assert!(matches!(backend, Backend::Git(_)));
199 assert_eq!(loaded.storage.backend, StorageBackend::Git);
200 }
201
202 #[tokio::test]
203 async fn read_only_board_open_does_not_wait_for_write_lock() {
204 let dir = TempDir::new().expect("temp dir");
205 init_board(dir.path()).await.expect("init");
206 let board_dir = dir.path().join(".pinto");
207 let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
208
209 let opened = timeout(Duration::from_secs(1), open_board(dir.path()))
210 .await
211 .expect("read-only open should not wait for the write lock")
212 .expect("open board");
213 assert!(matches!(opened.1, Backend::File(_)));
214 drop(held);
215 }
216
217 #[cfg(feature = "sqlite")]
218 #[tokio::test]
219 async fn waiting_writer_saves_to_backend_selected_after_config_switch() {
220 let dir = TempDir::new().expect("temp dir");
221 init_board(dir.path()).await.expect("init");
222 let board_dir = dir.path().join(".pinto");
223 let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
224
225 let ready = Arc::new(Notify::new());
226 let ready_for_writer = Arc::clone(&ready);
227 let project_dir = dir.path().to_path_buf();
228 let writer = tokio::spawn(async move {
229 let (_board_dir, repo, config, _lock) =
230 open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one())
231 .await?;
232 assert_eq!(config.storage.backend, StorageBackend::Sqlite);
233 let item = BacklogItem::new(
234 ItemId::new("T", 1),
235 "written after migration",
236 Status::new("todo"),
237 Rank::after(None),
238 Utc::now(),
239 )?;
240 BacklogItemRepository::save(&repo, &item).await?;
241 repo.commit("pinto: add T-1").await?;
242 Ok::<_, Error>(item)
243 });
244 ready.notified().await;
245
246 let config_path = board_dir.join("config.toml");
247 let mut config = Config::load(&config_path).await.expect("config");
248 config.storage.backend = StorageBackend::Sqlite;
251 config.save(&config_path).await.expect("switch backend");
252 drop(held);
253
254 let item = writer
255 .await
256 .expect("writer task")
257 .expect("writer saves item");
258 let (_, repo, loaded) = open_board(dir.path()).await.expect("open board");
259 assert_eq!(loaded.storage.backend, StorageBackend::Sqlite);
260 let persisted = BacklogItemRepository::load(&repo, &item.id)
261 .await
262 .expect("item should be visible in the selected backend");
263 assert_eq!(persisted, item);
264 }
265}