#[allow(clippy::wildcard_imports)]
use super::*;
pub const EPICS_KEY: &str = "epics";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicRef {
pub id: EpicId,
pub name: String,
pub display: String,
}
impl EpicRef {
#[must_use]
pub fn new(id: &EpicId, name: &str) -> Self {
Self {
id: id.clone(),
name: name.to_string(),
display: epic_display(id, name),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Epic {
pub id: EpicId,
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub owner: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub weight: Option<i64>,
pub parent: Option<String>,
pub projects: Vec<String>,
pub milestone: Option<String>,
pub cycle: Option<String>,
pub members: Vec<MemberRef>,
pub member_count: usize,
pub children: Vec<EpicRef>,
pub child_count: usize,
}
impl Epic {
pub(super) fn from_configuration(
id: EpicId,
record: &EpicConfiguration,
members: Vec<MemberRef>,
children: Vec<EpicRef>,
) -> Self {
Self {
id,
name: record.name.clone(),
description: record.description.clone(),
status: record.status.clone(),
owner: record.owner.clone(),
start_date: record.start_date.clone(),
due_date: record.due_date.clone(),
weight: record.weight,
parent: record.parent.clone(),
projects: record.projects.clone(),
milestone: record.milestone.clone(),
cycle: record.cycle.clone(),
member_count: members.len(),
members,
child_count: children.len(),
children,
}
}
}
#[must_use]
pub fn epic_display(id: &EpicId, name: &str) -> String {
format!("{name} ({id})")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EpicDiagnosticKind {
DuplicateName,
UnknownEpic,
UnknownIssue,
InvalidDates,
InvalidWeight,
InvalidReference,
SelfParent,
Cycle,
}
impl EpicDiagnosticKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
EpicDiagnosticKind::DuplicateName => "duplicate_name",
EpicDiagnosticKind::UnknownEpic => "unknown_epic",
EpicDiagnosticKind::UnknownIssue => "unknown_issue",
EpicDiagnosticKind::InvalidDates => "invalid_dates",
EpicDiagnosticKind::InvalidWeight => "invalid_weight",
EpicDiagnosticKind::InvalidReference => "invalid_reference",
EpicDiagnosticKind::SelfParent => "self_parent",
EpicDiagnosticKind::Cycle => "cycle",
}
}
}
impl fmt::Display for EpicDiagnosticKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn unknown_epic_reason(reference: &str) -> String {
format!("no epic matches `{reference}`; an epic is selected by its stable id or by name")
}
#[must_use]
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!("epic dates are invalid: {detail}")
}
#[must_use]
pub fn invalid_weight_reason(value: i64) -> String {
format!("epic weight must be a positive integer; got {value}")
}
#[must_use]
pub fn invalid_reference_reason(field: &str, value: &str) -> String {
format!(
"`epics.<id>.{field}` entry `{value}` is not a well-formed id; \
an epic references the target by its stable id"
)
}
#[must_use]
pub fn duplicate_name_reason(name: &str) -> String {
if name.trim().is_empty() {
"an epic name must not be blank".to_string()
} else if name.trim() != name {
format!("epic name `{name}` has leading or trailing whitespace")
} else {
format!("epic name `{name}` is already in use; epic names must be unique")
}
}
#[must_use]
pub fn self_parent_reason(epic: &EpicId) -> String {
format!("epic `{epic}` cannot be its own parent")
}
#[must_use]
pub fn cycle_reason(epic: &EpicId, path: &[EpicId]) -> String {
let rendered: Vec<&str> = path.iter().map(EpicId::as_str).collect();
format!(
"parent would close an epic cycle: `{epic}` -> {}",
rendered.join(" -> ")
)
}
#[must_use]
pub fn unresolved_parent_reason(epic: &EpicId, recorded_parent: &str) -> String {
format!(
"epic `{epic}` records parent `{recorded_parent}`, which no longer exists \
(the reference is kept so it can be repaired)"
)
}
#[must_use]
pub fn reference_detail(epic_id: &str, referenced: bool) -> String {
if referenced {
format!("epic `{epic_id}` referenced")
} else {
format!("epic `{epic_id}` dereferenced")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EpicError {
UnknownEpic {
reference: String,
},
UnknownIssue {
selector: u32,
},
DuplicateName {
name: String,
},
InvalidDates {
detail: String,
},
InvalidWeight {
value: i64,
},
InvalidReference {
field: &'static str,
value: String,
},
SelfParent {
epic: EpicId,
},
Cycle {
epic: EpicId,
path: Vec<EpicId>,
},
}
impl EpicError {
#[must_use]
pub fn kind(&self) -> EpicDiagnosticKind {
match self {
EpicError::UnknownEpic { .. } => EpicDiagnosticKind::UnknownEpic,
EpicError::UnknownIssue { .. } => EpicDiagnosticKind::UnknownIssue,
EpicError::DuplicateName { .. } => EpicDiagnosticKind::DuplicateName,
EpicError::InvalidDates { .. } => EpicDiagnosticKind::InvalidDates,
EpicError::InvalidWeight { .. } => EpicDiagnosticKind::InvalidWeight,
EpicError::InvalidReference { .. } => EpicDiagnosticKind::InvalidReference,
EpicError::SelfParent { .. } => EpicDiagnosticKind::SelfParent,
EpicError::Cycle { .. } => EpicDiagnosticKind::Cycle,
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
EpicError::UnknownEpic { reference } => unknown_epic_reason(reference),
EpicError::UnknownIssue { selector } => unknown_issue_reason(selector),
EpicError::DuplicateName { name } => duplicate_name_reason(name),
EpicError::InvalidDates { detail } => invalid_dates_reason(detail),
EpicError::InvalidWeight { value } => invalid_weight_reason(*value),
EpicError::InvalidReference { field, value } => invalid_reference_reason(field, value),
EpicError::SelfParent { epic } => self_parent_reason(epic),
EpicError::Cycle { epic, path } => cycle_reason(epic, path),
}
}
}
impl fmt::Display for EpicError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.kind(), self.message())
}
}
impl std::error::Error for EpicError {}
impl From<EpicError> for TixError {
fn from(err: EpicError) -> TixError {
TixError::InvalidInput(err.to_string())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NewEpic {
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub owner: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub weight: Option<i64>,
pub projects: Vec<String>,
pub milestone: Option<String>,
pub cycle: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EpicEdit {
pub name: Patch<String>,
pub description: Patch<String>,
pub status: Patch<String>,
pub owner: Patch<String>,
pub start_date: Patch<String>,
pub due_date: Patch<String>,
pub weight: Patch<i64>,
pub parent: Patch<String>,
pub projects: Patch<Vec<String>>,
pub milestone: Patch<String>,
pub cycle: Patch<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicDeletion {
pub id: EpicId,
pub name: String,
pub affected: Vec<MemberRef>,
pub child_epics: Vec<EpicRef>,
}
pub(super) fn normalize_optional(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
pub(super) fn validate_name(
registry: &IndexMap<String, EpicConfiguration>,
name: &str,
exclude: Option<&EpicId>,
) -> Result<(), EpicError> {
if name.trim().is_empty() || name.trim() != name {
return Err(EpicError::DuplicateName {
name: name.to_string(),
});
}
let normalized = name.to_lowercase();
let clash = registry.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(EpicError::DuplicateName {
name: name.to_string(),
});
}
Ok(())
}
pub(super) fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), EpicError> {
crate::model::validate_issue_dates(start, due).map_err(|err| EpicError::InvalidDates {
detail: err.to_string(),
})
}
pub(super) fn validate_project_ids(list: &[String]) -> Result<Vec<String>, EpicError> {
let mut out: Vec<String> = Vec::new();
for entry in list {
let id = ProjectId::new(entry).map_err(|_| EpicError::InvalidReference {
field: "projects",
value: entry.clone(),
})?;
if !out.iter().any(|stored| stored == id.as_str()) {
out.push(id.to_string());
}
}
Ok(out)
}
pub(super) fn validate_weight(weight: i64) -> Result<(), EpicError> {
if weight > 0 {
return Ok(());
}
Err(EpicError::InvalidWeight { value: weight })
}
pub fn resolve_epic_id(
registry: &IndexMap<String, EpicConfiguration>,
reference: &str,
) -> Result<EpicId, EpicError> {
if let Ok(id) = EpicId::new(reference) {
if registry.contains_key(id.as_str()) {
return Ok(id);
}
}
let normalized = reference.trim().to_lowercase();
if let Some((raw, _)) = registry
.iter()
.find(|(_, record)| record.name.to_lowercase() == normalized)
{
return EpicId::new(raw).map_err(|_| EpicError::UnknownEpic {
reference: reference.to_string(),
});
}
Err(EpicError::UnknownEpic {
reference: reference.to_string(),
})
}
pub(super) fn recorded_children(
registry: &IndexMap<String, EpicConfiguration>,
id: &EpicId,
) -> Vec<EpicRef> {
registry
.iter()
.filter(|(_, record)| record.parent.as_deref() == Some(id.as_str()))
.filter_map(|(raw, record)| {
EpicId::new(raw)
.ok()
.map(|child| EpicRef::new(&child, &record.name))
})
.collect()
}
pub fn create_epic(config: &mut Config, new: &NewEpic) -> Result<Epic, EpicError> {
validate_name(&config.epics, &new.name, None)?;
validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
if let Some(weight) = new.weight {
validate_weight(weight)?;
}
let projects = validate_project_ids(&new.projects)?;
let id = config.allocate_epic_id();
let record = EpicConfiguration {
name: new.name.clone(),
description: normalize_optional(new.description.clone()),
status: normalize_optional(new.status.clone()),
owner: normalize_optional(new.owner.clone()),
start_date: new.start_date.clone(),
due_date: new.due_date.clone(),
weight: new.weight,
projects,
milestone: normalize_optional(new.milestone.clone()),
cycle: normalize_optional(new.cycle.clone()),
..EpicConfiguration::default()
};
config.epics.insert(id.to_string(), record.clone());
Ok(Epic::from_configuration(
id,
&record,
Vec::new(),
Vec::new(),
))
}
pub fn edit_epic(config: &mut Config, reference: &str, edit: &EpicEdit) -> Result<Epic, EpicError> {
let id = resolve_epic_id(&config.epics, reference)?;
let mut updated =
config
.epics
.get(id.as_str())
.cloned()
.ok_or_else(|| EpicError::UnknownEpic {
reference: reference.to_string(),
})?;
match &edit.name {
Patch::Unchanged => {}
Patch::Clear => {
return Err(EpicError::DuplicateName {
name: String::new(),
});
}
Patch::Set(name) => {
validate_name(&config.epics, name, Some(&id))?;
}
}
if let Patch::Set(weight) = &edit.weight {
validate_weight(*weight)?;
}
let proposed_parent = match &edit.parent {
Patch::Unchanged => None,
Patch::Clear => Some(None),
Patch::Set(parent) => Some(Some(validate_epic_parent(&config.epics, &id, parent)?)),
};
let proposed_projects = match &edit.projects {
Patch::Unchanged => None,
Patch::Clear => Some(Vec::new()),
Patch::Set(list) => Some(validate_project_ids(list)?),
};
if let Patch::Set(name) = &edit.name {
updated.name.clone_from(name);
}
apply_optional(&edit.description, &mut updated.description);
apply_optional(&edit.status, &mut updated.status);
apply_optional(&edit.owner, &mut updated.owner);
apply_date(&edit.start_date, &mut updated.start_date);
apply_date(&edit.due_date, &mut updated.due_date);
if let Patch::Set(weight) = &edit.weight {
updated.weight = Some(*weight);
}
if let Patch::Clear = &edit.weight {
updated.weight = None;
}
if let Some(parent) = proposed_parent {
updated.parent = parent.map(|id| id.to_string());
}
if let Some(projects) = proposed_projects {
updated.projects = projects;
}
apply_optional(&edit.milestone, &mut updated.milestone);
apply_optional(&edit.cycle, &mut updated.cycle);
validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
if let Some(record) = config.epics.get_mut(id.as_str()) {
*record = updated.clone();
}
Ok(Epic::from_configuration(
id,
&updated,
Vec::new(),
Vec::new(),
))
}
pub(super) 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())),
}
}
pub(super) 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_epic<N: EpicNode>(
config: &mut Config,
reference: &str,
issues: &[N],
) -> Result<EpicDeletion, EpicError> {
let id = resolve_epic_id(&config.epics, reference)?;
let record = config
.epics
.get(id.as_str())
.cloned()
.ok_or_else(|| EpicError::UnknownEpic {
reference: reference.to_string(),
})?;
let affected = EpicIndex::build(config, issues)
.epic(&id)
.map(|epic| epic.members.clone())
.unwrap_or_default();
let child_epics = recorded_children(&config.epics, &id);
config.epics.shift_remove(id.as_str());
Ok(EpicDeletion {
id,
name: record.name,
affected,
child_epics,
})
}