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}