1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4pub const EPICS_KEY: &str = "epics";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct EpicRef {
16 pub id: EpicId,
18 pub name: String,
20 pub display: String,
22}
23
24impl EpicRef {
25 #[must_use]
27 pub fn new(id: &EpicId, name: &str) -> Self {
28 Self {
29 id: id.clone(),
30 name: name.to_string(),
31 display: epic_display(id, name),
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Epic {
45 pub id: EpicId,
47 pub name: String,
49 pub description: Option<String>,
51 pub status: Option<String>,
53 pub owner: Option<String>,
55 pub start_date: Option<String>,
57 pub due_date: Option<String>,
59 pub weight: Option<i64>,
61 pub parent: Option<String>,
64 pub projects: Vec<String>,
66 pub milestone: Option<String>,
68 pub cycle: Option<String>,
70 pub members: Vec<MemberRef>,
72 pub member_count: usize,
74 pub children: Vec<EpicRef>,
76 pub child_count: usize,
78}
79
80impl Epic {
81 pub(super) fn from_configuration(
83 id: EpicId,
84 record: &EpicConfiguration,
85 members: Vec<MemberRef>,
86 children: Vec<EpicRef>,
87 ) -> Self {
88 Self {
89 id,
90 name: record.name.clone(),
91 description: record.description.clone(),
92 status: record.status.clone(),
93 owner: record.owner.clone(),
94 start_date: record.start_date.clone(),
95 due_date: record.due_date.clone(),
96 weight: record.weight,
97 parent: record.parent.clone(),
98 projects: record.projects.clone(),
99 milestone: record.milestone.clone(),
100 cycle: record.cycle.clone(),
101 member_count: members.len(),
102 members,
103 child_count: children.len(),
104 children,
105 }
106 }
107}
108
109#[must_use]
115pub fn epic_display(id: &EpicId, name: &str) -> String {
116 format!("{name} ({id})")
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
124pub enum EpicDiagnosticKind {
125 DuplicateName,
127 UnknownEpic,
129 UnknownIssue,
131 InvalidDates,
133 InvalidWeight,
135 InvalidReference,
137 SelfParent,
139 Cycle,
141}
142
143impl EpicDiagnosticKind {
144 #[must_use]
146 pub fn as_str(self) -> &'static str {
147 match self {
148 EpicDiagnosticKind::DuplicateName => "duplicate_name",
149 EpicDiagnosticKind::UnknownEpic => "unknown_epic",
150 EpicDiagnosticKind::UnknownIssue => "unknown_issue",
151 EpicDiagnosticKind::InvalidDates => "invalid_dates",
152 EpicDiagnosticKind::InvalidWeight => "invalid_weight",
153 EpicDiagnosticKind::InvalidReference => "invalid_reference",
154 EpicDiagnosticKind::SelfParent => "self_parent",
155 EpicDiagnosticKind::Cycle => "cycle",
156 }
157 }
158}
159
160impl fmt::Display for EpicDiagnosticKind {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.write_str(self.as_str())
163 }
164}
165
166#[must_use]
168pub fn unknown_epic_reason(reference: &str) -> String {
169 format!("no epic matches `{reference}`; an epic is selected by its stable id or by name")
170}
171
172#[must_use]
174pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
175 format!("issue `{selector}` is not in this store")
176}
177
178#[must_use]
183pub fn invalid_dates_reason(detail: &str) -> String {
184 format!("epic dates are invalid: {detail}")
185}
186
187#[must_use]
189pub fn invalid_weight_reason(value: i64) -> String {
190 format!("epic weight must be a positive integer; got {value}")
191}
192
193#[must_use]
200pub fn invalid_reference_reason(field: &str, value: &str) -> String {
201 format!(
202 "`epics.<id>.{field}` entry `{value}` is not a well-formed id; \
203 an epic references the target by its stable id"
204 )
205}
206
207#[must_use]
209pub fn duplicate_name_reason(name: &str) -> String {
210 if name.trim().is_empty() {
211 "an epic name must not be blank".to_string()
212 } else if name.trim() != name {
213 format!("epic name `{name}` has leading or trailing whitespace")
214 } else {
215 format!("epic name `{name}` is already in use; epic names must be unique")
216 }
217}
218
219#[must_use]
221pub fn self_parent_reason(epic: &EpicId) -> String {
222 format!("epic `{epic}` cannot be its own parent")
223}
224
225#[must_use]
227pub fn cycle_reason(epic: &EpicId, path: &[EpicId]) -> String {
228 let rendered: Vec<&str> = path.iter().map(EpicId::as_str).collect();
229 format!(
230 "parent would close an epic cycle: `{epic}` -> {}",
231 rendered.join(" -> ")
232 )
233}
234
235#[must_use]
237pub fn unresolved_parent_reason(epic: &EpicId, recorded_parent: &str) -> String {
238 format!(
239 "epic `{epic}` records parent `{recorded_parent}`, which no longer exists \
240 (the reference is kept so it can be repaired)"
241 )
242}
243
244#[must_use]
249pub fn reference_detail(epic_id: &str, referenced: bool) -> String {
250 if referenced {
251 format!("epic `{epic_id}` referenced")
252 } else {
253 format!("epic `{epic_id}` dereferenced")
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
262pub enum EpicError {
263 UnknownEpic {
265 reference: String,
267 },
268 UnknownIssue {
270 selector: u32,
272 },
273 DuplicateName {
275 name: String,
277 },
278 InvalidDates {
280 detail: String,
282 },
283 InvalidWeight {
285 value: i64,
287 },
288 InvalidReference {
290 field: &'static str,
292 value: String,
294 },
295 SelfParent {
297 epic: EpicId,
299 },
300 Cycle {
302 epic: EpicId,
304 path: Vec<EpicId>,
306 },
307}
308
309impl EpicError {
310 #[must_use]
312 pub fn kind(&self) -> EpicDiagnosticKind {
313 match self {
314 EpicError::UnknownEpic { .. } => EpicDiagnosticKind::UnknownEpic,
315 EpicError::UnknownIssue { .. } => EpicDiagnosticKind::UnknownIssue,
316 EpicError::DuplicateName { .. } => EpicDiagnosticKind::DuplicateName,
317 EpicError::InvalidDates { .. } => EpicDiagnosticKind::InvalidDates,
318 EpicError::InvalidWeight { .. } => EpicDiagnosticKind::InvalidWeight,
319 EpicError::InvalidReference { .. } => EpicDiagnosticKind::InvalidReference,
320 EpicError::SelfParent { .. } => EpicDiagnosticKind::SelfParent,
321 EpicError::Cycle { .. } => EpicDiagnosticKind::Cycle,
322 }
323 }
324
325 #[must_use]
327 pub fn message(&self) -> String {
328 match self {
329 EpicError::UnknownEpic { reference } => unknown_epic_reason(reference),
330 EpicError::UnknownIssue { selector } => unknown_issue_reason(selector),
331 EpicError::DuplicateName { name } => duplicate_name_reason(name),
332 EpicError::InvalidDates { detail } => invalid_dates_reason(detail),
333 EpicError::InvalidWeight { value } => invalid_weight_reason(*value),
334 EpicError::InvalidReference { field, value } => invalid_reference_reason(field, value),
335 EpicError::SelfParent { epic } => self_parent_reason(epic),
336 EpicError::Cycle { epic, path } => cycle_reason(epic, path),
337 }
338 }
339}
340
341impl fmt::Display for EpicError {
342 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343 write!(f, "{}: {}", self.kind(), self.message())
344 }
345}
346
347impl std::error::Error for EpicError {}
348
349impl From<EpicError> for TixError {
355 fn from(err: EpicError) -> TixError {
356 TixError::InvalidInput(err.to_string())
357 }
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Eq)]
362pub struct NewEpic {
363 pub name: String,
365 pub description: Option<String>,
367 pub status: Option<String>,
369 pub owner: Option<String>,
371 pub start_date: Option<String>,
373 pub due_date: Option<String>,
375 pub weight: Option<i64>,
377 pub projects: Vec<String>,
380 pub milestone: Option<String>,
382 pub cycle: Option<String>,
384}
385
386#[derive(Debug, Clone, Default, PartialEq, Eq)]
388pub struct EpicEdit {
389 pub name: Patch<String>,
391 pub description: Patch<String>,
393 pub status: Patch<String>,
395 pub owner: Patch<String>,
397 pub start_date: Patch<String>,
399 pub due_date: Patch<String>,
401 pub weight: Patch<i64>,
403 pub parent: Patch<String>,
406 pub projects: Patch<Vec<String>>,
409 pub milestone: Patch<String>,
411 pub cycle: Patch<String>,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct EpicDeletion {
423 pub id: EpicId,
425 pub name: String,
427 pub affected: Vec<MemberRef>,
429 pub child_epics: Vec<EpicRef>,
431}
432
433pub(super) fn normalize_optional(value: Option<String>) -> Option<String> {
438 value.filter(|v| !v.trim().is_empty())
439}
440
441pub(super) fn validate_name(
448 registry: &IndexMap<String, EpicConfiguration>,
449 name: &str,
450 exclude: Option<&EpicId>,
451) -> Result<(), EpicError> {
452 if name.trim().is_empty() || name.trim() != name {
453 return Err(EpicError::DuplicateName {
454 name: name.to_string(),
455 });
456 }
457 let normalized = name.to_lowercase();
458 let clash = registry.iter().any(|(raw, record)| {
459 let is_self = exclude.is_some_and(|id| raw.as_str() == id.as_str());
460 !is_self && record.name.to_lowercase() == normalized
461 });
462 if clash {
463 return Err(EpicError::DuplicateName {
464 name: name.to_string(),
465 });
466 }
467 Ok(())
468}
469
470pub(super) fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), EpicError> {
472 crate::model::validate_issue_dates(start, due).map_err(|err| EpicError::InvalidDates {
473 detail: err.to_string(),
474 })
475}
476
477pub(super) fn validate_project_ids(list: &[String]) -> Result<Vec<String>, EpicError> {
485 let mut out: Vec<String> = Vec::new();
486 for entry in list {
487 let id = ProjectId::new(entry).map_err(|_| EpicError::InvalidReference {
488 field: "projects",
489 value: entry.clone(),
490 })?;
491 if !out.iter().any(|stored| stored == id.as_str()) {
492 out.push(id.to_string());
493 }
494 }
495 Ok(out)
496}
497
498pub(super) fn validate_weight(weight: i64) -> Result<(), EpicError> {
500 if weight > 0 {
501 return Ok(());
502 }
503 Err(EpicError::InvalidWeight { value: weight })
504}
505
506pub fn resolve_epic_id(
513 registry: &IndexMap<String, EpicConfiguration>,
514 reference: &str,
515) -> Result<EpicId, EpicError> {
516 if let Ok(id) = EpicId::new(reference) {
517 if registry.contains_key(id.as_str()) {
518 return Ok(id);
519 }
520 }
521 let normalized = reference.trim().to_lowercase();
522 if let Some((raw, _)) = registry
523 .iter()
524 .find(|(_, record)| record.name.to_lowercase() == normalized)
525 {
526 return EpicId::new(raw).map_err(|_| EpicError::UnknownEpic {
527 reference: reference.to_string(),
528 });
529 }
530 Err(EpicError::UnknownEpic {
531 reference: reference.to_string(),
532 })
533}
534
535pub(super) fn recorded_children(
537 registry: &IndexMap<String, EpicConfiguration>,
538 id: &EpicId,
539) -> Vec<EpicRef> {
540 registry
541 .iter()
542 .filter(|(_, record)| record.parent.as_deref() == Some(id.as_str()))
543 .filter_map(|(raw, record)| {
544 EpicId::new(raw)
545 .ok()
546 .map(|child| EpicRef::new(&child, &record.name))
547 })
548 .collect()
549}
550
551pub fn create_epic(config: &mut Config, new: &NewEpic) -> Result<Epic, EpicError> {
559 validate_name(&config.epics, &new.name, None)?;
560 validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
561 if let Some(weight) = new.weight {
562 validate_weight(weight)?;
563 }
564 let projects = validate_project_ids(&new.projects)?;
565 let id = config.allocate_epic_id();
566 let record = EpicConfiguration {
567 name: new.name.clone(),
568 description: normalize_optional(new.description.clone()),
569 status: normalize_optional(new.status.clone()),
570 owner: normalize_optional(new.owner.clone()),
571 start_date: new.start_date.clone(),
572 due_date: new.due_date.clone(),
573 weight: new.weight,
574 projects,
575 milestone: normalize_optional(new.milestone.clone()),
576 cycle: normalize_optional(new.cycle.clone()),
577 ..EpicConfiguration::default()
578 };
579 config.epics.insert(id.to_string(), record.clone());
580 Ok(Epic::from_configuration(
581 id,
582 &record,
583 Vec::new(),
584 Vec::new(),
585 ))
586}
587
588pub fn edit_epic(config: &mut Config, reference: &str, edit: &EpicEdit) -> Result<Epic, EpicError> {
596 let id = resolve_epic_id(&config.epics, reference)?;
597 let mut updated =
598 config
599 .epics
600 .get(id.as_str())
601 .cloned()
602 .ok_or_else(|| EpicError::UnknownEpic {
603 reference: reference.to_string(),
604 })?;
605
606 match &edit.name {
608 Patch::Unchanged => {}
609 Patch::Clear => {
610 return Err(EpicError::DuplicateName {
611 name: String::new(),
612 });
613 }
614 Patch::Set(name) => {
615 validate_name(&config.epics, name, Some(&id))?;
616 }
617 }
618 if let Patch::Set(weight) = &edit.weight {
619 validate_weight(*weight)?;
620 }
621 let proposed_parent = match &edit.parent {
622 Patch::Unchanged => None,
623 Patch::Clear => Some(None),
624 Patch::Set(parent) => Some(Some(validate_epic_parent(&config.epics, &id, parent)?)),
625 };
626 let proposed_projects = match &edit.projects {
627 Patch::Unchanged => None,
628 Patch::Clear => Some(Vec::new()),
629 Patch::Set(list) => Some(validate_project_ids(list)?),
630 };
631
632 if let Patch::Set(name) = &edit.name {
634 updated.name.clone_from(name);
635 }
636 apply_optional(&edit.description, &mut updated.description);
637 apply_optional(&edit.status, &mut updated.status);
638 apply_optional(&edit.owner, &mut updated.owner);
639 apply_date(&edit.start_date, &mut updated.start_date);
640 apply_date(&edit.due_date, &mut updated.due_date);
641 if let Patch::Set(weight) = &edit.weight {
642 updated.weight = Some(*weight);
643 }
644 if let Patch::Clear = &edit.weight {
645 updated.weight = None;
646 }
647 if let Some(parent) = proposed_parent {
648 updated.parent = parent.map(|id| id.to_string());
649 }
650 if let Some(projects) = proposed_projects {
651 updated.projects = projects;
652 }
653 apply_optional(&edit.milestone, &mut updated.milestone);
654 apply_optional(&edit.cycle, &mut updated.cycle);
655
656 validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
657
658 if let Some(record) = config.epics.get_mut(id.as_str()) {
659 *record = updated.clone();
660 }
661 Ok(Epic::from_configuration(
662 id,
663 &updated,
664 Vec::new(),
665 Vec::new(),
666 ))
667}
668
669pub(super) fn apply_optional(patch: &Patch<String>, slot: &mut Option<String>) {
671 match patch {
672 Patch::Unchanged => {}
673 Patch::Clear => *slot = None,
674 Patch::Set(value) => *slot = normalize_optional(Some(value.clone())),
675 }
676}
677
678pub(super) fn apply_date(patch: &Patch<String>, slot: &mut Option<String>) {
680 match patch {
681 Patch::Unchanged => {}
682 Patch::Clear => *slot = None,
683 Patch::Set(value) => *slot = Some(value.clone()),
684 }
685}
686
687pub fn delete_epic<N: EpicNode>(
695 config: &mut Config,
696 reference: &str,
697 issues: &[N],
698) -> Result<EpicDeletion, EpicError> {
699 let id = resolve_epic_id(&config.epics, reference)?;
700 let record = config
701 .epics
702 .get(id.as_str())
703 .cloned()
704 .ok_or_else(|| EpicError::UnknownEpic {
705 reference: reference.to_string(),
706 })?;
707 let affected = EpicIndex::build(config, issues)
708 .epic(&id)
709 .map(|epic| epic.members.clone())
710 .unwrap_or_default();
711 let child_epics = recorded_children(&config.epics, &id);
712 config.epics.shift_remove(id.as_str());
713 Ok(EpicDeletion {
714 id,
715 name: record.name,
716 affected,
717 child_epics,
718 })
719}