Skip to main content

tix/epics/
registry.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// The frontmatter key carrying an issue's epic references.
5///
6/// WP03's field is `epics` (contracts/epic-registry.md §3); named once here so a
7/// second spelling has no home.
8pub const EPICS_KEY: &str = "epics";
9
10/// A derived reference to another epic, with its display label.
11///
12/// The label is computed on read and **never stored**: a stored name goes stale
13/// the moment the epic is renamed (data-model §2).
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct EpicRef {
16    /// The referenced epic's stable id.
17    pub id: EpicId,
18    /// The epic's display label at read time.
19    pub name: String,
20    /// `Name (e-id)`, from [`epic_display`].
21    pub display: String,
22}
23
24impl EpicRef {
25    /// Build a reference, deriving the label from the name.
26    #[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/// The service-side view of an epic (data-model §2).
37///
38/// Combines the registry record with the derived members and children.
39/// Registry-only operations ([`create_epic`], [`edit_epic`]) return it with an
40/// **empty** member list — there is no issue set to derive from — while
41/// index-backed operations ([`EpicIndex::resolve`], [`list_epics`]) fill it.
42/// Nothing here is stored.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Epic {
45    /// The stable registry key; the identity that survives a rename (C-003).
46    pub id: EpicId,
47    /// The mutable display label; never the identity.
48    pub name: String,
49    /// Optional long-form context.
50    pub description: Option<String>,
51    /// Optional epic status (free-form; not a board column, not a category).
52    pub status: Option<String>,
53    /// Optional owner identity string.
54    pub owner: Option<String>,
55    /// Optional start date (`YYYY-MM-DD`).
56    pub start_date: Option<String>,
57    /// Optional due date (`YYYY-MM-DD`).
58    pub due_date: Option<String>,
59    /// Optional positive relative size, used by the epic rollup.
60    pub weight: Option<i64>,
61    /// The recorded parent epic **id**, exactly as stored. Retained even when it
62    /// no longer resolves, so the reference can be repaired (contract §3.4).
63    pub parent: Option<String>,
64    /// Project **ids** this epic concerns, verbatim (FR-002).
65    pub projects: Vec<String>,
66    /// The recorded milestone **name**, verbatim (FR-002).
67    pub milestone: Option<String>,
68    /// The recorded cycle **name**, verbatim (FR-002).
69    pub cycle: Option<String>,
70    /// Derived members, ordered by issue number. Never stored.
71    pub members: Vec<MemberRef>,
72    /// `members.len()`, carried because listings ask for it constantly.
73    pub member_count: usize,
74    /// Derived child epics, in registry declaration order. Never stored.
75    pub children: Vec<EpicRef>,
76    /// `children.len()`.
77    pub child_count: usize,
78}
79
80impl Epic {
81    /// Project a registry record into the service view.
82    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/// The shared display form for an epic: `Harden the sync path (e-7c4a91)`.
110///
111/// One function, because every listing must show the id beside the name and a
112/// second formatting site is how two surfaces end up disagreeing about what an
113/// epic is called.
114#[must_use]
115pub fn epic_display(id: &EpicId, name: &str) -> String {
116    format!("{name} ({id})")
117}
118
119/// The diagnostic kinds the epic service names, spelled exactly.
120///
121/// The variants map one-to-one onto [`EpicError`], so a surface prints the named
122/// kind without a second classification step (contract `cli-surface.md` §4).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
124pub enum EpicDiagnosticKind {
125    /// A name is blank, padded, or already in use.
126    DuplicateName,
127    /// An epic reference names no stored epic.
128    UnknownEpic,
129    /// An issue selector names no issue in the snapshot.
130    UnknownIssue,
131    /// The start/due dates are malformed or inverted.
132    InvalidDates,
133    /// The weight is not a positive relative size.
134    InvalidWeight,
135    /// A reference field's value is not a well-formed id.
136    InvalidReference,
137    /// The proposed parent is the epic itself.
138    SelfParent,
139    /// The proposed parent chain would close a cycle.
140    Cycle,
141}
142
143impl EpicDiagnosticKind {
144    /// The canonical snake_case spelling a surface prints.
145    #[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/// The single wording for an epic reference that names no stored epic.
167#[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/// The single wording for an issue selector that names no issue.
173#[must_use]
174pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
175    format!("issue `{selector}` is not in this store")
176}
177
178/// The single wording for an epic date refusal.
179///
180/// Wraps the shared [`crate::model::validate_issue_dates`] message so the date
181/// *rule* keeps its one owner and only the wording lives here.
182#[must_use]
183pub fn invalid_dates_reason(detail: &str) -> String {
184    format!("epic dates are invalid: {detail}")
185}
186
187/// The single wording for an epic weight refusal.
188#[must_use]
189pub fn invalid_weight_reason(value: i64) -> String {
190    format!("epic weight must be a positive integer; got {value}")
191}
192
193/// The single wording for a reference field whose value is not a well-formed id.
194///
195/// The write-path half of WP01's load-time shape check: an epic references a
196/// project **by id**, so a value that is not one has to be refused rather than
197/// stored, or the registry would no longer load. A *dangling* but well-formed id
198/// is a different thing and is stored, then diagnosed (C-005).
199#[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/// The single wording for an epic name refusal.
208#[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/// The single wording for a self-parent refusal.
220#[must_use]
221pub fn self_parent_reason(epic: &EpicId) -> String {
222    format!("epic `{epic}` cannot be its own parent")
223}
224
225/// The single wording for a cycle refusal, naming the offending path.
226#[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/// The single wording for an unresolved parent epic (contract §3.4).
236#[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/// The single wording for a reference change's `## History` detail line.
245///
246/// One renderer, in the service, for every writer, so the CLI, the TUI and MCP
247/// cannot word the same change differently.
248#[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/// Why an epic operation was refused.
258///
259/// The variants map one-to-one onto [`EpicDiagnosticKind`] and carry the
260/// contract's names (`contracts/epic-service.md` §1).
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub enum EpicError {
263    /// An epic reference did not resolve.
264    UnknownEpic {
265        /// The reference exactly as the caller wrote it.
266        reference: String,
267    },
268    /// An issue selector named no issue in the snapshot.
269    UnknownIssue {
270        /// The selector exactly as the caller wrote it.
271        selector: u32,
272    },
273    /// The name is blank, padded, or already in use.
274    DuplicateName {
275        /// The name as supplied.
276        name: String,
277    },
278    /// The start/due dates are malformed or inverted.
279    InvalidDates {
280        /// The shared date rule's message.
281        detail: String,
282    },
283    /// The weight is not a positive relative size.
284    InvalidWeight {
285        /// The value as supplied.
286        value: i64,
287    },
288    /// A reference field's value is not a well-formed id.
289    InvalidReference {
290        /// The record field the value belongs to, e.g. `projects`.
291        field: &'static str,
292        /// The value as supplied.
293        value: String,
294    },
295    /// The proposed parent is the epic itself.
296    SelfParent {
297        /// The epic that would parent itself.
298        epic: EpicId,
299    },
300    /// The proposed parent chain would close a cycle.
301    Cycle {
302        /// The epic whose proposed parent closes the loop.
303        epic: EpicId,
304        /// The offending path, from the epic back to itself.
305        path: Vec<EpicId>,
306    },
307}
308
309impl EpicError {
310    /// The named diagnostic kind this refusal reports.
311    #[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    /// The single wording for this refusal, via the kind's helper.
326    #[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
349/// An epic refusal becomes user input error, so `?` works at the call site.
350///
351/// The payload is a single-line diagnostic (`<kind>: <message>`), so it takes the
352/// crate's existing `invalid input:` prefix without doubling it — the same
353/// boundary every other selector/validation refusal crosses (cli-surface §6).
354impl From<EpicError> for TixError {
355    fn from(err: EpicError) -> TixError {
356        TixError::InvalidInput(err.to_string())
357    }
358}
359
360/// The spec for a new epic (contract §1: name plus optional metadata).
361#[derive(Debug, Clone, Default, PartialEq, Eq)]
362pub struct NewEpic {
363    /// Required display label.
364    pub name: String,
365    /// Optional long-form context.
366    pub description: Option<String>,
367    /// Optional epic status.
368    pub status: Option<String>,
369    /// Optional owner identity string.
370    pub owner: Option<String>,
371    /// Optional start date.
372    pub start_date: Option<String>,
373    /// Optional due date.
374    pub due_date: Option<String>,
375    /// Optional positive relative size.
376    pub weight: Option<i64>,
377    /// Project **ids** this epic concerns (FR-002). Shape is checked; existence is
378    /// diagnosed rather than refused, so a dangling reference is repairable.
379    pub projects: Vec<String>,
380    /// Optional milestone name (the existing name-keyed reference).
381    pub milestone: Option<String>,
382    /// Optional cycle name (the existing name-keyed reference).
383    pub cycle: Option<String>,
384}
385
386/// The fields of an epic edit.
387#[derive(Debug, Clone, Default, PartialEq, Eq)]
388pub struct EpicEdit {
389    /// The display label. A rename is a label change only (C-003, NFR-002).
390    pub name: Patch<String>,
391    /// Long-form context.
392    pub description: Patch<String>,
393    /// Epic status.
394    pub status: Patch<String>,
395    /// Owner identity string.
396    pub owner: Patch<String>,
397    /// Start date.
398    pub start_date: Patch<String>,
399    /// Due date.
400    pub due_date: Patch<String>,
401    /// Weight.
402    pub weight: Patch<i64>,
403    /// The parent epic reference: `Set` resolves to the parent's id and is
404    /// validated against the epic chain, `Clear` detaches the epic.
405    pub parent: Patch<String>,
406    /// Project **ids** this epic concerns. Each entry is shape-validated and never
407    /// resolved, so a dangling-but-well-formed id is stored for repair (C-005).
408    pub projects: Patch<Vec<String>>,
409    /// Milestone name; `Clear` removes the reference.
410    pub milestone: Patch<String>,
411    /// Cycle name; `Clear` removes the reference.
412    pub cycle: Patch<String>,
413}
414
415/// The impact report of an epic deletion (contract §1).
416///
417/// It **reports**; it does not delete. The affected issues keep their references
418/// (which then read as missing references, C-005) and the child epics keep their
419/// recorded parent (which then reads as unresolved, contract §3.4), so this type
420/// carries them for the caller's notice rather than a cascade (C-006).
421#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct EpicDeletion {
423    /// The id that was removed from the registry. Never handed out again.
424    pub id: EpicId,
425    /// The name the record carried, for the notice.
426    pub name: String,
427    /// The issues that still reference the deleted epic, ordered by number.
428    pub affected: Vec<MemberRef>,
429    /// The epics that recorded it as their parent, in declaration order.
430    pub child_epics: Vec<EpicRef>,
431}
432
433/// Normalize an optional text field: a written-but-empty value is absent.
434///
435/// WP01 refuses a present-but-blank key, so collapsing it here is what keeps a
436/// record this service writes loadable. Non-blank values are kept verbatim.
437pub(super) fn normalize_optional(value: Option<String>) -> Option<String> {
438    value.filter(|v| !v.trim().is_empty())
439}
440
441/// Validate a requested display name against the registry.
442///
443/// Mirrors [`crate::config`]'s rule (`validate_epics`): non-blank, no padding,
444/// and unique case-folded, since a user does not tell `Platform` from `platform`.
445/// WP01 owns the loader's copy; this is the write-path half, pinned to the same
446/// rule by `t006_create_epic_refuses_a_duplicate_name`.
447pub(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
470/// Validate the pair through the shared date rule (WP01 uses the same helper).
471pub(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
477/// Validate a project-id list: shape only, never resolution.
478///
479/// Each entry must be a well-formed [`ProjectId`], because that is what a
480/// registry must hold to load. A well-formed id the project registry does not
481/// hold is **kept** — it is the diagnosable dangling reference C-005 promises —
482/// and an exact duplicate collapses to one reference, as a repeated issue-side id
483/// does. The caller's order is preserved.
484pub(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
498/// Validate a weight: a positive relative size, or nothing.
499pub(super) fn validate_weight(weight: i64) -> Result<(), EpicError> {
500    if weight > 0 {
501        return Ok(());
502    }
503    Err(EpicError::InvalidWeight { value: weight })
504}
505
506/// Resolve an epic id from a reference: **id first**, then a name convenience.
507///
508/// Resolution is by the stable id (C-003). A name is accepted because a human
509/// selects epics by name; it resolves to the same epic, and WP01's uniqueness
510/// rule guarantees it is unambiguous. An id that also happens to look like
511/// another epic's name wins, which is what "id first" means.
512pub 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
535/// The epics that record `id` as their parent, in declaration order.
536pub(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
551/// Create an epic, allocating a fresh stable id (FR-001).
552///
553/// Validates the name, the dates, and the weight first, so a refusal yields no
554/// registry change (contract §4). The record is inserted into the in-memory
555/// [`Config`]; the caller persists it with `Config::save`, so the atomic write
556/// path has exactly one owner. The returned view carries no members or children —
557/// a create has no issue set to derive from.
558pub 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
588/// Edit an epic's fields by reference.
589///
590/// A name change is a **label change only**: the registry key is untouched, so
591/// every member reference and derived figure stays intact (C-003, NFR-002). A
592/// proposed parent is validated — self-parent and cycle — **before** any write,
593/// so a rejection leaves the registry byte-unchanged (contract §3, §4). The
594/// returned view carries no members — an edit has no issue set to derive from.
595pub 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    // --- validation first: nothing below this block runs on a refusal --------
607    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    // --- apply --------------------------------------------------------------
633    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
669/// Apply an optional-text patch.
670pub(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
678/// Apply a date patch.
679pub(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
687/// Remove an epic from the registry, reporting what referenced it.
688///
689/// Removes the registry entry **only**. Every member issue keeps its `epics`
690/// entry (which then reads as a missing reference) and every child epic keeps its
691/// recorded parent (which then reads as unresolved), so both can be repaired
692/// rather than being silently rebound (C-005, C-006). The id is never reused
693/// (WP01's allocation carries that guarantee).
694pub 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}