use std::collections::{HashMap, HashSet};
use std::fmt;
use indexmap::IndexMap;
use crate::config::{Config, ProjectConfiguration, ProjectId};
use crate::error::TixError;
use crate::planning::Progress;
pub const PROJECTS_KEY: &str = "projects";
pub const MISSING_REFERENCE_KIND: &str = "missing_project_reference";
pub trait ProjectNode {
fn number(&self) -> u32;
fn title(&self) -> &str;
fn status(&self) -> &str;
fn project_ids(&self) -> &[String];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IssueKey(u32);
impl IssueKey {
#[must_use]
pub fn new(number: u32) -> Self {
Self(number)
}
#[must_use]
pub fn number(self) -> u32 {
self.0
}
}
impl fmt::Display for IssueKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{}", self.0)
}
}
impl From<u32> for IssueKey {
fn from(number: u32) -> Self {
Self(number)
}
}
impl From<IssueKey> for u32 {
fn from(key: IssueKey) -> Self {
key.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberRef {
pub issue: IssueKey,
pub display: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
pub id: ProjectId,
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub lead: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub members: Vec<MemberRef>,
pub member_count: usize,
}
impl Project {
fn from_configuration(
id: ProjectId,
record: &ProjectConfiguration,
members: Vec<MemberRef>,
) -> Self {
Self {
id,
name: record.name.clone(),
description: record.description.clone(),
status: record.status.clone(),
lead: record.lead.clone(),
start_date: record.start_date.clone(),
due_date: record.due_date.clone(),
member_count: members.len(),
members,
}
}
}
#[must_use]
pub fn project_display(id: &ProjectId, name: &str) -> String {
format!("{name} ({id})")
}
pub const NO_PROJECT_BUCKET: &str = "(no project)";
pub const MULTIPLE_PROJECTS_BUCKET: &str = "(multiple projects)";
#[must_use]
pub fn project_bucket_label(projects: &[String]) -> String {
match projects {
[] => NO_PROJECT_BUCKET.to_string(),
[only] => only.clone(),
_ => MULTIPLE_PROJECTS_BUCKET.to_string(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectMembership {
pub project_id: ProjectId,
pub members: Vec<MemberRef>,
pub member_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingProjectReference {
pub issue: IssueKey,
pub issue_display: String,
pub project_id: String,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectDiagnosticKind {
UnknownProject,
UnknownIssue,
InvalidDates,
DuplicateName,
}
impl ProjectDiagnosticKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ProjectDiagnosticKind::UnknownProject => "unknown_project",
ProjectDiagnosticKind::UnknownIssue => "unknown_issue",
ProjectDiagnosticKind::InvalidDates => "invalid_dates",
ProjectDiagnosticKind::DuplicateName => "duplicate_name",
}
}
}
impl fmt::Display for ProjectDiagnosticKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn unknown_project_reason(reference: &str) -> String {
format!("no project matches `{reference}`; a project is selected by its stable id or by name")
}
pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
format!("issue `{selector}` is not in this store")
}
#[must_use]
pub fn invalid_dates_reason(detail: &str) -> String {
format!("project dates are invalid: {detail}")
}
#[must_use]
pub fn duplicate_name_reason(name: &str) -> String {
if name.trim().is_empty() {
"a project name must not be blank".to_string()
} else if name.trim() != name {
format!("project name `{name}` has leading or trailing whitespace")
} else {
format!("project name `{name}` is already in use; project names must be unique")
}
}
#[must_use]
pub fn missing_reference_reason(issue: u32, project_id: &str) -> String {
format!(
"issue #{issue} references project `{project_id}`, which is not in the registry \
(the reference is kept so it can be repaired)"
)
}
#[must_use]
pub fn association_detail(project_id: &str, associated: bool) -> String {
if associated {
format!("project `{project_id}` associated")
} else {
format!("project `{project_id}` dissociated")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectError {
UnknownProject {
reference: String,
},
UnknownIssue {
selector: u32,
},
InvalidDates {
detail: String,
},
DuplicateName {
name: String,
},
}
impl ProjectError {
#[must_use]
pub fn kind(&self) -> ProjectDiagnosticKind {
match self {
ProjectError::UnknownProject { .. } => ProjectDiagnosticKind::UnknownProject,
ProjectError::UnknownIssue { .. } => ProjectDiagnosticKind::UnknownIssue,
ProjectError::InvalidDates { .. } => ProjectDiagnosticKind::InvalidDates,
ProjectError::DuplicateName { .. } => ProjectDiagnosticKind::DuplicateName,
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
ProjectError::UnknownProject { reference } => unknown_project_reason(reference),
ProjectError::UnknownIssue { selector } => unknown_issue_reason(selector),
ProjectError::InvalidDates { detail } => invalid_dates_reason(detail),
ProjectError::DuplicateName { name } => duplicate_name_reason(name),
}
}
}
impl fmt::Display for ProjectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.kind(), self.message())
}
}
impl std::error::Error for ProjectError {}
impl From<ProjectError> for TixError {
fn from(err: ProjectError) -> TixError {
TixError::InvalidInput(err.to_string())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NewProject {
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub lead: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Patch<T> {
#[default]
Unchanged,
Clear,
Set(T),
}
impl<T> Patch<T> {
#[must_use]
pub fn is_unchanged(&self) -> bool {
matches!(self, Patch::Unchanged)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectEdit {
pub name: Patch<String>,
pub description: Patch<String>,
pub status: Patch<String>,
pub lead: Patch<String>,
pub start_date: Patch<String>,
pub due_date: Patch<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectDeletion {
pub id: ProjectId,
pub name: String,
pub affected: Vec<MemberRef>,
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
fn validate_name(
config: &Config,
name: &str,
exclude: Option<&ProjectId>,
) -> Result<(), ProjectError> {
if name.trim().is_empty() || name.trim() != name {
return Err(ProjectError::DuplicateName {
name: name.to_string(),
});
}
let normalized = name.to_lowercase();
let clash = config.projects.iter().any(|(raw, record)| {
let is_self = exclude.is_some_and(|id| raw.as_str() == id.as_str());
!is_self && record.name.to_lowercase() == normalized
});
if clash {
return Err(ProjectError::DuplicateName {
name: name.to_string(),
});
}
Ok(())
}
fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), ProjectError> {
crate::model::validate_issue_dates(start, due).map_err(|err| ProjectError::InvalidDates {
detail: err.to_string(),
})
}
pub fn resolve_project_id(config: &Config, reference: &str) -> Result<ProjectId, ProjectError> {
if let Ok(id) = ProjectId::new(reference) {
if config.projects.contains_key(id.as_str()) {
return Ok(id);
}
}
let normalized = reference.trim().to_lowercase();
if let Some((raw, _)) = config
.projects
.iter()
.find(|(_, record)| record.name.to_lowercase() == normalized)
{
return ProjectId::new(raw).map_err(|_| ProjectError::UnknownProject {
reference: reference.to_string(),
});
}
Err(ProjectError::UnknownProject {
reference: reference.to_string(),
})
}
pub fn create_project(config: &mut Config, new: &NewProject) -> Result<Project, ProjectError> {
validate_name(config, &new.name, None)?;
validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
let id = config.allocate_project_id();
let record = ProjectConfiguration {
name: new.name.clone(),
description: normalize_optional(new.description.clone()),
status: normalize_optional(new.status.clone()),
lead: normalize_optional(new.lead.clone()),
start_date: new.start_date.clone(),
due_date: new.due_date.clone(),
};
config.projects.insert(id.to_string(), record.clone());
Ok(Project::from_configuration(id, &record, Vec::new()))
}
pub fn edit_project(
config: &mut Config,
reference: &str,
edit: &ProjectEdit,
) -> Result<Project, ProjectError> {
let id = resolve_project_id(config, reference)?;
let mut updated =
config
.projects
.get(id.as_str())
.cloned()
.ok_or_else(|| ProjectError::UnknownProject {
reference: reference.to_string(),
})?;
match &edit.name {
Patch::Unchanged => {}
Patch::Clear => {
return Err(ProjectError::DuplicateName {
name: String::new(),
});
}
Patch::Set(name) => {
validate_name(config, name, Some(&id))?;
updated.name.clone_from(name);
}
}
apply_optional(&edit.description, &mut updated.description);
apply_optional(&edit.status, &mut updated.status);
apply_optional(&edit.lead, &mut updated.lead);
apply_date(&edit.start_date, &mut updated.start_date);
apply_date(&edit.due_date, &mut updated.due_date);
validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
if let Some(record) = config.projects.get_mut(id.as_str()) {
*record = updated.clone();
}
Ok(Project::from_configuration(id, &updated, Vec::new()))
}
fn apply_optional(patch: &Patch<String>, slot: &mut Option<String>) {
match patch {
Patch::Unchanged => {}
Patch::Clear => *slot = None,
Patch::Set(value) => *slot = normalize_optional(Some(value.clone())),
}
}
fn apply_date(patch: &Patch<String>, slot: &mut Option<String>) {
match patch {
Patch::Unchanged => {}
Patch::Clear => *slot = None,
Patch::Set(value) => *slot = Some(value.clone()),
}
}
pub fn delete_project<N: ProjectNode>(
config: &mut Config,
reference: &str,
issues: &[N],
) -> Result<ProjectDeletion, ProjectError> {
let id = resolve_project_id(config, reference)?;
let record =
config
.projects
.get(id.as_str())
.cloned()
.ok_or_else(|| ProjectError::UnknownProject {
reference: reference.to_string(),
})?;
let affected = ProjectIndex::build(config, issues)
.project(&id)
.map(|project| project.members.clone())
.unwrap_or_default();
config.projects.shift_remove(id.as_str());
Ok(ProjectDeletion {
id,
name: record.name,
affected,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectIndex {
projects: IndexMap<ProjectId, Project>,
buckets: HashMap<ProjectId, Vec<IssueKey>>,
missing: Vec<MissingProjectReference>,
labels: HashMap<IssueKey, String>,
statuses: HashMap<IssueKey, String>,
}
impl ProjectIndex {
pub fn build<N: ProjectNode>(config: &Config, issues: &[N]) -> Self {
let known: HashMap<String, ProjectId> = config
.projects
.keys()
.filter_map(|raw| ProjectId::new(raw).ok().map(|id| (raw.clone(), id)))
.collect();
let mut labels: HashMap<IssueKey, String> = HashMap::new();
let mut statuses: HashMap<IssueKey, String> = HashMap::new();
let mut buckets: HashMap<ProjectId, Vec<IssueKey>> = HashMap::new();
let mut missing: Vec<MissingProjectReference> = Vec::new();
for issue in issues {
let key = IssueKey::new(issue.number());
let display = crate::hierarchy::display_label(issue.number(), issue.title());
labels.insert(key, display.clone());
statuses.insert(key, issue.status().to_string());
let mut seen: HashSet<&str> = HashSet::new();
for raw in issue.project_ids() {
if !seen.insert(raw.as_str()) {
continue;
}
match known.get(raw.as_str()) {
Some(id) => buckets.entry(id.clone()).or_default().push(key),
None => missing.push(MissingProjectReference {
issue: key,
issue_display: display.clone(),
project_id: raw.clone(),
reason: missing_reference_reason(issue.number(), raw),
}),
}
}
}
for members in buckets.values_mut() {
members.sort_unstable();
}
missing.sort_by(|a, b| {
a.issue
.cmp(&b.issue)
.then_with(|| a.project_id.cmp(&b.project_id))
});
let mut projects: IndexMap<ProjectId, Project> = IndexMap::new();
for (raw, record) in &config.projects {
let Ok(id) = ProjectId::new(raw) else {
continue;
};
let members: Vec<MemberRef> = buckets
.get(&id)
.map(|keys| {
keys.iter()
.map(|key| MemberRef {
issue: *key,
display: labels.get(key).cloned().unwrap_or_else(|| key.to_string()),
})
.collect()
})
.unwrap_or_default();
projects.insert(id.clone(), Project::from_configuration(id, record, members));
}
Self {
projects,
buckets,
missing,
labels,
statuses,
}
}
#[must_use]
pub fn buckets(&self) -> &HashMap<ProjectId, Vec<IssueKey>> {
&self.buckets
}
#[must_use]
pub fn bucket(&self, id: &ProjectId) -> Option<&[IssueKey]> {
if !self.projects.contains_key(id) {
return None;
}
Some(self.buckets.get(id).map_or(&[][..], Vec::as_slice))
}
#[must_use]
pub fn projects(&self) -> Vec<&Project> {
self.projects.values().collect()
}
#[must_use]
pub fn project(&self, id: &ProjectId) -> Option<&Project> {
self.projects.get(id)
}
#[must_use]
pub fn label(&self, key: IssueKey) -> Option<&str> {
self.labels.get(&key).map(String::as_str)
}
#[must_use]
pub fn status_of(&self, key: IssueKey) -> Option<&str> {
self.statuses.get(&key).map(String::as_str)
}
#[must_use]
pub fn missing(&self) -> &[MissingProjectReference] {
&self.missing
}
pub fn resolve(&self, reference: &str) -> Result<&Project, ProjectError> {
if let Ok(id) = ProjectId::new(reference) {
if let Some(project) = self.projects.get(&id) {
return Ok(project);
}
}
let normalized = reference.trim().to_lowercase();
self.projects
.values()
.find(|project| project.name.to_lowercase() == normalized)
.ok_or_else(|| ProjectError::UnknownProject {
reference: reference.to_string(),
})
}
pub fn membership(&self, reference: &str) -> Result<ProjectMembership, ProjectError> {
let project = self.resolve(reference)?;
Ok(ProjectMembership {
project_id: project.id.clone(),
members: project.members.clone(),
member_count: project.member_count,
})
}
#[must_use]
pub fn resolved_memberships(&self) -> HashMap<u32, Vec<String>> {
let mut out: HashMap<u32, Vec<String>> = HashMap::new();
for project in self.projects.values() {
if let Some(members) = self.buckets.get(&project.id) {
for member in members {
out.entry(member.number())
.or_default()
.push(project.id.to_string());
}
}
}
out
}
#[must_use]
pub fn into_buckets(self) -> HashMap<ProjectId, Vec<IssueKey>> {
self.buckets
}
#[must_use]
pub fn into_missing(self) -> Vec<MissingProjectReference> {
self.missing
}
#[must_use]
pub fn into_projects(self) -> Vec<Project> {
self.projects.into_values().collect()
}
}
#[must_use]
pub fn membership_index<N: ProjectNode>(
config: &Config,
issues: &[N],
) -> HashMap<ProjectId, Vec<IssueKey>> {
ProjectIndex::build(config, issues).into_buckets()
}
pub fn membership<N: ProjectNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<ProjectMembership, ProjectError> {
ProjectIndex::build(config, issues).membership(reference)
}
#[must_use]
pub fn list_projects<N: ProjectNode>(config: &Config, issues: &[N]) -> Vec<Project> {
ProjectIndex::build(config, issues).into_projects()
}
pub fn resolve<N: ProjectNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<Project, ProjectError> {
ProjectIndex::build(config, issues)
.resolve(reference)
.cloned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectEventKind {
ProjectAssociated,
ProjectDissociated,
}
impl ProjectEventKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ProjectEventKind::ProjectAssociated => "project_associated",
ProjectEventKind::ProjectDissociated => "project_dissociated",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectEvent {
pub kind: ProjectEventKind,
pub project_id: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectChange {
pub issue: IssueKey,
pub project_ids: Vec<String>,
pub event: Option<ProjectEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkProjectOutcome {
pub selector: u32,
pub outcome: Result<ProjectChange, ProjectError>,
}
fn find_issue<N: ProjectNode>(issues: &[N], selector: u32) -> Result<&N, ProjectError> {
issues
.iter()
.find(|issue| issue.number() == selector)
.ok_or(ProjectError::UnknownIssue { selector })
}
fn associate_resolved<N: ProjectNode>(
issues: &[N],
selector: u32,
id: &ProjectId,
) -> Result<ProjectChange, ProjectError> {
let issue = find_issue(issues, selector)?;
let current = issue.project_ids();
let key = IssueKey::new(selector);
if current.iter().any(|stored| stored == id.as_str()) {
return Ok(ProjectChange {
issue: key,
project_ids: current.to_vec(),
event: None,
});
}
let mut next = current.to_vec();
next.push(id.to_string());
Ok(ProjectChange {
issue: key,
project_ids: next,
event: Some(ProjectEvent {
kind: ProjectEventKind::ProjectAssociated,
project_id: id.to_string(),
detail: association_detail(id.as_str(), true),
}),
})
}
pub fn associate<N: ProjectNode>(
config: &Config,
issues: &[N],
issue_selector: u32,
project_reference: &str,
) -> Result<ProjectChange, ProjectError> {
let id = resolve_project_id(config, project_reference)?;
associate_resolved(issues, issue_selector, &id)
}
pub fn dissociate<N: ProjectNode>(
issues: &[N],
issue_selector: u32,
project_id: &str,
) -> Result<ProjectChange, ProjectError> {
let issue = find_issue(issues, issue_selector)?;
let current = issue.project_ids();
let key = IssueKey::new(issue_selector);
if !current.iter().any(|stored| stored == project_id) {
return Ok(ProjectChange {
issue: key,
project_ids: current.to_vec(),
event: None,
});
}
let next: Vec<String> = current
.iter()
.filter(|stored| stored.as_str() != project_id)
.cloned()
.collect();
Ok(ProjectChange {
issue: key,
project_ids: next,
event: Some(ProjectEvent {
kind: ProjectEventKind::ProjectDissociated,
project_id: project_id.to_string(),
detail: association_detail(project_id, false),
}),
})
}
#[must_use]
pub fn bulk_associate<N: ProjectNode>(
config: &Config,
issues: &[N],
project_reference: &str,
issue_selectors: &[u32],
) -> Vec<BulkProjectOutcome> {
let resolved = resolve_project_id(config, project_reference);
issue_selectors
.iter()
.map(|&selector| {
let outcome = match &resolved {
Ok(id) => associate_resolved(issues, selector, id),
Err(err) => Err(err.clone()),
};
BulkProjectOutcome { selector, outcome }
})
.collect()
}
#[must_use]
pub fn validate_references<N: ProjectNode>(
config: &Config,
issues: &[N],
) -> Vec<MissingProjectReference> {
ProjectIndex::build(config, issues).into_missing()
}
pub trait HealthNode: ProjectNode {
fn assignee(&self) -> Option<&str>;
}
#[must_use]
pub fn assigned_keys<N: HealthNode>(issues: &[N]) -> HashSet<u32> {
issues
.iter()
.filter(|issue| {
issue
.assignee()
.is_some_and(|assignee| !assignee.trim().is_empty())
})
.map(ProjectNode::number)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectProgress {
pub done: usize,
pub total: usize,
pub progress: Progress,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectHealth {
pub overdue: usize,
pub blocked: usize,
pub unassigned: usize,
pub missing_references: Vec<MissingProjectReference>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectHealthReport {
pub project_id: ProjectId,
pub name: String,
pub progress: ProjectProgress,
pub health: ProjectHealth,
}
impl ProjectIndex {
pub fn health<S: std::hash::BuildHasher>(
&self,
config: &Config,
reference: &str,
assigned: &HashSet<u32, S>,
overdue: &HashSet<u32, S>,
blocked: &HashSet<u32, S>,
) -> Result<ProjectHealthReport, ProjectError> {
let project = self.resolve(reference)?;
let (_, resolution) = crate::workflow::active_resolution(config);
let members = self.bucket(&project.id).unwrap_or(&[]);
let mut done = 0usize;
let mut overdue_count = 0usize;
let mut blocked_count = 0usize;
let mut unassigned = 0usize;
for key in members {
let number = key.number();
let complete = self
.status_of(*key)
.and_then(|status| resolution.resolve(status))
.is_some_and(crate::workflow::WorkflowCategory::is_complete);
if complete {
done += 1;
}
if !complete && overdue.contains(&number) {
overdue_count += 1;
}
if blocked.contains(&number) {
blocked_count += 1;
}
if !assigned.contains(&number) {
unassigned += 1;
}
}
let total = members.len();
let member_keys: HashSet<IssueKey> = members.iter().copied().collect();
let missing_references: Vec<MissingProjectReference> = self
.missing
.iter()
.filter(|missing| member_keys.contains(&missing.issue))
.cloned()
.collect();
Ok(ProjectHealthReport {
project_id: project.id.clone(),
name: project.name.clone(),
progress: ProjectProgress {
done,
total,
progress: Progress { done, total },
},
health: ProjectHealth {
overdue: overdue_count,
blocked: blocked_count,
unassigned,
missing_references,
},
})
}
}
pub fn health<N: HealthNode, S: std::hash::BuildHasher>(
config: &Config,
issues: &[N],
reference: &str,
assigned: &HashSet<u32, S>,
overdue: &HashSet<u32, S>,
blocked: &HashSet<u32, S>,
) -> Result<ProjectHealthReport, ProjectError> {
ProjectIndex::build(config, issues).health(config, reference, assigned, overdue, blocked)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Node {
number: u32,
title: String,
status: String,
projects: Vec<String>,
assignee: Option<String>,
}
impl Node {
fn new(number: u32, title: &str) -> Self {
Self {
number,
title: title.to_string(),
status: "todo".to_string(),
projects: Vec::new(),
assignee: None,
}
}
fn with(mut self, ids: &[&str]) -> Self {
self.projects = ids.iter().map(|id| (*id).to_string()).collect();
self
}
fn in_column(mut self, status: &str) -> Self {
self.status = status.to_string();
self
}
fn assigned_to(mut self, assignee: &str) -> Self {
self.assignee = Some(assignee.to_string());
self
}
}
impl ProjectNode for Node {
fn number(&self) -> u32 {
self.number
}
fn title(&self) -> &str {
&self.title
}
fn status(&self) -> &str {
&self.status
}
fn project_ids(&self) -> &[String] {
&self.projects
}
}
impl HealthNode for Node {
fn assignee(&self) -> Option<&str> {
self.assignee.as_deref()
}
}
fn registry(entries: &[(&str, &str)]) -> Config {
let mut config = Config::default();
for (id, name) in entries {
config.projects.insert(
(*id).to_string(),
ProjectConfiguration {
name: (*name).to_string(),
..ProjectConfiguration::default()
},
);
}
config
}
fn ids(change: &ProjectChange) -> Vec<&str> {
change.project_ids.iter().map(String::as_str).collect()
}
fn set(numbers: &[u32]) -> HashSet<u32> {
numbers.iter().copied().collect()
}
fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
let mut config = Config::default();
config.kanban.configurations.insert(
"default".to_string(),
crate::config::BoardConfiguration {
columns: columns.iter().map(|c| (*c).to_string()).collect(),
done_column: Some(done_column.to_string()),
category_map: map
.iter()
.map(|(column, key)| ((*column).to_string(), (*key).to_string()))
.collect(),
..Default::default()
},
);
config
}
#[test]
fn create_project_allocates_a_stable_id() {
let mut config = Config::default();
let project = create_project(
&mut config,
&NewProject {
name: "Platform hardening".into(),
description: Some("reduce risk".into()),
..NewProject::default()
},
)
.unwrap();
assert!(project.id.as_str().starts_with("p-"));
assert_eq!(project.name, "Platform hardening");
assert_eq!(project.member_count, 0);
assert!(config.projects.contains_key(project.id.as_str()));
assert_eq!(
config.projects[project.id.as_str()].name,
"Platform hardening"
);
}
#[test]
fn create_project_refuses_a_duplicate_name_case_folded() {
let mut config = registry(&[("p-aaaa", "Platform")]);
let err = create_project(
&mut config,
&NewProject {
name: "platform".into(),
..NewProject::default()
},
)
.unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
assert_eq!(config.projects.len(), 1, "a refusal writes nothing");
}
#[test]
fn create_project_refuses_inverted_dates() {
let mut config = Config::default();
let err = create_project(
&mut config,
&NewProject {
name: "Bad dates".into(),
start_date: Some("2026-12-01".into()),
due_date: Some("2026-01-01".into()),
..NewProject::default()
},
)
.unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::InvalidDates);
assert!(config.projects.is_empty());
}
#[test]
fn rename_changes_the_label_only() {
let mut config = registry(&[("p-3f9a2c", "Platform hardening")]);
let issues = vec![Node::new(1, "One").with(&["p-3f9a2c"])];
let edited = edit_project(
&mut config,
"p-3f9a2c",
&ProjectEdit {
name: Patch::Set("Platform".into()),
..ProjectEdit::default()
},
)
.unwrap();
assert!(config.projects.contains_key("p-3f9a2c"));
assert_eq!(edited.id.as_str(), "p-3f9a2c");
assert_eq!(edited.name, "Platform");
let membership = membership(&config, &issues, "p-3f9a2c").unwrap();
assert_eq!(membership.member_count, 1);
}
#[test]
fn edit_project_clears_an_optional_field() {
let mut config = registry(&[("p-aaaa", "One")]);
config.projects["p-aaaa"].lead = Some("Alice".into());
let edited = edit_project(
&mut config,
"p-aaaa",
&ProjectEdit {
lead: Patch::Clear,
..ProjectEdit::default()
},
)
.unwrap();
assert_eq!(edited.lead, None);
assert_eq!(config.projects["p-aaaa"].lead, None);
}
#[test]
fn edit_project_refuses_an_unknown_reference() {
let mut config = registry(&[("p-aaaa", "One")]);
let err = edit_project(
&mut config,
"does-not-exist",
&ProjectEdit {
name: Patch::Set("X".into()),
..ProjectEdit::default()
},
)
.unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
}
#[test]
fn delete_reports_affected_issues_and_keeps_them() {
let mut config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(2, "Two").with(&["p-aaaa"]),
Node::new(7, "Seven").with(&["p-aaaa"]),
];
let impact = delete_project(&mut config, "One", &issues).unwrap();
assert_eq!(impact.id.as_str(), "p-aaaa");
assert_eq!(impact.name, "One");
assert_eq!(
impact
.affected
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![2, 7],
"affected issues are ordered by issue number"
);
assert!(config.projects.is_empty());
assert_eq!(issues[0].projects, vec!["p-aaaa".to_string()]);
assert_eq!(validate_references(&config, &issues).len(), 2);
}
#[test]
fn successive_create_draws_after_a_delete_are_distinct() {
let mut config = Config::default();
let first = create_project(
&mut config,
&NewProject {
name: "One".into(),
..NewProject::default()
},
)
.unwrap();
delete_project(&mut config, first.id.as_str(), &[] as &[Node]).unwrap();
let second = create_project(
&mut config,
&NewProject {
name: "One".into(),
..NewProject::default()
},
)
.unwrap();
assert_ne!(first.id, second.id, "two draws must be distinct");
}
#[test]
fn resolve_by_id_and_by_name_agree() {
let config = registry(&[("p-aaaa", "Platform"), ("p-bbbb", "Docs")]);
let issues = vec![Node::new(1, "One").with(&["p-bbbb"])];
let by_id = resolve(&config, &issues, "p-bbbb").unwrap();
let by_name = resolve(&config, &issues, "Docs").unwrap();
assert_eq!(by_id, by_name);
assert_eq!(by_id.member_count, 1);
}
#[test]
fn id_resolution_takes_precedence_over_a_name_lookalike() {
let mut config = registry(&[("p-aaaa", "Platform"), ("Docs", "Docs refresh")]);
config.projects["Docs"].name = "Docs refresh".into();
let resolved = resolve_project_id(&config, "Docs").unwrap();
assert_eq!(resolved.as_str(), "Docs", "the id is matched first");
}
#[test]
fn unknown_id_and_unknown_name_are_refused() {
let config = registry(&[("p-aaaa", "Platform")]);
for reference in ["p-missing", "No such name"] {
let err = resolve_project_id(&config, reference).unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
assert!(err.message().contains(reference));
}
}
#[test]
fn list_projects_is_declaration_ordered_with_member_counts() {
let config = registry(&[("p-zeta", "Zeta"), ("p-alpha", "Alpha")]);
let issues = vec![
Node::new(3, "Three").with(&["p-alpha"]),
Node::new(1, "One").with(&["p-zeta", "p-alpha"]),
];
let projects = list_projects(&config, &issues);
assert_eq!(
projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["Zeta", "Alpha"],
"declaration order is the display order"
);
assert_eq!(projects[0].member_count, 1);
assert_eq!(projects[1].member_count, 2);
}
#[test]
fn list_projects_never_errors_on_an_empty_registry() {
let config = Config::default();
assert_eq!(
list_projects(&config, &[] as &[Node]),
[] as [crate::projects::Project; 0]
);
}
#[test]
fn project_display_shows_the_id_beside_the_name() {
let id = ProjectId::new("p-3f9a2c").unwrap();
assert_eq!(project_display(&id, "Platform"), "Platform (p-3f9a2c)");
}
#[test]
fn an_issue_may_belong_to_several_projects() {
let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
let issues = vec![Node::new(1, "Shared").with(&["p-aaaa", "p-bbbb"])];
assert_eq!(
membership(&config, &issues, "p-aaaa").unwrap().member_count,
1
);
assert_eq!(
membership(&config, &issues, "p-bbbb").unwrap().member_count,
1
);
}
#[test]
fn an_unassociated_issue_is_in_no_bucket() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "None"), Node::new(2, "One").with(&["p-aaaa"])];
let index = ProjectIndex::build(&config, &issues);
let id = ProjectId::new("p-aaaa").unwrap();
assert_eq!(index.bucket(&id).unwrap(), [IssueKey::new(2)]);
}
#[test]
fn bucket_is_none_only_for_an_unknown_project() {
let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
let index = ProjectIndex::build(&config, &issues);
let known_empty = ProjectId::new("p-bbbb").unwrap();
assert_eq!(index.bucket(&known_empty), Some(&[][..]));
let unknown = ProjectId::new("p-missing").unwrap();
assert_eq!(index.bucket(&unknown), None);
}
#[test]
fn membership_order_is_stable_across_repeated_builds() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(9, "Nine").with(&["p-aaaa"]),
Node::new(2, "Two").with(&["p-aaaa"]),
Node::new(5, "Five").with(&["p-aaaa"]),
];
let first = membership(&config, &issues, "p-aaaa").unwrap();
let second = membership(&config, &issues, "p-aaaa").unwrap();
assert_eq!(first, second);
assert_eq!(
first
.members
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![2, 5, 9]
);
assert_eq!(first.members[0].display, "#2 Two");
}
#[test]
fn the_free_operations_agree_with_one_built_index() {
let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
let issues = vec![
Node::new(1, "One").with(&["p-aaaa", "p-bbbb"]),
Node::new(2, "Two").with(&["p-aaaa"]),
];
let index = ProjectIndex::build(&config, &issues);
assert_eq!(membership_index(&config, &issues), *index.buckets());
assert_eq!(
membership(&config, &issues, "p-aaaa").unwrap(),
index.membership("p-aaaa").unwrap()
);
assert_eq!(
validate_references(&config, &issues),
index.missing().to_vec()
);
assert_eq!(
list_projects(&config, &issues),
index
.projects()
.into_iter()
.cloned()
.collect::<Vec<Project>>()
);
}
#[test]
fn associate_leaves_the_registry_record_unchanged() {
let config = registry(&[("p-aaaa", "One")]);
let before = config.projects["p-aaaa"].clone();
let issues = vec![Node::new(1, "One")];
let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
assert_eq!(config.projects["p-aaaa"], before);
assert_eq!(
config.projects.len(),
1,
"a project record carries no member list"
);
assert_eq!(change.project_ids, vec!["p-aaaa".to_string()]);
let serialized = toml::to_string(&config).expect("the registry serializes");
assert!(
!serialized.contains("member"),
"no member/members key may reach disk: {serialized}"
);
}
#[test]
fn associate_produces_the_list_and_one_event() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "One")];
let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
assert_eq!(ids(&change), ["p-aaaa"]);
let event = change.event.expect("an accepted change is audited");
assert_eq!(event.kind, ProjectEventKind::ProjectAssociated);
assert_eq!(event.project_id, "p-aaaa");
assert_eq!(event.detail, "project `p-aaaa` associated");
}
#[test]
fn re_association_is_idempotent_and_emits_no_event() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
assert_eq!(ids(&change), ["p-aaaa"], "no duplicate entry");
assert!(change.event.is_none(), "a no-op is not an audit entry");
}
#[test]
fn associating_an_unknown_project_or_issue_is_refused() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "One")];
let err = associate(&config, &issues, 1, "p-nope").unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
let err = associate(&config, &issues, 99, "p-aaaa").unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
assert!(err.message().contains("99"));
}
#[test]
fn dissociate_keeps_the_issue_intact() {
let issues = vec![Node::new(1, "One").with(&["p-aaaa", "p-bbbb"])];
let change = dissociate(&issues, 1, "p-aaaa").unwrap();
assert_eq!(ids(&change), ["p-bbbb"], "only the one association drops");
let event = change.event.unwrap();
assert_eq!(event.kind, ProjectEventKind::ProjectDissociated);
assert_eq!(event.detail, "project `p-aaaa` dissociated");
assert_eq!(issues[0].projects, vec!["p-aaaa", "p-bbbb"]);
}
#[test]
fn dissociating_an_absent_association_is_a_noop() {
let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
let change = dissociate(&issues, 1, "p-bbbb").unwrap();
assert_eq!(ids(&change), ["p-aaaa"]);
assert!(change.event.is_none());
}
#[test]
fn dissociate_does_not_resolve_a_project_name() {
let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
let change = dissociate(&issues, 1, "One").unwrap();
assert_eq!(ids(&change), ["p-aaaa"], "a name must not match the id");
assert!(change.event.is_none(), "and it is not an audit entry");
}
#[test]
fn dissociate_removes_a_stale_reference_without_a_project() {
let issues = vec![Node::new(1, "One").with(&["p-deleted"])];
let change = dissociate(&issues, 1, "p-deleted").unwrap();
assert_eq!(change.project_ids, [] as [std::string::String; 0]);
assert!(change.event.is_some());
}
#[test]
fn dissociate_refuses_an_unknown_issue() {
let issues = vec![Node::new(1, "One")];
let err = dissociate(&issues, 42, "p-aaaa").unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
}
#[test]
fn bulk_associate_reports_each_issue_independently() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
let outcomes = bulk_associate(&config, &issues, "p-aaaa", &[1, 99, 2]);
assert_eq!(outcomes.len(), 3, "one entry per selector");
assert_eq!(outcomes[0].selector, 1);
assert!(outcomes[0].outcome.is_ok());
assert_eq!(
outcomes[1].outcome.as_ref().unwrap_err().kind(),
ProjectDiagnosticKind::UnknownIssue,
"the bad selector is reported, not fatal"
);
assert!(
outcomes[2].outcome.is_ok(),
"the good selectors after a bad one still process"
);
let events: usize = outcomes
.iter()
.filter_map(|o| o.outcome.as_ref().ok())
.filter(|change| change.event.is_some())
.count();
assert_eq!(events, 2);
}
#[test]
fn bulk_associate_reports_an_unknown_project_per_selector() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
let outcomes = bulk_associate(&config, &issues, "p-nope", &[1, 2]);
assert!(outcomes.iter().all(|o| {
o.outcome.as_ref().unwrap_err().kind() == ProjectDiagnosticKind::UnknownProject
}));
}
#[test]
fn a_missing_reference_names_the_issue_and_the_id() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(4, "Four").with(&["p-aaaa", "p-gone"])];
let missing = validate_references(&config, &issues);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].issue.number(), 4);
assert_eq!(missing[0].issue_display, "#4 Four");
assert_eq!(missing[0].project_id, "p-gone");
assert!(missing[0].reason.contains("#4"));
assert!(missing[0].reason.contains("p-gone"));
}
#[test]
fn a_resolvable_set_reports_no_missing_references() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
assert_eq!(
validate_references(&config, &issues),
[] as [crate::projects::MissingProjectReference; 0]
);
}
#[test]
fn a_duplicated_missing_id_is_reported_once() {
let config = Config::default();
let issues = vec![Node::new(1, "One").with(&["p-gone", "p-gone"])];
assert_eq!(validate_references(&config, &issues).len(), 1);
}
#[test]
fn a_malformed_id_is_a_missing_reference_not_a_panic() {
let config = Config::default();
let issues = vec![Node::new(1, "One").with(&["not a valid id"])];
let missing = validate_references(&config, &issues);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].project_id, "not a valid id");
}
#[test]
fn validate_references_is_deterministic() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(3, "Three").with(&["p-z", "p-a"]),
Node::new(1, "One").with(&["p-m"]),
];
let first = validate_references(&config, &issues);
let second = validate_references(&config, &issues);
assert_eq!(first, second);
assert_eq!(
first
.iter()
.map(|m| (m.issue.number(), m.project_id.as_str()))
.collect::<Vec<_>>(),
vec![(1, "p-m"), (3, "p-a"), (3, "p-z")],
"ordered by (issue number, id)"
);
}
#[test]
fn a_missing_reference_is_accounted_for_in_the_index() {
let config = Config::default();
let issues = vec![Node::new(1, "One").with(&["p-gone"])];
let index = ProjectIndex::build(&config, &issues);
assert!(index.buckets().is_empty());
assert_eq!(index.missing().len(), 1);
}
#[test]
fn the_operations_run_on_in_memory_values() {
let mut config = Config::default();
let created = create_project(
&mut config,
&NewProject {
name: "In memory".into(),
..NewProject::default()
},
)
.unwrap();
let issues = vec![Node::new(1, "One")];
let change = associate(&config, &issues, 1, created.id.as_str()).unwrap();
assert_eq!(change.project_ids, vec![created.id.to_string()]);
assert_eq!(list_projects(&config, &issues).len(), 1);
assert_eq!(
validate_references(&config, &issues),
[] as [crate::projects::MissingProjectReference; 0]
);
}
#[test]
fn t015_a_project_with_four_members_and_one_complete_reports_one_of_four() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
Node::new(2, "Two").with(&["p-aaaa"]),
Node::new(3, "Three").with(&["p-aaaa"]),
Node::new(4, "Four").with(&["p-aaaa"]),
];
let assigned = assigned_keys(&issues);
let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
assert_eq!(report.progress.done, 1);
assert_eq!(report.progress.total, 4);
assert_eq!(
report.progress.progress,
Progress { done: 1, total: 4 },
"the typed planner value is reused, not restated"
);
assert_eq!(report.project_id.as_str(), "p-aaaa");
assert_eq!(report.name, "One");
}
#[test]
fn t015_an_empty_project_reports_zeros() {
let config = registry(&[("p-aaaa", "One")]);
let report =
health::<Node, _>(&config, &[], "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
assert_eq!(report.progress.progress, Progress { done: 0, total: 0 });
assert_eq!(report.health.overdue, 0);
assert_eq!(report.health.blocked, 0);
assert_eq!(report.health.unassigned, 0);
assert_eq!(
report.health.missing_references,
[] as [crate::projects::MissingProjectReference; 0]
);
}
#[test]
fn t015_completion_resolves_from_the_configured_column_not_a_literal() {
let mut config = custom_completion(
&["triage", "wip", "shipped"],
"shipped",
&[
("triage", "unstarted"),
("wip", "active"),
("shipped", "completed"),
],
);
config.projects.insert(
"p-aaaa".to_string(),
ProjectConfiguration {
name: "One".to_string(),
..ProjectConfiguration::default()
},
);
let issues = vec![
Node::new(1, "Shipped")
.in_column("shipped")
.with(&["p-aaaa"]),
Node::new(2, "Literal done")
.in_column("done")
.with(&["p-aaaa"]),
];
let report = health(&config, &issues, "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
assert_eq!(
report.progress.done, 1,
"only the configured completion column counts"
);
assert_eq!(report.progress.total, 2);
}
#[test]
fn t016_health_counts_the_known_fixture_exactly() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(1, "Complete")
.in_column("done")
.assigned_to("alice")
.with(&["p-aaaa"]),
Node::new(2, "Overdue")
.assigned_to("bob")
.with(&["p-aaaa", "p-gone"]),
Node::new(3, "Blocked")
.assigned_to("carol")
.with(&["p-aaaa"]),
Node::new(4, "Unassigned").with(&["p-aaaa"]),
];
let assigned = assigned_keys(&issues);
let report = health(
&config,
&issues,
"p-aaaa",
&assigned,
&set(&[1, 2]),
&set(&[3]),
)
.unwrap();
assert_eq!(report.progress.done, 1);
assert_eq!(report.progress.total, 4);
assert_eq!(report.health.overdue, 1, "a done issue is not overdue");
assert_eq!(report.health.blocked, 1);
assert_eq!(report.health.unassigned, 1);
assert_eq!(report.health.missing_references.len(), 1);
let missing = &report.health.missing_references[0];
assert_eq!(missing.issue.number(), 2);
assert_eq!(missing.project_id, "p-gone");
assert_ne!(missing.reason, "");
}
#[test]
fn t016_a_done_member_is_never_overdue() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
Node::new(2, "Open").with(&["p-aaaa"]),
];
let report = health(
&config,
&issues,
"p-aaaa",
&set(&[1, 2]),
&set(&[1, 2]),
&set(&[]),
)
.unwrap();
assert_eq!(
report.health.overdue, 1,
"only the open overdue member counts"
);
}
#[test]
fn t016_a_blank_assignee_counts_as_unassigned() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(1, "Absent").with(&["p-aaaa"]),
Node::new(2, "Blank").assigned_to(" ").with(&["p-aaaa"]),
Node::new(3, "Assigned")
.assigned_to("alice")
.with(&["p-aaaa"]),
];
let assigned = assigned_keys(&issues);
assert_eq!(assigned, set(&[3]), "absent and blank are both unassigned");
let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
assert_eq!(report.health.unassigned, 2);
}
#[test]
fn t016_a_resolvable_member_set_reports_no_missing_references() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
let report = health(&config, &issues, "p-aaaa", &set(&[1]), &set(&[]), &set(&[])).unwrap();
assert_eq!(
report.health.missing_references,
[] as [crate::projects::MissingProjectReference; 0]
);
}
#[test]
fn t016_health_refuses_an_unknown_project() {
let config = registry(&[("p-aaaa", "One")]);
let err =
health::<Node, _>(&config, &[], "p-nope", &set(&[]), &set(&[]), &set(&[])).unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
}
#[test]
fn t019_c006_the_health_read_is_deterministic_and_correct() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(1, "Complete")
.in_column("done")
.assigned_to("alice")
.with(&["p-aaaa"]),
Node::new(2, "Overdue").assigned_to("bob").with(&["p-aaaa"]),
Node::new(3, "Blocked")
.assigned_to("carol")
.with(&["p-aaaa"]),
Node::new(4, "Unassigned").with(&["p-aaaa"]),
];
let assigned = assigned_keys(&issues);
let first = health(
&config,
&issues,
"p-aaaa",
&assigned,
&set(&[2]),
&set(&[3]),
)
.unwrap();
assert_eq!(
first.progress,
ProjectProgress {
done: 1,
total: 4,
progress: Progress { done: 1, total: 4 },
}
);
assert_eq!(first.health.overdue, 1);
assert_eq!(first.health.blocked, 1);
assert_eq!(first.health.unassigned, 1);
for _ in 0..3 {
let again = health(
&config,
&issues,
"p-aaaa",
&assigned,
&set(&[2]),
&set(&[3]),
)
.unwrap();
assert_eq!(again, first, "repeated reads of one snapshot are identical");
}
}
#[test]
fn t019_c006_a_noop_association_change_leaves_the_figures_unchanged() {
let config = registry(&[("p-aaaa", "One")]);
let issues = vec![
Node::new(1, "Complete").in_column("done").with(&["p-aaaa"]),
Node::new(2, "Open").with(&["p-aaaa"]),
];
let assigned = assigned_keys(&issues);
let before = health(&config, &issues, "p-aaaa", &assigned, &set(&[2]), &set(&[])).unwrap();
let change = dissociate(&issues, 1, "p-bbbb").unwrap();
assert_eq!(ids(&change), ["p-aaaa"], "the list is unchanged");
assert!(change.event.is_none(), "no event is emitted");
let mut applied = issues.clone();
applied[0].projects = change.project_ids.clone();
let assigned_after = assigned_keys(&applied);
let after = health(
&config,
&applied,
"p-aaaa",
&assigned_after,
&set(&[2]),
&set(&[]),
)
.unwrap();
assert_eq!(
before, after,
"applying the no-op leaves the figures unchanged"
);
}
}