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 #[cfg(feature = "sqlite")]
164 use crate::backlog::{BacklogItem, ItemId, Status};
165 #[cfg(feature = "sqlite")]
166 use crate::rank::Rank;
167 use crate::service::init_board;
168 #[cfg(feature = "sqlite")]
169 use crate::storage::BacklogItemRepository;
170 use crate::storage::StorageBackend;
171 #[cfg(feature = "sqlite")]
172 use chrono::Utc;
173 use std::sync::Arc;
174 use tempfile::TempDir;
175 use tokio::sync::Notify;
176 use tokio::time::{Duration, timeout};
177
178 #[tokio::test]
179 async fn writer_selects_backend_after_waiting_for_the_board_lock() {
180 let dir = TempDir::new().expect("temp dir");
181 init_board(dir.path()).await.expect("init");
182 let board_dir = dir.path().join(".pinto");
183 let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
184
185 let project_dir = dir.path().to_path_buf();
186 let ready = Arc::new(Notify::new());
187 let ready_for_writer = Arc::clone(&ready);
188 let writer = tokio::spawn(async move {
189 open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one()).await
190 });
191 ready.notified().await;
192
193 let config_path = board_dir.join("config.toml");
194 let mut config = Config::load(&config_path).await.expect("config");
195 config.storage.backend = StorageBackend::Git;
196 config.save(&config_path).await.expect("switch backend");
197 drop(held);
198
199 let (_, backend, loaded, _) = writer
200 .await
201 .expect("writer task")
202 .expect("writer opens board");
203 assert!(matches!(backend, Backend::Git(_)));
204 assert_eq!(loaded.storage.backend, StorageBackend::Git);
205 }
206
207 #[tokio::test]
208 async fn read_only_board_open_does_not_wait_for_write_lock() {
209 let dir = TempDir::new().expect("temp dir");
210 init_board(dir.path()).await.expect("init");
211 let board_dir = dir.path().join(".pinto");
212 let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
213
214 let opened = timeout(Duration::from_secs(1), open_board(dir.path()))
215 .await
216 .expect("read-only open should not wait for the write lock")
217 .expect("open board");
218 assert!(matches!(opened.1, Backend::File(_)));
219 drop(held);
220 }
221
222 #[cfg(feature = "sqlite")]
223 #[tokio::test]
224 async fn waiting_writer_saves_to_backend_selected_after_config_switch() {
225 let dir = TempDir::new().expect("temp dir");
226 init_board(dir.path()).await.expect("init");
227 let board_dir = dir.path().join(".pinto");
228 let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
229
230 let ready = Arc::new(Notify::new());
231 let ready_for_writer = Arc::clone(&ready);
232 let project_dir = dir.path().to_path_buf();
233 let writer = tokio::spawn(async move {
234 let (_board_dir, repo, config, _lock) =
235 open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one())
236 .await?;
237 assert_eq!(config.storage.backend, StorageBackend::Sqlite);
238 let item = BacklogItem::new(
239 ItemId::new("T", 1),
240 "written after migration",
241 Status::new("todo"),
242 Rank::after(None),
243 Utc::now(),
244 )?;
245 BacklogItemRepository::save(&repo, &item).await?;
246 repo.commit("pinto: add T-1").await?;
247 Ok::<_, Error>(item)
248 });
249 ready.notified().await;
250
251 let config_path = board_dir.join("config.toml");
252 let mut config = Config::load(&config_path).await.expect("config");
253 config.storage.backend = StorageBackend::Sqlite;
256 config.save(&config_path).await.expect("switch backend");
257 drop(held);
258
259 let item = writer
260 .await
261 .expect("writer task")
262 .expect("writer saves item");
263 let (_, repo, loaded) = open_board(dir.path()).await.expect("open board");
264 assert_eq!(loaded.storage.backend, StorageBackend::Sqlite);
265 let persisted = BacklogItemRepository::load(&repo, &item.id)
266 .await
267 .expect("item should be visible in the selected backend");
268 assert_eq!(persisted, item);
269 }
270}