mod board;
mod burndown;
mod commits;
mod cycletime;
mod dependency;
mod dod;
mod item;
mod lifecycle;
mod migrate;
mod order;
mod points;
mod relations;
mod search;
mod settings;
mod sprint;
mod template;
#[cfg(test)]
mod test_support;
mod velocity;
mod wip;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::storage::{Backend, BoardLock};
pub use board::{Board, BoardColumn, BoardQuery, SortKey, board};
pub use burndown::{Burndown, BurndownDay, BurndownMetric, burndown};
pub use commits::{LinkOutcome, ScanOutcome, link_commits, scan_commits, unlink_commits};
pub use cycletime::{CycleTimeFilter, CycleTimeReport, DurationSummary, cycle_time};
pub use dependency::{
DependencyOutcome, ItemDetail, add_dependency, item_detail, remove_dependency,
};
pub use dod::{clear_common_dod, common_dod, set_common_dod};
pub use item::{
AddItemOutcome, EditOutcome, ItemEdit, ListFilter, NewItem, RebalanceOutcome, RemoveOutcome,
ReorderTarget, add_item, add_item_with_outcome, apply_item_edit, edit_item, item_edit_template,
list_items, move_item, rebalance, remove_item, reorder_item, show_item,
};
pub use lifecycle::{InitOutcome, init_board};
pub use migrate::{MigrateOutcome, migrate_storage};
pub use order::{Forest, build_forest, hierarchical, hierarchical_order};
pub(crate) use points::apply_effective_points;
pub use search::{SearchFilter, SearchMode};
pub use settings::{DisplaySettings, TuiSettings, display_settings, tui_settings};
pub(crate) use sprint::validate_sprint_assignment;
pub use sprint::{
assign_sprint, assign_sprint_by_status, assign_sprint_raw, close_sprint, create_sprint,
delete_sprint, edit_sprint, list_sprints, set_sprint_capacity, sprint_capacity, start_sprint,
unassign_sprint,
};
use std::path::{Path, PathBuf};
pub use template::template_body;
use tokio::fs;
pub use velocity::{VelocityReport, VelocitySprint, velocity};
pub use wip::{WipViolation, check_wip, wip_violations};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum LabelMatch {
#[default]
Any,
All,
}
impl LabelMatch {
#[must_use]
pub fn matches(self, item_labels: &[String], requested: &[String]) -> bool {
match self {
Self::Any => requested
.iter()
.any(|requested| item_labels.iter().any(|label| label == requested)),
Self::All => requested
.iter()
.all(|requested| item_labels.iter().any(|label| label == requested)),
}
}
}
pub async fn lock_board(project_dir: &Path) -> Result<BoardLock> {
let (board_dir, _) = initialized_board_paths(project_dir).await?;
BoardLock::acquire(&board_dir).await
}
async fn open_board(project_dir: &Path) -> Result<(PathBuf, Backend, Config)> {
let (board_dir, config_path) = initialized_board_paths(project_dir).await?;
let config = Config::load(&config_path).await?;
let repo = Backend::open(&board_dir, config.storage.backend).await?;
Ok((board_dir, repo, config))
}
async fn initialized_board_paths(project_dir: &Path) -> Result<(PathBuf, PathBuf)> {
let board_dir = project_dir.join(".pinto");
let config_path = board_dir.join("config.toml");
if !fs::try_exists(&config_path)
.await
.map_err(|e| Error::io(&config_path, &e))?
{
return Err(Error::NotInitialized { path: board_dir });
}
Ok((board_dir, config_path))
}
async fn open_board_locked(project_dir: &Path) -> Result<(PathBuf, Backend, Config, BoardLock)> {
open_board_locked_with_hook(project_dir, || {}).await
}
async fn open_board_locked_with_hook<F>(
project_dir: &Path,
before_lock: F,
) -> Result<(PathBuf, Backend, Config, BoardLock)>
where
F: FnOnce(),
{
let (board_dir, config_path) = initialized_board_paths(project_dir).await?;
before_lock();
let lock = BoardLock::acquire(&board_dir).await?;
let config = Config::load(&config_path).await?;
let repo = Backend::open_for_write(&board_dir, config.storage.backend).await?;
Ok((board_dir, repo, config, lock))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backlog::{BacklogItem, ItemId, Status};
use crate::rank::Rank;
use crate::service::init_board;
use crate::storage::{BacklogItemRepository, StorageBackend};
use chrono::Utc;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::sync::Notify;
use tokio::time::{Duration, timeout};
#[tokio::test]
async fn writer_selects_backend_after_waiting_for_the_board_lock() {
let dir = TempDir::new().expect("temp dir");
init_board(dir.path()).await.expect("init");
let board_dir = dir.path().join(".pinto");
let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
let project_dir = dir.path().to_path_buf();
let ready = Arc::new(Notify::new());
let ready_for_writer = Arc::clone(&ready);
let writer = tokio::spawn(async move {
open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one()).await
});
ready.notified().await;
let config_path = board_dir.join("config.toml");
let mut config = Config::load(&config_path).await.expect("config");
config.storage.backend = StorageBackend::Git;
config.save(&config_path).await.expect("switch backend");
drop(held);
let (_, backend, loaded, _) = writer
.await
.expect("writer task")
.expect("writer opens board");
assert!(matches!(backend, Backend::Git(_)));
assert_eq!(loaded.storage.backend, StorageBackend::Git);
}
#[tokio::test]
async fn read_only_board_open_does_not_wait_for_write_lock() {
let dir = TempDir::new().expect("temp dir");
init_board(dir.path()).await.expect("init");
let board_dir = dir.path().join(".pinto");
let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
let opened = timeout(Duration::from_secs(1), open_board(dir.path()))
.await
.expect("read-only open should not wait for the write lock")
.expect("open board");
assert!(matches!(opened.1, Backend::File(_)));
drop(held);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn waiting_writer_saves_to_backend_selected_after_config_switch() {
let dir = TempDir::new().expect("temp dir");
init_board(dir.path()).await.expect("init");
let board_dir = dir.path().join(".pinto");
let held = BoardLock::acquire(&board_dir).await.expect("hold lock");
let ready = Arc::new(Notify::new());
let ready_for_writer = Arc::clone(&ready);
let project_dir = dir.path().to_path_buf();
let writer = tokio::spawn(async move {
let (_board_dir, repo, config, _lock) =
open_board_locked_with_hook(&project_dir, move || ready_for_writer.notify_one())
.await?;
assert_eq!(config.storage.backend, StorageBackend::Sqlite);
let item = BacklogItem::new(
ItemId::new("T", 1),
"written after migration",
Status::new("todo"),
Rank::after(None),
Utc::now(),
)?;
BacklogItemRepository::save(&repo, &item).await?;
repo.commit("pinto: add T-1").await?;
Ok::<_, Error>(item)
});
ready.notified().await;
let config_path = board_dir.join("config.toml");
let mut config = Config::load(&config_path).await.expect("config");
config.storage.backend = StorageBackend::Sqlite;
config.save(&config_path).await.expect("switch backend");
drop(held);
let item = writer
.await
.expect("writer task")
.expect("writer saves item");
let (_, repo, loaded) = open_board(dir.path()).await.expect("open board");
assert_eq!(loaded.storage.backend, StorageBackend::Sqlite);
let persisted = BacklogItemRepository::load(&repo, &item.id)
.await
.expect("item should be visible in the selected backend");
assert_eq!(persisted, item);
}
}