Skip to main content

tix/epics/
references.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// Which kind of reference failed (contract §1, data-model §7).
5///
6/// The variants are the five kinds `resolve_references` diagnoses. `Epic` is a
7/// recorded **epic id** that the registry does not hold — from an epic's `parent`
8/// or from an issue's `epics` entry. `Issue` is an issue's `epics` entry that is
9/// not a well-formed epic id at all (a blank, padded, or name-shaped entry): the
10/// entry names no epic, so the `Epic` kind — which names a missing id — cannot
11/// express it. `Project`, `Milestone` and `Cycle` are an epic record's own
12/// reference fields.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub enum EpicReferenceKind {
15    /// A recorded epic id the registry does not hold.
16    Epic,
17    /// An epic's project id the project registry does not hold.
18    Project,
19    /// An issue-side `epics` entry that is not a well-formed epic id.
20    Issue,
21    /// An epic's milestone name the milestone registry does not hold.
22    Milestone,
23    /// An epic's cycle name the cycle registry does not hold.
24    Cycle,
25}
26
27impl EpicReferenceKind {
28    /// The canonical snake_case spelling a surface prints.
29    #[must_use]
30    pub fn as_str(self) -> &'static str {
31        match self {
32            EpicReferenceKind::Epic => "epic",
33            EpicReferenceKind::Project => "project",
34            EpicReferenceKind::Issue => "issue",
35            EpicReferenceKind::Milestone => "milestone",
36            EpicReferenceKind::Cycle => "cycle",
37        }
38    }
39
40    /// The `tix lint` diagnostic kind for a missing reference of this kind
41    /// (contract `cli-surface.md` §4).
42    #[must_use]
43    pub fn diagnostic_kind(self) -> &'static str {
44        match self {
45            EpicReferenceKind::Epic => "missing_epic_reference",
46            EpicReferenceKind::Project => "missing_project_reference",
47            EpicReferenceKind::Issue => "missing_issue_reference",
48            EpicReferenceKind::Milestone => "missing_milestone_reference",
49            EpicReferenceKind::Cycle => "missing_cycle_reference",
50        }
51    }
52}
53
54impl fmt::Display for EpicReferenceKind {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60/// The entity that holds a failing reference (data-model §7's holder).
61///
62/// A reference lives either on an epic record or in an issue's `epics` list, and
63/// a diagnostic that did not say which would make the user edit the wrong file.
64/// One enum rather than a second diagnostic type, because a second type for one
65/// direction is the second resolver C-002 forbids.
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
67pub enum ReferenceHolder {
68    /// An epic-held reference, by epic id.
69    Epic(EpicId),
70    /// An issue-held reference, by issue number.
71    Issue(IssueKey),
72}
73
74impl fmt::Display for ReferenceHolder {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            ReferenceHolder::Epic(id) => write!(f, "epic `{id}`"),
78            ReferenceHolder::Issue(key) => write!(f, "issue {key}"),
79        }
80    }
81}
82
83/// The diagnosed outcome for a reference that does not resolve (data-model §7).
84///
85/// The diagnostic names the **failing reference**, never merely that something is
86/// missing, so `tix lint` can point at the field to repair.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct EpicReferenceResolution {
89    /// The entity holding the reference field.
90    pub holder: ReferenceHolder,
91    /// Which kind of reference failed.
92    pub target_kind: EpicReferenceKind,
93    /// The reference exactly as recorded, retained for repair.
94    pub target: String,
95    /// Human-readable text, reused verbatim by lint, the CLI, the TUI and MCP.
96    pub reason: String,
97}
98
99/// The single wording for a recorded epic id that does not resolve.
100#[must_use]
101pub fn missing_epic_reason(holder: &ReferenceHolder, target: &str) -> String {
102    format!(
103        "{holder} records epic reference `{target}`, which is not in the registry \
104         (the reference is kept so it can be repaired)"
105    )
106}
107
108/// The single wording for an issue-side entry that names no epic at all.
109#[must_use]
110pub fn malformed_epic_reason(issue: u32, raw: &str) -> String {
111    format!(
112        "issue #{issue} records epic reference `{raw}`, which is not a well-formed epic id \
113         (ids are recorded, never names; the entry is kept so it can be repaired)"
114    )
115}
116
117/// The single wording for an epic's project reference that does not resolve.
118#[must_use]
119pub fn missing_project_reason(epic: &EpicId, target: &str) -> String {
120    format!(
121        "epic `{epic}` references project `{target}`, which is not in the project registry \
122         (the reference is kept so it can be repaired)"
123    )
124}
125
126/// The single wording for an epic's milestone reference that does not resolve.
127#[must_use]
128pub fn missing_milestone_reason(epic: &EpicId, target: &str) -> String {
129    format!(
130        "epic `{epic}` references milestone `{target}`, which is not in the milestone registry \
131         (the reference is kept so it can be repaired)"
132    )
133}
134
135/// The single wording for an epic's cycle reference that does not resolve.
136#[must_use]
137pub fn missing_cycle_reason(epic: &EpicId, target: &str) -> String {
138    format!(
139        "epic `{epic}` references cycle `{target}`, which is not in the cycle registry \
140         (the reference is kept so it can be repaired)"
141    )
142}
143
144/// The ordering every diagnostic list uses: holder, then kind, then target.
145pub(super) fn reference_order(
146    a: &EpicReferenceResolution,
147    b: &EpicReferenceResolution,
148) -> std::cmp::Ordering {
149    a.holder
150        .cmp(&b.holder)
151        .then_with(|| a.target_kind.cmp(&b.target_kind))
152        .then_with(|| a.target.cmp(&b.target))
153}
154
155/// The resolvable targets of one snapshot, consumed from their owners.
156///
157/// This module performs no file I/O: the sets are plain in-memory collections, so
158/// a caller with a fixture — a unit test, or a surface that already resolved them
159/// — builds one directly. [`ReferenceTargets::from_planner`] is the optional
160/// production adapter: it consumes the **existing** lookups, including
161/// `Planner::milestone_names()`, which is filesystem-backed, plus
162/// `Planner::cycle_names()` and the implicit `Default` cycle `Planner::cycle()`
163/// answers for, and the project registry for project ids. No second milestone or
164/// cycle store exists (research R2).
165///
166/// Each set is a `HashSet`, so a lookup is a hash probe rather than a scan: with
167/// `E` epics and `p` references each, `resolve_references` stays one pass over the
168/// issues plus one pass over the registry (contract §7, NFR-001).
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
170pub struct ReferenceTargets {
171    /// Project ids the project registry holds, as the registry keys them.
172    pub projects: HashSet<String>,
173    /// Milestone names the milestone registry holds.
174    pub milestones: HashSet<String>,
175    /// Cycle names the cycle registry answers for.
176    pub cycles: HashSet<String>,
177}
178
179impl ReferenceTargets {
180    /// Consume the existing planning lookups for one [`Planner`].
181    ///
182    /// This is the production adapter: milestones come from
183    /// [`Planner::milestone_names`] and cycles from [`Planner::cycle_names`] plus
184    /// the implicit `Default` cycle [`Planner::cycle`] answers for even when no
185    /// file holds it. Both sets are built once here, so the diagnosis below is a
186    /// probe per reference rather than a scan per reference.
187    pub fn from_planner(planner: &Planner) -> Result<Self> {
188        let mut cycles: HashSet<String> = planner.cycle_names().into_iter().collect();
189        if planner.cycle(DEFAULT_CYCLE_NAME).is_some() {
190            cycles.insert(DEFAULT_CYCLE_NAME.to_string());
191        }
192        Ok(Self {
193            projects: planner.config().projects.keys().cloned().collect(),
194            milestones: planner.milestone_names()?.into_iter().collect(),
195            cycles,
196        })
197    }
198
199    /// Whether the project registry holds `id`.
200    #[must_use]
201    pub fn project_known(&self, id: &str) -> bool {
202        self.projects.contains(id)
203    }
204
205    /// Whether the milestone registry holds `name`.
206    #[must_use]
207    pub fn milestone_known(&self, name: &str) -> bool {
208        self.milestones.contains(name)
209    }
210
211    /// Whether the cycle registry answers for `name`.
212    #[must_use]
213    pub fn cycle_known(&self, name: &str) -> bool {
214        self.cycles.contains(name)
215    }
216}
217
218/// Every reference in the snapshot that does not resolve (contract §1, §5).
219///
220/// **Never** returns an error — lint consumes the list directly — and performs one
221/// pass over the issue set (the index build) plus one pass over the registry
222/// rather than one lookup per reference (contract §7). Each failing reference is
223/// reported once, naming the holder, the kind, and the recorded target, ordered by
224/// `(holder, kind, target)` so lint output is stable (C-005, contract §8).
225///
226/// `targets` carries what the owners resolved: the project registry, and the
227/// milestone and cycle names from the `Planner` lookups (contract §2). The service
228/// stores none of them.
229#[must_use]
230pub fn resolve_references<N: EpicNode>(
231    registry: &IndexMap<String, EpicConfiguration>,
232    issues: &[N],
233    targets: &ReferenceTargets,
234) -> Vec<EpicReferenceResolution> {
235    let index = EpicIndex::build_from_registry(registry, issues);
236    let mut out: Vec<EpicReferenceResolution> = index.missing().to_vec();
237    for epic in index.epics() {
238        out.extend(index.missing_for(&epic.id, targets));
239    }
240    out.sort_by(reference_order);
241    out
242}
243
244/// Find an issue in the snapshot by its number.
245pub(super) fn find_issue<N: EpicNode>(issues: &[N], selector: u32) -> Result<&N, EpicError> {
246    issues
247        .iter()
248        .find(|issue| issue.number() == selector)
249        .ok_or(EpicError::UnknownIssue { selector })
250}
251
252/// Produce the reference change for a **resolved** epic id.
253pub(super) fn reference_resolved<N: EpicNode>(
254    issues: &[N],
255    selector: u32,
256    id: &EpicId,
257) -> Result<EpicChange, EpicError> {
258    let issue = find_issue(issues, selector)?;
259    let current = issue.epic_ids();
260    let key = IssueKey::new(selector);
261    if current.iter().any(|stored| stored == id.as_str()) {
262        return Ok(EpicChange {
263            issue: key,
264            epic_ids: current.to_vec(),
265            event: None,
266        });
267    }
268    let mut next = current.to_vec();
269    next.push(id.to_string());
270    Ok(EpicChange {
271        issue: key,
272        epic_ids: next,
273        event: Some(EpicEvent {
274            kind: EpicEventKind::EpicReferenced,
275            epic_id: id.to_string(),
276            detail: reference_detail(id.as_str(), true),
277        }),
278    })
279}
280
281/// Reference an epic from an issue, producing the change and its History event.
282///
283/// Idempotent: an already-present reference changes nothing and yields **no**
284/// event (contract §5). Refuses an unknown epic or an unknown issue; a refusal
285/// produces no change and no event.
286pub fn reference_epic<N: EpicNode>(
287    config: &Config,
288    issues: &[N],
289    issue_selector: u32,
290    epic_reference: &str,
291) -> Result<EpicChange, EpicError> {
292    let id = resolve_epic_id(&config.epics, epic_reference)?;
293    reference_resolved(issues, issue_selector, &id)
294}
295
296/// Remove an issue's reference to an epic.
297///
298/// Drops only the reference: it never deletes or modifies the issue (C-006).
299/// Dereferencing an absent reference is an idempotent no-op with no event. The
300/// epic is **not** required to exist — removing a stale reference is the repair
301/// path C-005 promises — so the only refusal is an unknown issue.
302///
303/// `epic_id` is compared against the stored list **verbatim**: unlike
304/// [`reference_epic`], an epic *name* is not resolved here. That is deliberate —
305/// the stored entries are ids, and a caller must resolve a name to an id first
306/// (via [`resolve_epic_id`]). A name therefore matches nothing and is an inert
307/// no-op, which keeps the repair path working for an id that is no longer a valid
308/// [`EpicId`] shape or no longer in the registry.
309pub fn dereference_epic<N: EpicNode>(
310    issues: &[N],
311    issue_selector: u32,
312    epic_id: &str,
313) -> Result<EpicChange, EpicError> {
314    let issue = find_issue(issues, issue_selector)?;
315    let current = issue.epic_ids();
316    let key = IssueKey::new(issue_selector);
317    if !current.iter().any(|stored| stored == epic_id) {
318        return Ok(EpicChange {
319            issue: key,
320            epic_ids: current.to_vec(),
321            event: None,
322        });
323    }
324    let next: Vec<String> = current
325        .iter()
326        .filter(|stored| stored.as_str() != epic_id)
327        .cloned()
328        .collect();
329    Ok(EpicChange {
330        issue: key,
331        epic_ids: next,
332        event: Some(EpicEvent {
333            kind: EpicEventKind::EpicDereferenced,
334            epic_id: epic_id.to_string(),
335            detail: reference_detail(epic_id, false),
336        }),
337    })
338}
339
340/// Reference each selector independently, reporting every outcome.
341///
342/// One rejection must not abort the rest (contract §5): the returned vector has
343/// one entry per selector, in selector order, and an unknown epic refuses each
344/// entry rather than the whole call.
345#[must_use]
346pub fn bulk_reference<N: EpicNode>(
347    config: &Config,
348    issues: &[N],
349    epic_reference: &str,
350    issue_selectors: &[u32],
351) -> Vec<BulkEpicOutcome> {
352    let resolved = resolve_epic_id(&config.epics, epic_reference);
353    issue_selectors
354        .iter()
355        .map(|&selector| {
356            let outcome = match &resolved {
357                Ok(id) => reference_resolved(issues, selector, id),
358                Err(err) => Err(err.clone()),
359            };
360            BulkEpicOutcome { selector, outcome }
361        })
362        .collect()
363}