use crate::backlog::{BacklogItem, ItemId, Status, Workflow};
use crate::error::{Error, Result};
use crate::rank::Rank;
use crate::service::relations::{validate_dependencies, validate_parent};
use crate::service::{
LabelMatch, SearchFilter, apply_effective_points, open_board, open_board_locked,
validate_sprint_assignment,
};
use crate::sprint::Sprint;
use crate::storage::BacklogItemRepository;
use chrono::Utc;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone)]
pub struct NewItem {
pub points: Option<u32>,
pub labels: Vec<String>,
pub sprint: Option<String>,
pub body: String,
pub parent: Option<ItemId>,
pub depends_on: Vec<ItemId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddItemOutcome {
pub item: BacklogItem,
pub cycle_warning: bool,
}
pub async fn add_item(project_dir: &Path, title: &str, new: NewItem) -> Result<BacklogItem> {
Ok(add_item_with_outcome(project_dir, title, new).await?.item)
}
pub async fn add_item_with_outcome(
project_dir: &Path,
title: &str,
new: NewItem,
) -> Result<AddItemOutcome> {
let (board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
let config_path = board_dir.join("config.toml");
let status = config
.columns
.first()
.map(Status::new)
.ok_or_else(|| Error::parse(&config_path, "board config has no columns"))?;
let NewItem {
points,
labels,
sprint,
body,
parent,
depends_on,
} = new;
let sprint = match sprint {
Some(raw) => Some(validate_sprint_assignment(&repo, &raw).await?.to_string()),
None => None,
};
let id = repo.next_id(&config.project.key).await?;
let existing = repo.list().await?;
let rank = Rank::after(existing.last().map(|it| &it.rank));
let mut item = BacklogItem::new(id.clone(), title, status, rank, Utc::now())?;
if let Some(parent) = &parent {
validate_parent(&existing, &id, parent)?;
}
let cycle_warning = validate_dependencies(&existing, &id, &depends_on)?;
item.points = points;
item.labels = labels;
item.sprint = sprint;
item.body = body;
item.parent = parent;
item.depends_on = depends_on
.into_iter()
.fold(Vec::new(), |mut unique, dependency| {
if !unique.contains(&dependency) {
unique.push(dependency);
}
unique
});
repo.save(&item).await?;
repo.commit(&format!("pinto: add {}", item.id)).await?;
Ok(AddItemOutcome {
item,
cycle_warning,
})
}
#[derive(Debug, Default, Clone)]
pub struct ListFilter {
pub roots_only: bool,
pub status: Vec<String>,
pub sprint: Option<String>,
pub labels: Vec<String>,
pub label_match: LabelMatch,
pub search: Option<SearchFilter>,
}
impl ListFilter {
fn matches(&self, item: &BacklogItem, sprints: &[Sprint]) -> bool {
if self.roots_only && item.parent.is_some() {
return false;
}
if !self.status.is_empty()
&& !self
.status
.iter()
.any(|status| item.status.as_str() == status)
{
return false;
}
if let Some(sprint) = &self.sprint
&& item.sprint.as_deref() != Some(sprint.as_str())
{
return false;
}
if !self.labels.is_empty() && !self.label_match.matches(&item.labels, &self.labels) {
return false;
}
if let Some(search) = &self.search {
let sprint = item
.sprint
.as_deref()
.and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
if !search.matches(item, sprint) {
return false;
}
}
true
}
}
pub async fn list_items(project_dir: &Path, filter: &ListFilter) -> Result<Vec<BacklogItem>> {
let (_board_dir, repo, config) = open_board(project_dir).await?;
for status in &filter.status {
if !config.columns.iter().any(|column| column == status) {
return Err(Error::UnknownStatus(status.clone()));
}
}
let mut items = repo.list().await?;
apply_effective_points(
&mut items,
config.points.aggregate_children,
&Status::new(&config.done_column),
);
let sprints = if filter.search.is_some() {
crate::storage::SprintRepository::list(&repo).await?
} else {
Vec::new()
};
let filtered: Vec<BacklogItem> = items
.into_iter()
.filter(|item| filter.matches(item, &sprints))
.collect();
Ok(crate::service::hierarchical(filtered))
}
pub async fn show_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
let (_board_dir, repo, config) = open_board(project_dir).await?;
let mut items = repo.list().await?;
apply_effective_points(
&mut items,
config.points.aggregate_children,
&Status::new(&config.done_column),
);
items
.into_iter()
.find(|item| &item.id == id)
.ok_or_else(|| Error::NotFound(id.clone()))
}
pub async fn move_item(project_dir: &Path, id: &ItemId, to: &str) -> Result<BacklogItem> {
let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
let workflow = Workflow::new(config.columns.iter().map(Status::new));
let mut item = repo.load(id).await?;
item.transition_to(Status::new(to), &workflow, Utc::now())?;
repo.save(&item).await?;
repo.commit(&format!("pinto: update {}", item.id)).await?;
Ok(item)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoveOutcome {
Archived(PathBuf),
Deleted,
}
fn referencing_items(items: &[BacklogItem], target: &ItemId) -> String {
items
.iter()
.filter(|item| {
&item.id != target
&& (item.parent.as_ref() == Some(target)
|| item
.depends_on
.iter()
.any(|dependency| dependency == target))
})
.map(|item| item.id.to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub async fn remove_item(
project_dir: &Path,
id: &ItemId,
permanent: bool,
) -> Result<RemoveOutcome> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
if permanent {
let items = repo.list().await?;
if items.iter().any(|item| &item.id == id) {
let references = referencing_items(&items, id);
if !references.is_empty() {
return Err(Error::ReferencedItem {
item: id.clone(),
references,
});
}
}
BacklogItemRepository::delete(&repo, id).await?;
repo.commit(&format!("pinto: remove {id}")).await?;
Ok(RemoveOutcome::Deleted)
} else {
let dest = repo.archive(id).await?;
repo.commit(&format!("pinto: archive {id}")).await?;
Ok(RemoveOutcome::Archived(dest))
}
}