Skip to main content

tix/
projects.rs

1//! The single owner of project behaviour (mission
2//! `first-class-projects-01M3BYBW`, WP02).
3//!
4//! Everything about a project lives here: identity and resolution, the registry
5//! lifecycle (create / edit / delete / list), derived membership and the index
6//! that makes a project filter a bucket lookup, the association mutations, and
7//! missing-reference diagnosis. Surfaces call this rather than re-deriving,
8//! because a second identity resolver or a stored membership index is exactly
9//! the divergence the mission's charter escalates (C-006, NFR-001).
10//!
11//! ## What this module deliberately does not do
12//!
13//! - **It touches no files.** It takes an in-memory [`Config`] and an in-memory
14//!   issue set, and returns values. Storage owns the write; this module owns the
15//!   rule. The whole surface is therefore testable with no `Store` and no
16//!   `tempfile` (T010 step 4).
17//! - **It does not know about the issue model.** [`ProjectNode`] is the minimum
18//!   an issue must expose — its number, title, status, and its `projects` id
19//!   list — and WP03 implements it for [`crate::model::Issue`] in `src/model.rs`
20//!   (this mission's WP03 owns that file), exactly as the hierarchy service's
21//!   `HierarchyNode` is implemented there. WP02 is forbidden from touching
22//!   `src/model.rs`, so the service is defined against the shape WP03 provides.
23//! - **It never mutates or returns a whole `Issue`.** [`associate`] and
24//!   [`dissociate`] return a [`ProjectChange`]: the resulting association list
25//!   plus the History event to append. Storage applies it. That is the boundary
26//!   that keeps this module model-free.
27//! - **It stores no membership.** A project's members are derived by inverting
28//!   the issues' association lists, never persisted (data-model §4, C-003).
29//! - **It does not compute progress or health.** `ProjectProgress` and
30//!   `ProjectHealth` are WP04's, added to this module additively; the single
31//!   `health` operation the contract names lands there (contract §2).
32//!
33//! ## The index is built once per read
34//!
35//! [`ProjectIndex`] is the derived view over one snapshot. It is built once
36//! ([`ProjectIndex::build`]) and every read — [`ProjectIndex::membership`],
37//! [`ProjectIndex::resolve`], [`ProjectIndex::missing`] — reads that one value
38//! rather than rebuilding. The contract's free operations each build exactly one
39//! index per call, so a filter is a bucket lookup rather than a scan (NFR-001).
40//!
41//! ## Ordering is deterministic
42//!
43//! Members are ordered by issue number, projects by declaration order, and
44//! missing references by `(issue number, project id)`, so repeated reads of the
45//! same snapshot are identical (contract §6).
46
47use std::collections::{HashMap, HashSet};
48use std::fmt;
49
50use indexmap::IndexMap;
51
52use crate::config::{Config, ProjectConfiguration, ProjectId};
53use crate::error::TixError;
54use crate::planning::Progress;
55
56/// The frontmatter key carrying an issue's project associations.
57///
58/// WP03's field is `projects` (data-model §6); named once here so a second
59/// spelling has no home.
60pub const PROJECTS_KEY: &str = "projects";
61
62/// The lint diagnostic kind for an association that no longer resolves.
63///
64/// Distinct from [`ProjectDiagnosticKind::UnknownProject`]: the latter is a
65/// *refusal* of a request naming a project, while this is a finding about a
66/// stored reference (contract `cli-surface.md` §5).
67pub const MISSING_REFERENCE_KIND: &str = "missing_project_reference";
68
69/// The minimum an issue must expose for project derivation.
70///
71/// WP03 implements this for [`crate::model::Issue`]. Keeping it a trait is what
72/// lets this module be written and tested without `src/model.rs`, which WP03
73/// owns.
74pub trait ProjectNode {
75    /// The issue's number, which is its identity within one snapshot.
76    fn number(&self) -> u32;
77    /// The issue's title, used only for the derived display label.
78    fn title(&self) -> &str;
79    /// The issue's raw status.
80    fn status(&self) -> &str;
81    /// The stored project ids, in the order the file carries them.
82    fn project_ids(&self) -> &[String];
83}
84
85// ---------------------------------------------------------------------------
86// T006 — the Project view, resolution, and lifecycle
87// ---------------------------------------------------------------------------
88
89/// A project issue number, as the derived structures key it.
90///
91/// A newtype rather than a bare `u32` so a membership list cannot be confused
92/// with any other number list, exactly as the contract spells it (`IssueKey`).
93#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
94pub struct IssueKey(u32);
95
96impl IssueKey {
97    /// Wrap an issue number.
98    #[must_use]
99    pub fn new(number: u32) -> Self {
100        Self(number)
101    }
102
103    /// The wrapped number.
104    #[must_use]
105    pub fn number(self) -> u32 {
106        self.0
107    }
108}
109
110impl fmt::Display for IssueKey {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "#{}", self.0)
113    }
114}
115
116impl From<u32> for IssueKey {
117    fn from(number: u32) -> Self {
118        Self(number)
119    }
120}
121
122impl From<IssueKey> for u32 {
123    fn from(key: IssueKey) -> Self {
124        key.0
125    }
126}
127
128/// One member of a project, with its derived display label.
129///
130/// The label is computed on read and **never stored**: a stored title goes stale
131/// the moment the issue is retitled (data-model §4).
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct MemberRef {
134    /// The member issue's number.
135    pub issue: IssueKey,
136    /// `#<number> <title>`, from the shared renderer in `hierarchy`.
137    pub display: String,
138}
139
140/// The service-side view of a project (data-model §2).
141///
142/// Combines the registry record with the derived members. Registry-only
143/// operations ([`create_project`], [`edit_project`]) return it with an **empty**
144/// member list — there is no issue set to derive from — while index-backed
145/// operations ([`resolve`], [`list_projects`]) fill it.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Project {
148    /// The stable registry key; the identity that survives a rename (C-002).
149    pub id: ProjectId,
150    /// The mutable display label; never the identity.
151    pub name: String,
152    /// Optional long-form context.
153    pub description: Option<String>,
154    /// Optional project status (free-form; not a board column).
155    pub status: Option<String>,
156    /// Optional lead identity string.
157    pub lead: Option<String>,
158    /// Optional start date (`YYYY-MM-DD`).
159    pub start_date: Option<String>,
160    /// Optional due date (`YYYY-MM-DD`).
161    pub due_date: Option<String>,
162    /// Derived members, ordered by issue number. Never stored.
163    pub members: Vec<MemberRef>,
164    /// `members.len()`, carried because listings ask for it constantly.
165    pub member_count: usize,
166}
167
168impl Project {
169    /// Project a registry record into the service view.
170    fn from_configuration(
171        id: ProjectId,
172        record: &ProjectConfiguration,
173        members: Vec<MemberRef>,
174    ) -> Self {
175        Self {
176            id,
177            name: record.name.clone(),
178            description: record.description.clone(),
179            status: record.status.clone(),
180            lead: record.lead.clone(),
181            start_date: record.start_date.clone(),
182            due_date: record.due_date.clone(),
183            member_count: members.len(),
184            members,
185        }
186    }
187}
188
189/// The shared display form for a project: `Platform hardening (p-3f9a2c)`.
190///
191/// One function, because the contract requires every listing to show the id
192/// beside the name, and a second formatting site is how two surfaces end up
193/// disagreeing about what a project is called.
194#[must_use]
195pub fn project_display(id: &ProjectId, name: &str) -> String {
196    format!("{name} ({id})")
197}
198
199/// The clearly labelled bucket for an issue in **no** project.
200pub const NO_PROJECT_BUCKET: &str = "(no project)";
201
202/// The single bucket for an issue in **several** projects.
203///
204/// One bucket rather than a duplicate per project: an issue would otherwise become
205/// several rows, and a listing's cursor, hit-testing and per-column counts are all
206/// index-into-one-list arithmetic — a card that appeared twice would need a second
207/// identity no surface has (`ProjectPartition` on the board records the same
208/// decision).
209pub const MULTIPLE_PROJECTS_BUCKET: &str = "(multiple projects)";
210
211/// The group-section label for one issue's **resolved** project memberships.
212///
213/// The one home of the bucket rule, so the board partition, `tix list --group
214/// project` and `tix status --group project` cannot label one bucket three ways:
215/// none is the clearly labelled [`NO_PROJECT_BUCKET`], one is the project **id**
216/// (the id is what selects a project, so a reader can act on the header), and
217/// several collapse to [`MULTIPLE_PROJECTS_BUCKET`].
218///
219/// `projects` holds **resolved** ids in registry declaration order, as
220/// [`ProjectIndex::resolved_memberships`] returns them; an association the registry
221/// does not hold is not a project here either and is diagnosed by `tix lint`
222/// instead.
223#[must_use]
224pub fn project_bucket_label(projects: &[String]) -> String {
225    match projects {
226        [] => NO_PROJECT_BUCKET.to_string(),
227        [only] => only.clone(),
228        _ => MULTIPLE_PROJECTS_BUCKET.to_string(),
229    }
230}
231
232/// The derived members of a project (data-model §4).
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ProjectMembership {
235    /// The project the members belong to.
236    pub project_id: ProjectId,
237    /// Members ordered by issue number.
238    pub members: Vec<MemberRef>,
239    /// `members.len()`.
240    pub member_count: usize,
241}
242
243/// A stored association whose project is not in the registry (data-model §3).
244///
245/// The reference is **reported, never dropped**: the issue keeps its id so the
246/// reference can be repaired (C-005). Ordered deterministically by
247/// `(issue number, project id)`.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct MissingProjectReference {
250    /// The issue holding the reference.
251    pub issue: IssueKey,
252    /// The issue's derived display label, so lint can name it without a lookup.
253    pub issue_display: String,
254    /// The id exactly as stored, retained for repair. May not even be a valid
255    /// [`ProjectId`] shape, which is itself a reason it does not resolve.
256    pub project_id: String,
257    /// Human-readable text, produced by [`missing_reference_reason`].
258    pub reason: String,
259}
260
261/// The diagnostic kinds `contracts/project-service.md` §1 names, spelled exactly.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum ProjectDiagnosticKind {
264    /// A project reference names neither a stored id nor a stored name.
265    UnknownProject,
266    /// An issue selector names no issue in the snapshot.
267    UnknownIssue,
268    /// The start/due dates are malformed or inverted.
269    InvalidDates,
270    /// The name is blank, padded, or already in use.
271    DuplicateName,
272}
273
274impl ProjectDiagnosticKind {
275    /// The canonical snake_case spelling a surface prints.
276    #[must_use]
277    pub fn as_str(self) -> &'static str {
278        match self {
279            ProjectDiagnosticKind::UnknownProject => "unknown_project",
280            ProjectDiagnosticKind::UnknownIssue => "unknown_issue",
281            ProjectDiagnosticKind::InvalidDates => "invalid_dates",
282            ProjectDiagnosticKind::DuplicateName => "duplicate_name",
283        }
284    }
285}
286
287impl fmt::Display for ProjectDiagnosticKind {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.write_str(self.as_str())
290    }
291}
292
293/// The single wording for an unresolvable project reference.
294#[must_use]
295pub fn unknown_project_reason(reference: &str) -> String {
296    format!("no project matches `{reference}`; a project is selected by its stable id or by name")
297}
298
299/// The single wording for an issue selector that names no issue.
300pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
301    format!("issue `{selector}` is not in this store")
302}
303
304/// The single wording for a project date refusal.
305///
306/// Wraps the shared [`crate::model::validate_issue_dates`] message so the date
307/// *rule* keeps its one owner and only the wording lives here.
308#[must_use]
309pub fn invalid_dates_reason(detail: &str) -> String {
310    format!("project dates are invalid: {detail}")
311}
312
313/// The single wording for a project name refusal.
314///
315/// One helper for the [`ProjectDiagnosticKind::DuplicateName`] kind: a blank or
316/// padded name is as unusable for selection as a duplicate, and both are the
317/// same remedy — give the project a distinct, plainly written name.
318#[must_use]
319pub fn duplicate_name_reason(name: &str) -> String {
320    if name.trim().is_empty() {
321        "a project name must not be blank".to_string()
322    } else if name.trim() != name {
323        format!("project name `{name}` has leading or trailing whitespace")
324    } else {
325        format!("project name `{name}` is already in use; project names must be unique")
326    }
327}
328
329/// The single wording for an association that no longer resolves.
330#[must_use]
331pub fn missing_reference_reason(issue: u32, project_id: &str) -> String {
332    format!(
333        "issue #{issue} references project `{project_id}`, which is not in the registry \
334         (the reference is kept so it can be repaired)"
335    )
336}
337
338/// The single wording for an association change's `## History` detail line.
339///
340/// One renderer, in the service, for every writer, so the CLI, the TUI and MCP
341/// cannot word the same change differently.
342#[must_use]
343pub fn association_detail(project_id: &str, associated: bool) -> String {
344    if associated {
345        format!("project `{project_id}` associated")
346    } else {
347        format!("project `{project_id}` dissociated")
348    }
349}
350
351/// Why a project operation was refused.
352///
353/// The variants map one-to-one onto [`ProjectDiagnosticKind`], so a surface can
354/// print the named kind without a second classification step.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum ProjectError {
357    /// A project reference did not resolve.
358    UnknownProject {
359        /// The reference exactly as the caller wrote it.
360        reference: String,
361    },
362    /// An issue selector named no issue in the snapshot.
363    UnknownIssue {
364        /// The selector exactly as the caller wrote it.
365        selector: u32,
366    },
367    /// The start/due dates are malformed or inverted.
368    InvalidDates {
369        /// The shared date rule's message.
370        detail: String,
371    },
372    /// The name is blank, padded, or already in use.
373    DuplicateName {
374        /// The name as supplied.
375        name: String,
376    },
377}
378
379impl ProjectError {
380    /// The named diagnostic kind this refusal reports.
381    #[must_use]
382    pub fn kind(&self) -> ProjectDiagnosticKind {
383        match self {
384            ProjectError::UnknownProject { .. } => ProjectDiagnosticKind::UnknownProject,
385            ProjectError::UnknownIssue { .. } => ProjectDiagnosticKind::UnknownIssue,
386            ProjectError::InvalidDates { .. } => ProjectDiagnosticKind::InvalidDates,
387            ProjectError::DuplicateName { .. } => ProjectDiagnosticKind::DuplicateName,
388        }
389    }
390
391    /// The single wording for this refusal, via the kind's helper.
392    #[must_use]
393    pub fn message(&self) -> String {
394        match self {
395            ProjectError::UnknownProject { reference } => unknown_project_reason(reference),
396            ProjectError::UnknownIssue { selector } => unknown_issue_reason(selector),
397            ProjectError::InvalidDates { detail } => invalid_dates_reason(detail),
398            ProjectError::DuplicateName { name } => duplicate_name_reason(name),
399        }
400    }
401}
402
403impl fmt::Display for ProjectError {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        write!(f, "{}: {}", self.kind(), self.message())
406    }
407}
408
409impl std::error::Error for ProjectError {}
410
411/// A project refusal becomes user input error, so `?` works at the call site.
412///
413/// The payload is a single-line diagnostic (`<kind>: <message>`), so it takes the
414/// crate's existing `invalid input:` prefix without doubling it — the same
415/// boundary every other selector/validation refusal crosses (cli-surface §6).
416impl From<ProjectError> for TixError {
417    fn from(err: ProjectError) -> TixError {
418        TixError::InvalidInput(err.to_string())
419    }
420}
421
422/// The spec for a new project.
423#[derive(Debug, Clone, Default, PartialEq, Eq)]
424pub struct NewProject {
425    /// Required display label.
426    pub name: String,
427    /// Optional long-form context.
428    pub description: Option<String>,
429    /// Optional project status.
430    pub status: Option<String>,
431    /// Optional lead identity string.
432    pub lead: Option<String>,
433    /// Optional start date.
434    pub start_date: Option<String>,
435    /// Optional due date.
436    pub due_date: Option<String>,
437}
438
439/// One field edit: leave it, clear it, or set it.
440///
441/// A three-state patch rather than `Option<Option<T>>` because "unchanged" and
442/// "clear" must be distinguishable at the call site, and an `Option` nests into a
443/// shape a reader has to count parentheses to understand.
444#[derive(Debug, Clone, PartialEq, Eq, Default)]
445pub enum Patch<T> {
446    /// Leave the field as it is.
447    #[default]
448    Unchanged,
449    /// Remove the value.
450    Clear,
451    /// Replace the value.
452    Set(T),
453}
454
455impl<T> Patch<T> {
456    /// Whether this patch leaves the field alone.
457    #[must_use]
458    pub fn is_unchanged(&self) -> bool {
459        matches!(self, Patch::Unchanged)
460    }
461}
462
463/// The fields of a project edit.
464#[derive(Debug, Clone, Default, PartialEq, Eq)]
465pub struct ProjectEdit {
466    /// The display label. A rename is a label change only (C-002, SC-005).
467    pub name: Patch<String>,
468    /// Long-form context.
469    pub description: Patch<String>,
470    /// Project status.
471    pub status: Patch<String>,
472    /// Lead identity string.
473    pub lead: Patch<String>,
474    /// Start date.
475    pub start_date: Patch<String>,
476    /// Due date.
477    pub due_date: Patch<String>,
478}
479
480/// The impact report of a project deletion (contract §1).
481///
482/// It **reports**; it does not delete. The affected issues keep their
483/// associations, which then read as missing references (C-004, C-005), so this
484/// type carries them for the caller's notice rather than a cascade.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct ProjectDeletion {
487    /// The id that was removed from the registry. Never handed out again.
488    pub id: ProjectId,
489    /// The name the record carried, for the notice.
490    pub name: String,
491    /// The issues that still reference the deleted project, by issue number.
492    pub affected: Vec<MemberRef>,
493}
494
495/// Normalize an optional text field: a written-but-empty value is absent.
496///
497/// WP01 refuses a present-but-blank key, so collapsing it here is what keeps a
498/// record this service writes loadable. Non-blank values are kept verbatim.
499fn normalize_optional(value: Option<String>) -> Option<String> {
500    value.filter(|v| !v.trim().is_empty())
501}
502
503/// Validate a requested display name against the registry.
504///
505/// Mirrors [`crate::config`]'s rule (`validate_projects`): non-blank, no padding,
506/// and unique case-folded and trimmed, since a user does not tell `Platform` from
507/// `platform`. WP01 owns the loader's copy; this is the write-path half, and the
508/// two are pinned to the same rule by `create_project_refuses_a_duplicate_name`.
509fn validate_name(
510    config: &Config,
511    name: &str,
512    exclude: Option<&ProjectId>,
513) -> Result<(), ProjectError> {
514    if name.trim().is_empty() || name.trim() != name {
515        return Err(ProjectError::DuplicateName {
516            name: name.to_string(),
517        });
518    }
519    let normalized = name.to_lowercase();
520    let clash = config.projects.iter().any(|(raw, record)| {
521        let is_self = exclude.is_some_and(|id| raw.as_str() == id.as_str());
522        !is_self && record.name.to_lowercase() == normalized
523    });
524    if clash {
525        return Err(ProjectError::DuplicateName {
526            name: name.to_string(),
527        });
528    }
529    Ok(())
530}
531
532/// Validate the pair through the shared date rule (WP01 uses the same helper).
533fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), ProjectError> {
534    crate::model::validate_issue_dates(start, due).map_err(|err| ProjectError::InvalidDates {
535        detail: err.to_string(),
536    })
537}
538
539/// Resolve a project id from a reference: **id first**, then a name convenience.
540///
541/// Resolution is by the stable id (C-002). A name is accepted because a human
542/// selects projects by name; it resolves to the same project, and WP01's
543/// uniqueness rule guarantees it is unambiguous. An id that also happens to look
544/// like another project's name wins, which is what "id first" means.
545pub fn resolve_project_id(config: &Config, reference: &str) -> Result<ProjectId, ProjectError> {
546    if let Ok(id) = ProjectId::new(reference) {
547        if config.projects.contains_key(id.as_str()) {
548            return Ok(id);
549        }
550    }
551    let normalized = reference.trim().to_lowercase();
552    if let Some((raw, _)) = config
553        .projects
554        .iter()
555        .find(|(_, record)| record.name.to_lowercase() == normalized)
556    {
557        return ProjectId::new(raw).map_err(|_| ProjectError::UnknownProject {
558            reference: reference.to_string(),
559        });
560    }
561    Err(ProjectError::UnknownProject {
562        reference: reference.to_string(),
563    })
564}
565
566/// Create a project, allocating a fresh stable id (FR-001).
567///
568/// Validates the name and dates first, so a refusal yields no registry change.
569/// The record is inserted into the in-memory [`Config`]; the caller persists it
570/// with `Config::save`, so the atomic write path has exactly one owner.
571pub fn create_project(config: &mut Config, new: &NewProject) -> Result<Project, ProjectError> {
572    validate_name(config, &new.name, None)?;
573    validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
574    let id = config.allocate_project_id();
575    let record = ProjectConfiguration {
576        name: new.name.clone(),
577        description: normalize_optional(new.description.clone()),
578        status: normalize_optional(new.status.clone()),
579        lead: normalize_optional(new.lead.clone()),
580        start_date: new.start_date.clone(),
581        due_date: new.due_date.clone(),
582    };
583    config.projects.insert(id.to_string(), record.clone());
584    Ok(Project::from_configuration(id, &record, Vec::new()))
585}
586
587/// Edit a project's fields by reference.
588///
589/// A name change is a **label change only**: the registry key is untouched, so
590/// every association and derived figure stays intact (C-002, SC-005). The
591/// returned view carries no members — an edit has no issue set to derive from.
592pub fn edit_project(
593    config: &mut Config,
594    reference: &str,
595    edit: &ProjectEdit,
596) -> Result<Project, ProjectError> {
597    let id = resolve_project_id(config, reference)?;
598    let mut updated =
599        config
600            .projects
601            .get(id.as_str())
602            .cloned()
603            .ok_or_else(|| ProjectError::UnknownProject {
604                reference: reference.to_string(),
605            })?;
606
607    match &edit.name {
608        Patch::Unchanged => {}
609        Patch::Clear => {
610            return Err(ProjectError::DuplicateName {
611                name: String::new(),
612            });
613        }
614        Patch::Set(name) => {
615            validate_name(config, name, Some(&id))?;
616            updated.name.clone_from(name);
617        }
618    }
619    apply_optional(&edit.description, &mut updated.description);
620    apply_optional(&edit.status, &mut updated.status);
621    apply_optional(&edit.lead, &mut updated.lead);
622    apply_date(&edit.start_date, &mut updated.start_date);
623    apply_date(&edit.due_date, &mut updated.due_date);
624
625    validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
626
627    if let Some(record) = config.projects.get_mut(id.as_str()) {
628        *record = updated.clone();
629    }
630    Ok(Project::from_configuration(id, &updated, Vec::new()))
631}
632
633/// Apply an optional-text patch.
634fn apply_optional(patch: &Patch<String>, slot: &mut Option<String>) {
635    match patch {
636        Patch::Unchanged => {}
637        Patch::Clear => *slot = None,
638        Patch::Set(value) => *slot = normalize_optional(Some(value.clone())),
639    }
640}
641
642/// Apply a date patch.
643fn apply_date(patch: &Patch<String>, slot: &mut Option<String>) {
644    match patch {
645        Patch::Unchanged => {}
646        Patch::Clear => *slot = None,
647        Patch::Set(value) => *slot = Some(value.clone()),
648    }
649}
650
651/// Remove a project from the registry, reporting the issues that reference it.
652///
653/// Removes the registry entry **only**. Every issue keeps its association, which
654/// then reads as a missing reference so the user can repair it (C-004, C-005).
655/// The id is never reused (WP01's allocation carries that guarantee).
656pub fn delete_project<N: ProjectNode>(
657    config: &mut Config,
658    reference: &str,
659    issues: &[N],
660) -> Result<ProjectDeletion, ProjectError> {
661    let id = resolve_project_id(config, reference)?;
662    let record =
663        config
664            .projects
665            .get(id.as_str())
666            .cloned()
667            .ok_or_else(|| ProjectError::UnknownProject {
668                reference: reference.to_string(),
669            })?;
670    let affected = ProjectIndex::build(config, issues)
671        .project(&id)
672        .map(|project| project.members.clone())
673        .unwrap_or_default();
674    config.projects.shift_remove(id.as_str());
675    Ok(ProjectDeletion {
676        id,
677        name: record.name,
678        affected,
679    })
680}
681
682// ---------------------------------------------------------------------------
683// T007 — the derived index and membership
684// ---------------------------------------------------------------------------
685
686/// The derived project view over one snapshot of issues and the registry.
687///
688/// Built once and read many times. The bucket map is the NFR-001 index; the
689/// project map is `IndexMap` so declaration order is the display order; the
690/// label/status maps carry what a member listing needs. Nothing here is stored.
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct ProjectIndex {
693    /// Projects in declaration order, each carrying its derived members.
694    projects: IndexMap<ProjectId, Project>,
695    /// The reverse index: project -> member issue keys, ascending.
696    buckets: HashMap<ProjectId, Vec<IssueKey>>,
697    /// Associations whose project does not resolve, deterministically ordered.
698    missing: Vec<MissingProjectReference>,
699    /// Issue number -> display label, for member listings.
700    labels: HashMap<IssueKey, String>,
701    /// Issue number -> status, for the deriving surfaces.
702    statuses: HashMap<IssueKey, String>,
703}
704
705impl ProjectIndex {
706    /// Build the index in **one pass** over the issue set.
707    ///
708    /// An association whose project does not resolve is kept in [`Self::missing`]
709    /// rather than dropped: a silently absent reference is the failure C-005
710    /// forbids, and [`validate_references`] reports exactly this set.
711    pub fn build<N: ProjectNode>(config: &Config, issues: &[N]) -> Self {
712        // Ids the registry actually holds. A key that is not a valid ProjectId
713        // shape cannot be referenced, so it is skipped rather than panicking.
714        let known: HashMap<String, ProjectId> = config
715            .projects
716            .keys()
717            .filter_map(|raw| ProjectId::new(raw).ok().map(|id| (raw.clone(), id)))
718            .collect();
719
720        let mut labels: HashMap<IssueKey, String> = HashMap::new();
721        let mut statuses: HashMap<IssueKey, String> = HashMap::new();
722        let mut buckets: HashMap<ProjectId, Vec<IssueKey>> = HashMap::new();
723        let mut missing: Vec<MissingProjectReference> = Vec::new();
724
725        for issue in issues {
726            let key = IssueKey::new(issue.number());
727            let display = crate::hierarchy::display_label(issue.number(), issue.title());
728            labels.insert(key, display.clone());
729            statuses.insert(key, issue.status().to_string());
730
731            // A duplicated id on one issue is one association, so it is reported
732            // once and bucketed once (T009 step 5).
733            let mut seen: HashSet<&str> = HashSet::new();
734            for raw in issue.project_ids() {
735                if !seen.insert(raw.as_str()) {
736                    continue;
737                }
738                match known.get(raw.as_str()) {
739                    Some(id) => buckets.entry(id.clone()).or_default().push(key),
740                    None => missing.push(MissingProjectReference {
741                        issue: key,
742                        issue_display: display.clone(),
743                        project_id: raw.clone(),
744                        reason: missing_reference_reason(issue.number(), raw),
745                    }),
746                }
747            }
748        }
749
750        for members in buckets.values_mut() {
751            members.sort_unstable();
752        }
753        // (issue number, id): the ordering the contract pins for lint stability.
754        missing.sort_by(|a, b| {
755            a.issue
756                .cmp(&b.issue)
757                .then_with(|| a.project_id.cmp(&b.project_id))
758        });
759
760        let mut projects: IndexMap<ProjectId, Project> = IndexMap::new();
761        for (raw, record) in &config.projects {
762            let Ok(id) = ProjectId::new(raw) else {
763                continue;
764            };
765            let members: Vec<MemberRef> = buckets
766                .get(&id)
767                .map(|keys| {
768                    keys.iter()
769                        .map(|key| MemberRef {
770                            issue: *key,
771                            display: labels.get(key).cloned().unwrap_or_else(|| key.to_string()),
772                        })
773                        .collect()
774                })
775                .unwrap_or_default();
776            projects.insert(id.clone(), Project::from_configuration(id, record, members));
777        }
778
779        Self {
780            projects,
781            buckets,
782            missing,
783            labels,
784            statuses,
785        }
786    }
787
788    /// The reverse index, as the contract spells it.
789    #[must_use]
790    pub fn buckets(&self) -> &HashMap<ProjectId, Vec<IssueKey>> {
791        &self.buckets
792    }
793
794    /// The member keys of one project.
795    ///
796    /// `None` **only** for a project the registry does not hold. A known project
797    /// with no members returns an empty slice, so "not a project" and "a project
798    /// with no members yet" stay distinguishable to a caller.
799    #[must_use]
800    pub fn bucket(&self, id: &ProjectId) -> Option<&[IssueKey]> {
801        if !self.projects.contains_key(id) {
802            return None;
803        }
804        Some(self.buckets.get(id).map_or(&[][..], Vec::as_slice))
805    }
806
807    /// Projects in declaration order.
808    #[must_use]
809    pub fn projects(&self) -> Vec<&Project> {
810        self.projects.values().collect()
811    }
812
813    /// One project by id.
814    #[must_use]
815    pub fn project(&self, id: &ProjectId) -> Option<&Project> {
816        self.projects.get(id)
817    }
818
819    /// The issue display label the index recorded.
820    #[must_use]
821    pub fn label(&self, key: IssueKey) -> Option<&str> {
822        self.labels.get(&key).map(String::as_str)
823    }
824
825    /// An issue's status, as the index recorded it.
826    #[must_use]
827    pub fn status_of(&self, key: IssueKey) -> Option<&str> {
828        self.statuses.get(&key).map(String::as_str)
829    }
830
831    /// The diagnosed associations of this snapshot.
832    #[must_use]
833    pub fn missing(&self) -> &[MissingProjectReference] {
834        &self.missing
835    }
836
837    /// Resolve a project by id (first) or by name.
838    pub fn resolve(&self, reference: &str) -> Result<&Project, ProjectError> {
839        if let Ok(id) = ProjectId::new(reference) {
840            if let Some(project) = self.projects.get(&id) {
841                return Ok(project);
842            }
843        }
844        let normalized = reference.trim().to_lowercase();
845        self.projects
846            .values()
847            .find(|project| project.name.to_lowercase() == normalized)
848            .ok_or_else(|| ProjectError::UnknownProject {
849                reference: reference.to_string(),
850            })
851    }
852
853    /// The ordered membership of a project.
854    pub fn membership(&self, reference: &str) -> Result<ProjectMembership, ProjectError> {
855        let project = self.resolve(reference)?;
856        Ok(ProjectMembership {
857            project_id: project.id.clone(),
858            members: project.members.clone(),
859            member_count: project.member_count,
860        })
861    }
862
863    /// Every issue's **resolved** project ids, in registry declaration order.
864    ///
865    /// The reverse of [`Self::buckets`]: issue number -> the ids of the projects
866    /// that hold it. Derived from the same buckets, so a grouping surface cannot
867    /// disagree with [`Self::membership`] about who is a member; an association the
868    /// registry does not hold appears in no bucket and therefore in no entry here
869    /// (it is diagnosed by [`validate_references`] instead — the chosen asymmetry
870    /// `tix search`'s `project_matches` documents).
871    ///
872    /// Declaration order is the display order, so [`project_bucket_label`] over an
873    /// entry is the same label the board's `ProjectPartition` produces.
874    #[must_use]
875    pub fn resolved_memberships(&self) -> HashMap<u32, Vec<String>> {
876        let mut out: HashMap<u32, Vec<String>> = HashMap::new();
877        for project in self.projects.values() {
878            if let Some(members) = self.buckets.get(&project.id) {
879                for member in members {
880                    out.entry(member.number())
881                        .or_default()
882                        .push(project.id.to_string());
883                }
884            }
885        }
886        out
887    }
888
889    /// Consume the index into its reverse map (the contract's return shape).
890    #[must_use]
891    pub fn into_buckets(self) -> HashMap<ProjectId, Vec<IssueKey>> {
892        self.buckets
893    }
894
895    /// Consume the index into its missing-reference list.
896    #[must_use]
897    pub fn into_missing(self) -> Vec<MissingProjectReference> {
898        self.missing
899    }
900
901    /// Consume the index into its declaration-ordered project list.
902    #[must_use]
903    pub fn into_projects(self) -> Vec<Project> {
904        self.projects.into_values().collect()
905    }
906}
907
908/// Build the reverse index once for one read (`HashMap<ProjectId, Vec<IssueKey>>`).
909///
910/// A project filter is then a bucket lookup rather than a scan (NFR-001).
911#[must_use]
912pub fn membership_index<N: ProjectNode>(
913    config: &Config,
914    issues: &[N],
915) -> HashMap<ProjectId, Vec<IssueKey>> {
916    ProjectIndex::build(config, issues).into_buckets()
917}
918
919/// The ordered membership of a project, by id or name.
920pub fn membership<N: ProjectNode>(
921    config: &Config,
922    issues: &[N],
923    reference: &str,
924) -> Result<ProjectMembership, ProjectError> {
925    ProjectIndex::build(config, issues).membership(reference)
926}
927
928/// The projects in declaration order, with derived member counts.
929///
930/// Never errors: an empty registry is an empty list, and a registry with no
931/// issues is a list with zero members.
932#[must_use]
933pub fn list_projects<N: ProjectNode>(config: &Config, issues: &[N]) -> Vec<Project> {
934    ProjectIndex::build(config, issues).into_projects()
935}
936
937/// Resolve a project by id (first) or by name.
938pub fn resolve<N: ProjectNode>(
939    config: &Config,
940    issues: &[N],
941    reference: &str,
942) -> Result<Project, ProjectError> {
943    ProjectIndex::build(config, issues)
944        .resolve(reference)
945        .cloned()
946}
947
948// ---------------------------------------------------------------------------
949// T008 — association mutations, with History events
950// ---------------------------------------------------------------------------
951
952/// The two association mutations, as the `## History` section records them.
953///
954/// Declared here rather than in `model::HistoryEventKind` because WP02 may not
955/// edit `src/model.rs`; WP03 maps these onto that enum, exactly as the hierarchy
956/// mission's `ParentEventKind` is mapped.
957#[derive(Debug, Clone, Copy, PartialEq, Eq)]
958pub enum ProjectEventKind {
959    /// An issue gained an association.
960    ProjectAssociated,
961    /// An issue lost an association.
962    ProjectDissociated,
963}
964
965impl ProjectEventKind {
966    /// The canonical snake_case spelling the History section will carry.
967    #[must_use]
968    pub fn as_str(self) -> &'static str {
969        match self {
970            ProjectEventKind::ProjectAssociated => "project_associated",
971            ProjectEventKind::ProjectDissociated => "project_dissociated",
972        }
973    }
974}
975
976/// One association mutation, ready for storage to append to `## History`.
977#[derive(Debug, Clone, PartialEq, Eq)]
978pub struct ProjectEvent {
979    /// Which of the two mutations this is.
980    pub kind: ProjectEventKind,
981    /// The project id exactly as recorded. A `String`, not a [`ProjectId`],
982    /// because a dissociate may remove a stored id that is not a valid shape.
983    pub project_id: String,
984    /// The rendered detail line, from [`association_detail`].
985    pub detail: String,
986}
987
988/// The outcome of an association mutation: the resulting list plus the event.
989///
990/// The service produces the event and **does not write**; storage applies the
991/// list and appends the event, so the atomicity rule lives in one place. It never
992/// returns a whole `Issue`, which is what keeps this module model-free.
993#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct ProjectChange {
995    /// The issue whose associations changed.
996    pub issue: IssueKey,
997    /// The association list after the change, first-seen order preserved.
998    pub project_ids: Vec<String>,
999    /// The event to append, or `None` when the mutation was a no-op.
1000    ///
1001    /// `None` for associating an already-present project or dissociating an
1002    /// absent one: neither is a change, and recording one would put an entry in
1003    /// the audit trail for nothing.
1004    pub event: Option<ProjectEvent>,
1005}
1006
1007/// The per-issue outcome of a bulk association (contract §1).
1008///
1009/// One entry per selector, each carrying its own success or refusal, so one
1010/// rejection is observable beside the rest rather than aborting them (FR-002).
1011#[derive(Debug, Clone, PartialEq, Eq)]
1012pub struct BulkProjectOutcome {
1013    /// The selector this entry answers.
1014    pub selector: u32,
1015    /// The change or the refusal for that selector.
1016    pub outcome: Result<ProjectChange, ProjectError>,
1017}
1018
1019/// Find an issue in the snapshot by its number.
1020fn find_issue<N: ProjectNode>(issues: &[N], selector: u32) -> Result<&N, ProjectError> {
1021    issues
1022        .iter()
1023        .find(|issue| issue.number() == selector)
1024        .ok_or(ProjectError::UnknownIssue { selector })
1025}
1026
1027/// Produce the association change for a **resolved** project id.
1028fn associate_resolved<N: ProjectNode>(
1029    issues: &[N],
1030    selector: u32,
1031    id: &ProjectId,
1032) -> Result<ProjectChange, ProjectError> {
1033    let issue = find_issue(issues, selector)?;
1034    let current = issue.project_ids();
1035    let key = IssueKey::new(selector);
1036    if current.iter().any(|stored| stored == id.as_str()) {
1037        return Ok(ProjectChange {
1038            issue: key,
1039            project_ids: current.to_vec(),
1040            event: None,
1041        });
1042    }
1043    let mut next = current.to_vec();
1044    next.push(id.to_string());
1045    Ok(ProjectChange {
1046        issue: key,
1047        project_ids: next,
1048        event: Some(ProjectEvent {
1049            kind: ProjectEventKind::ProjectAssociated,
1050            project_id: id.to_string(),
1051            detail: association_detail(id.as_str(), true),
1052        }),
1053    })
1054}
1055
1056/// Associate an issue with a project, producing the change and its History event.
1057///
1058/// Idempotent: an already-present association changes nothing and yields **no**
1059/// event (contract §3). Refuses an unknown project or an unknown issue; a refusal
1060/// produces no change and no event.
1061pub fn associate<N: ProjectNode>(
1062    config: &Config,
1063    issues: &[N],
1064    issue_selector: u32,
1065    project_reference: &str,
1066) -> Result<ProjectChange, ProjectError> {
1067    let id = resolve_project_id(config, project_reference)?;
1068    associate_resolved(issues, issue_selector, &id)
1069}
1070
1071/// Remove an issue's association with a project.
1072///
1073/// Drops only the association: it never deletes or modifies the issue (C-004).
1074/// Dissociating an absent association is an idempotent no-op with no event. The
1075/// project is **not** required to exist — removing a stale reference is the
1076/// repair path C-005 promises — so the only refusal is an unknown issue.
1077///
1078/// `project_id` is compared against the stored list **verbatim**: unlike
1079/// [`associate`], a project *name* is not resolved here. That is deliberate, not
1080/// an oversight — the stored entries are ids, and a caller must resolve a name to
1081/// an id first (via [`resolve_project_id`]). A name therefore matches nothing and
1082/// is an inert no-op, which is what keeps the repair path working for an id that
1083/// is no longer a valid [`ProjectId`] shape or no longer in the registry.
1084pub fn dissociate<N: ProjectNode>(
1085    issues: &[N],
1086    issue_selector: u32,
1087    project_id: &str,
1088) -> Result<ProjectChange, ProjectError> {
1089    let issue = find_issue(issues, issue_selector)?;
1090    let current = issue.project_ids();
1091    let key = IssueKey::new(issue_selector);
1092    if !current.iter().any(|stored| stored == project_id) {
1093        return Ok(ProjectChange {
1094            issue: key,
1095            project_ids: current.to_vec(),
1096            event: None,
1097        });
1098    }
1099    let next: Vec<String> = current
1100        .iter()
1101        .filter(|stored| stored.as_str() != project_id)
1102        .cloned()
1103        .collect();
1104    Ok(ProjectChange {
1105        issue: key,
1106        project_ids: next,
1107        event: Some(ProjectEvent {
1108            kind: ProjectEventKind::ProjectDissociated,
1109            project_id: project_id.to_string(),
1110            detail: association_detail(project_id, false),
1111        }),
1112    })
1113}
1114
1115/// Associate each selector independently, reporting every outcome.
1116///
1117/// One rejection must not abort the rest (contract §3): the returned vector has
1118/// one entry per selector, in selector order, and an unknown project refuses each
1119/// entry rather than the whole call.
1120#[must_use]
1121pub fn bulk_associate<N: ProjectNode>(
1122    config: &Config,
1123    issues: &[N],
1124    project_reference: &str,
1125    issue_selectors: &[u32],
1126) -> Vec<BulkProjectOutcome> {
1127    let resolved = resolve_project_id(config, project_reference);
1128    issue_selectors
1129        .iter()
1130        .map(|&selector| {
1131            let outcome = match &resolved {
1132                Ok(id) => associate_resolved(issues, selector, id),
1133                Err(err) => Err(err.clone()),
1134            };
1135            BulkProjectOutcome { selector, outcome }
1136        })
1137        .collect()
1138}
1139
1140// ---------------------------------------------------------------------------
1141// T009 — missing-reference diagnosis
1142// ---------------------------------------------------------------------------
1143
1144/// Every association in the snapshot whose project is not in the registry.
1145///
1146/// **Never errors** — lint consumes the list directly — and performs one pass
1147/// over the issue set rather than one lookup per project. Each missing
1148/// association is reported once, ordered by `(issue number, project id)` so lint
1149/// output is stable (C-005, contract §4).
1150#[must_use]
1151pub fn validate_references<N: ProjectNode>(
1152    config: &Config,
1153    issues: &[N],
1154) -> Vec<MissingProjectReference> {
1155    ProjectIndex::build(config, issues).into_missing()
1156}
1157
1158// ---------------------------------------------------------------------------
1159// T015/T016 — project progress and health: the one computation (FR-003, C-006)
1160// ---------------------------------------------------------------------------
1161//
1162// Out-of-map rationale (WP04): `src/projects.rs` is the single owner of project
1163// computation, so project progress and health belong here. Putting the figures in
1164// a surface would create the second implementation C-006 prohibits — this module
1165// is where the one implementation must live, and the addition below is additive:
1166// it introduces new items and a second `impl ProjectIndex` block, and edits none
1167// of WP02's existing code.
1168
1169/// The one per-issue fact project health reads beyond [`ProjectNode`].
1170///
1171/// `ProjectNode` is WP02's minimal shape and deliberately does not grow a member
1172/// for a later work package: WP03 implements it for [`crate::model::Issue`] in
1173/// `src/model.rs`, which WP04 may not edit, so a required method added there would
1174/// not compile. This trait is the adapter instead — implemented for
1175/// [`crate::model::Issue`] in the WP04 surface (`src/commands/view.rs`) — which
1176/// keeps this module free of the issue model, exactly as its module contract
1177/// promises.
1178pub trait HealthNode: ProjectNode {
1179    /// The issue's assignee, `None` when unset. A blank value counts as unset;
1180    /// [`assigned_keys`] is where that rule lives.
1181    fn assignee(&self) -> Option<&str>;
1182}
1183
1184/// The members of a snapshot that carry an assignee.
1185///
1186/// The single home of the "unassigned" rule (data-model §5): an **absent** assignee
1187/// or one that is **blank after trimming** counts as unassigned. Returned as a set
1188/// of issue numbers and consumed by [`ProjectIndex::health`], the same shape as the
1189/// consumed overdue and blocked reads, so the *count* is computed in exactly one
1190/// place (C-006) while the rule itself also has exactly one home.
1191#[must_use]
1192pub fn assigned_keys<N: HealthNode>(issues: &[N]) -> HashSet<u32> {
1193    issues
1194        .iter()
1195        .filter(|issue| {
1196            issue
1197                .assignee()
1198                .is_some_and(|assignee| !assignee.trim().is_empty())
1199        })
1200        .map(ProjectNode::number)
1201        .collect()
1202}
1203
1204/// A project's completion progress (data-model §5, FR-003).
1205///
1206/// Mirrors the hierarchy rollup ([`crate::hierarchy::ParentRollup`]): the counts
1207/// spelled out for a listing, plus the typed [`Progress`] value so CLI, TUI and MCP
1208/// results can be compared **by value** (NFR-003). [`Progress`] is the planner's,
1209/// reused rather than reinvented (research R3).
1210#[derive(Debug, Clone, PartialEq, Eq)]
1211pub struct ProjectProgress {
1212    /// Members in the board's **configured** completion column.
1213    pub done: usize,
1214    /// Members.
1215    pub total: usize,
1216    /// The typed completion value, reused from the planner.
1217    pub progress: Progress,
1218}
1219
1220/// A project's health counters (data-model §5, FR-003).
1221///
1222/// Every figure is **consumed** from an existing derived read, never recomputed
1223/// here: `overdue` from the planning overdue read and `blocked` from the
1224/// relationship graph's `blocked_state` (research R4).
1225#[derive(Debug, Clone, PartialEq, Eq)]
1226pub struct ProjectHealth {
1227    /// Members past their due date and not done.
1228    pub overdue: usize,
1229    /// Members with an open blocker.
1230    pub blocked: usize,
1231    /// Members with no assignee (absent or blank after trimming).
1232    pub unassigned: usize,
1233    /// The project's members' associations that no longer resolve (C-005).
1234    pub missing_references: Vec<MissingProjectReference>,
1235}
1236
1237/// The typed value the `health` operation returns (contract §1).
1238///
1239/// One value, carrying the project's identity, its progress, and its health, so a
1240/// CLI, TUI or MCP surface renders it rather than re-deriving any figure (C-006).
1241#[derive(Debug, Clone, PartialEq, Eq)]
1242pub struct ProjectHealthReport {
1243    /// The project the figures describe.
1244    pub project_id: ProjectId,
1245    /// The project's display label, so a surface needs no second lookup.
1246    pub name: String,
1247    /// Completion figures.
1248    pub progress: ProjectProgress,
1249    /// The health counters.
1250    pub health: ProjectHealth,
1251}
1252
1253impl ProjectIndex {
1254    /// Progress and health for one project (contract §1, FR-003).
1255    ///
1256    /// Reads the members from the index's bucket, so a project's figures are a
1257    /// bucket lookup rather than a scan of the issue set (contract §4).
1258    ///
1259    /// `assigned`, `overdue`, and `blocked` are the **consumed** derived facts,
1260    /// keyed by issue number and each produced by its owner: [`assigned_keys`], the
1261    /// planning overdue read, and the relationship graph's `blocked_state`. Project
1262    /// health counts them and never recomputes them. Completion resolves through the
1263    /// board's configured completion column via the service resolution, never a
1264    /// literal `"done"`.
1265    ///
1266    /// A **done** issue is never counted as overdue, matching the planner's own
1267    /// overdue rule: the passed set is normally already filtered, and the guard keeps
1268    /// the invariant true for any caller that passes a raw set.
1269    pub fn health<S: std::hash::BuildHasher>(
1270        &self,
1271        config: &Config,
1272        reference: &str,
1273        assigned: &HashSet<u32, S>,
1274        overdue: &HashSet<u32, S>,
1275        blocked: &HashSet<u32, S>,
1276    ) -> Result<ProjectHealthReport, ProjectError> {
1277        let project = self.resolve(reference)?;
1278        let (_, resolution) = crate::workflow::active_resolution(config);
1279        let members = self.bucket(&project.id).unwrap_or(&[]);
1280        let mut done = 0usize;
1281        let mut overdue_count = 0usize;
1282        let mut blocked_count = 0usize;
1283        let mut unassigned = 0usize;
1284        for key in members {
1285            let number = key.number();
1286            let complete = self
1287                .status_of(*key)
1288                .and_then(|status| resolution.resolve(status))
1289                .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1290            if complete {
1291                done += 1;
1292            }
1293            if !complete && overdue.contains(&number) {
1294                overdue_count += 1;
1295            }
1296            if blocked.contains(&number) {
1297                blocked_count += 1;
1298            }
1299            if !assigned.contains(&number) {
1300                unassigned += 1;
1301            }
1302        }
1303        let total = members.len();
1304        // The project's members' dangling associations, in the index's deterministic
1305        // order (`missing` is sorted by `(issue number, project id)`). An association
1306        // whose project does not resolve cannot belong to any bucket, so the
1307        // references reported here are the members' *other* broken references —
1308        // surfaced wherever health is shown (C-005).
1309        let member_keys: HashSet<IssueKey> = members.iter().copied().collect();
1310        let missing_references: Vec<MissingProjectReference> = self
1311            .missing
1312            .iter()
1313            .filter(|missing| member_keys.contains(&missing.issue))
1314            .cloned()
1315            .collect();
1316        Ok(ProjectHealthReport {
1317            project_id: project.id.clone(),
1318            name: project.name.clone(),
1319            progress: ProjectProgress {
1320                done,
1321                total,
1322                progress: Progress { done, total },
1323            },
1324            health: ProjectHealth {
1325                overdue: overdue_count,
1326                blocked: blocked_count,
1327                unassigned,
1328                missing_references,
1329            },
1330        })
1331    }
1332}
1333
1334/// Progress and health for a project, in one index build (contract §1).
1335///
1336/// The free form of [`ProjectIndex::health`], for a caller that needs exactly one
1337/// project. A caller that needs several builds one [`ProjectIndex`] and calls
1338/// [`ProjectIndex::health`] on it, so the snapshot is indexed once (contract §4).
1339pub fn health<N: HealthNode, S: std::hash::BuildHasher>(
1340    config: &Config,
1341    issues: &[N],
1342    reference: &str,
1343    assigned: &HashSet<u32, S>,
1344    overdue: &HashSet<u32, S>,
1345    blocked: &HashSet<u32, S>,
1346) -> Result<ProjectHealthReport, ProjectError> {
1347    ProjectIndex::build(config, issues).health(config, reference, assigned, overdue, blocked)
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352    #[allow(clippy::wildcard_imports)]
1353    use super::*;
1354
1355    /// An in-memory stand-in for the shape WP03 will expose on `Issue`.
1356    ///
1357    /// The inline suite is deliberately filesystem-free (T010 step 4): every
1358    /// test below builds a [`Config`] and a `Vec<Node>` by hand, with no `Store`
1359    /// and no `tempdir`.
1360    #[derive(Debug, Clone, PartialEq, Eq)]
1361    struct Node {
1362        number: u32,
1363        title: String,
1364        status: String,
1365        projects: Vec<String>,
1366        assignee: Option<String>,
1367    }
1368
1369    impl Node {
1370        fn new(number: u32, title: &str) -> Self {
1371            Self {
1372                number,
1373                title: title.to_string(),
1374                status: "todo".to_string(),
1375                projects: Vec::new(),
1376                assignee: None,
1377            }
1378        }
1379
1380        fn with(mut self, ids: &[&str]) -> Self {
1381            self.projects = ids.iter().map(|id| (*id).to_string()).collect();
1382            self
1383        }
1384
1385        fn in_column(mut self, status: &str) -> Self {
1386            self.status = status.to_string();
1387            self
1388        }
1389
1390        fn assigned_to(mut self, assignee: &str) -> Self {
1391            self.assignee = Some(assignee.to_string());
1392            self
1393        }
1394    }
1395
1396    impl ProjectNode for Node {
1397        fn number(&self) -> u32 {
1398            self.number
1399        }
1400        fn title(&self) -> &str {
1401            &self.title
1402        }
1403        fn status(&self) -> &str {
1404            &self.status
1405        }
1406        fn project_ids(&self) -> &[String] {
1407            &self.projects
1408        }
1409    }
1410
1411    impl HealthNode for Node {
1412        fn assignee(&self) -> Option<&str> {
1413            self.assignee.as_deref()
1414        }
1415    }
1416
1417    /// A registry holding `(id, name)` pairs in declaration order.
1418    fn registry(entries: &[(&str, &str)]) -> Config {
1419        let mut config = Config::default();
1420        for (id, name) in entries {
1421            config.projects.insert(
1422                (*id).to_string(),
1423                ProjectConfiguration {
1424                    name: (*name).to_string(),
1425                    ..ProjectConfiguration::default()
1426                },
1427            );
1428        }
1429        config
1430    }
1431
1432    fn ids(change: &ProjectChange) -> Vec<&str> {
1433        change.project_ids.iter().map(String::as_str).collect()
1434    }
1435
1436    /// A set of issue numbers, as the consumed health inputs are keyed.
1437    fn set(numbers: &[u32]) -> HashSet<u32> {
1438        numbers.iter().copied().collect()
1439    }
1440
1441    /// A config whose default board completes on a **custom** column, so a literal
1442    /// `"done"` comparison cannot pass the tests that use it.
1443    fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
1444        let mut config = Config::default();
1445        config.kanban.configurations.insert(
1446            "default".to_string(),
1447            crate::config::BoardConfiguration {
1448                columns: columns.iter().map(|c| (*c).to_string()).collect(),
1449                done_column: Some(done_column.to_string()),
1450                category_map: map
1451                    .iter()
1452                    .map(|(column, key)| ((*column).to_string(), (*key).to_string()))
1453                    .collect(),
1454                ..Default::default()
1455            },
1456        );
1457        config
1458    }
1459
1460    // --- T006: create / edit / delete / list / resolve -----------------------
1461
1462    #[test]
1463    fn create_project_allocates_a_stable_id() {
1464        let mut config = Config::default();
1465        let project = create_project(
1466            &mut config,
1467            &NewProject {
1468                name: "Platform hardening".into(),
1469                description: Some("reduce risk".into()),
1470                ..NewProject::default()
1471            },
1472        )
1473        .unwrap();
1474
1475        assert!(project.id.as_str().starts_with("p-"));
1476        assert_eq!(project.name, "Platform hardening");
1477        assert_eq!(project.member_count, 0);
1478        assert!(config.projects.contains_key(project.id.as_str()));
1479        // The id is the key, the name is stored inside the record.
1480        assert_eq!(
1481            config.projects[project.id.as_str()].name,
1482            "Platform hardening"
1483        );
1484    }
1485
1486    #[test]
1487    fn create_project_refuses_a_duplicate_name_case_folded() {
1488        let mut config = registry(&[("p-aaaa", "Platform")]);
1489        let err = create_project(
1490            &mut config,
1491            &NewProject {
1492                name: "platform".into(),
1493                ..NewProject::default()
1494            },
1495        )
1496        .unwrap_err();
1497        assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
1498        assert_eq!(config.projects.len(), 1, "a refusal writes nothing");
1499    }
1500
1501    #[test]
1502    fn create_project_refuses_inverted_dates() {
1503        let mut config = Config::default();
1504        let err = create_project(
1505            &mut config,
1506            &NewProject {
1507                name: "Bad dates".into(),
1508                start_date: Some("2026-12-01".into()),
1509                due_date: Some("2026-01-01".into()),
1510                ..NewProject::default()
1511            },
1512        )
1513        .unwrap_err();
1514        assert_eq!(err.kind(), ProjectDiagnosticKind::InvalidDates);
1515        assert!(config.projects.is_empty());
1516    }
1517
1518    #[test]
1519    fn rename_changes_the_label_only() {
1520        let mut config = registry(&[("p-3f9a2c", "Platform hardening")]);
1521        let issues = vec![Node::new(1, "One").with(&["p-3f9a2c"])];
1522
1523        let edited = edit_project(
1524            &mut config,
1525            "p-3f9a2c",
1526            &ProjectEdit {
1527                name: Patch::Set("Platform".into()),
1528                ..ProjectEdit::default()
1529            },
1530        )
1531        .unwrap();
1532
1533        // The key is untouched, so the association resolves exactly as before.
1534        assert!(config.projects.contains_key("p-3f9a2c"));
1535        assert_eq!(edited.id.as_str(), "p-3f9a2c");
1536        assert_eq!(edited.name, "Platform");
1537        let membership = membership(&config, &issues, "p-3f9a2c").unwrap();
1538        assert_eq!(membership.member_count, 1);
1539    }
1540
1541    #[test]
1542    fn edit_project_clears_an_optional_field() {
1543        let mut config = registry(&[("p-aaaa", "One")]);
1544        config.projects["p-aaaa"].lead = Some("Alice".into());
1545        let edited = edit_project(
1546            &mut config,
1547            "p-aaaa",
1548            &ProjectEdit {
1549                lead: Patch::Clear,
1550                ..ProjectEdit::default()
1551            },
1552        )
1553        .unwrap();
1554        assert_eq!(edited.lead, None);
1555        assert_eq!(config.projects["p-aaaa"].lead, None);
1556    }
1557
1558    #[test]
1559    fn edit_project_refuses_an_unknown_reference() {
1560        let mut config = registry(&[("p-aaaa", "One")]);
1561        let err = edit_project(
1562            &mut config,
1563            "does-not-exist",
1564            &ProjectEdit {
1565                name: Patch::Set("X".into()),
1566                ..ProjectEdit::default()
1567            },
1568        )
1569        .unwrap_err();
1570        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1571    }
1572
1573    #[test]
1574    fn delete_reports_affected_issues_and_keeps_them() {
1575        let mut config = registry(&[("p-aaaa", "One")]);
1576        let issues = vec![
1577            Node::new(2, "Two").with(&["p-aaaa"]),
1578            Node::new(7, "Seven").with(&["p-aaaa"]),
1579        ];
1580
1581        let impact = delete_project(&mut config, "One", &issues).unwrap();
1582
1583        assert_eq!(impact.id.as_str(), "p-aaaa");
1584        assert_eq!(impact.name, "One");
1585        assert_eq!(
1586            impact
1587                .affected
1588                .iter()
1589                .map(|m| m.issue.number())
1590                .collect::<Vec<_>>(),
1591            vec![2, 7],
1592            "affected issues are ordered by issue number"
1593        );
1594        // The registry entry is gone; the issues keep their references.
1595        assert!(config.projects.is_empty());
1596        assert_eq!(issues[0].projects, vec!["p-aaaa".to_string()]);
1597        // And the retained references are now diagnosable.
1598        assert_eq!(validate_references(&config, &issues).len(), 2);
1599    }
1600
1601    #[test]
1602    fn successive_create_draws_after_a_delete_are_distinct() {
1603        // Honest scope: this pins only that two successive `ProjectId::generate`
1604        // draws differ. It cannot observe *non-reuse of the deleted id* — a
1605        // fresh 2^64-space draw repeating one would fail here with probability
1606        // ~2^-64, and would fail anyway. Deleted-id non-reuse is WP01's
1607        // guarantee, carried by the id shape plus the stored-id redraw; the
1608        // rationale is recorded on `ProjectId::generate` and `allocate_project_id`.
1609        let mut config = Config::default();
1610        let first = create_project(
1611            &mut config,
1612            &NewProject {
1613                name: "One".into(),
1614                ..NewProject::default()
1615            },
1616        )
1617        .unwrap();
1618        delete_project(&mut config, first.id.as_str(), &[] as &[Node]).unwrap();
1619        let second = create_project(
1620            &mut config,
1621            &NewProject {
1622                name: "One".into(),
1623                ..NewProject::default()
1624            },
1625        )
1626        .unwrap();
1627        assert_ne!(first.id, second.id, "two draws must be distinct");
1628    }
1629
1630    #[test]
1631    fn resolve_by_id_and_by_name_agree() {
1632        let config = registry(&[("p-aaaa", "Platform"), ("p-bbbb", "Docs")]);
1633        let issues = vec![Node::new(1, "One").with(&["p-bbbb"])];
1634        let by_id = resolve(&config, &issues, "p-bbbb").unwrap();
1635        let by_name = resolve(&config, &issues, "Docs").unwrap();
1636        assert_eq!(by_id, by_name);
1637        assert_eq!(by_id.member_count, 1);
1638    }
1639
1640    #[test]
1641    fn id_resolution_takes_precedence_over_a_name_lookalike() {
1642        let mut config = registry(&[("p-aaaa", "Platform"), ("Docs", "Docs refresh")]);
1643        // `Docs` is a valid id shape *and* another project's name.
1644        config.projects["Docs"].name = "Docs refresh".into();
1645        let resolved = resolve_project_id(&config, "Docs").unwrap();
1646        assert_eq!(resolved.as_str(), "Docs", "the id is matched first");
1647    }
1648
1649    #[test]
1650    fn unknown_id_and_unknown_name_are_refused() {
1651        let config = registry(&[("p-aaaa", "Platform")]);
1652        for reference in ["p-missing", "No such name"] {
1653            let err = resolve_project_id(&config, reference).unwrap_err();
1654            assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1655            assert!(err.message().contains(reference));
1656        }
1657    }
1658
1659    #[test]
1660    fn list_projects_is_declaration_ordered_with_member_counts() {
1661        let config = registry(&[("p-zeta", "Zeta"), ("p-alpha", "Alpha")]);
1662        let issues = vec![
1663            Node::new(3, "Three").with(&["p-alpha"]),
1664            Node::new(1, "One").with(&["p-zeta", "p-alpha"]),
1665        ];
1666        let projects = list_projects(&config, &issues);
1667        assert_eq!(
1668            projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
1669            ["Zeta", "Alpha"],
1670            "declaration order is the display order"
1671        );
1672        assert_eq!(projects[0].member_count, 1);
1673        assert_eq!(projects[1].member_count, 2);
1674    }
1675
1676    #[test]
1677    fn list_projects_never_errors_on_an_empty_registry() {
1678        let config = Config::default();
1679        assert_eq!(
1680            list_projects(&config, &[] as &[Node]),
1681            [] as [crate::projects::Project; 0]
1682        );
1683    }
1684
1685    #[test]
1686    fn project_display_shows_the_id_beside_the_name() {
1687        let id = ProjectId::new("p-3f9a2c").unwrap();
1688        assert_eq!(project_display(&id, "Platform"), "Platform (p-3f9a2c)");
1689    }
1690
1691    // --- T007: the derived index and membership ------------------------------
1692
1693    #[test]
1694    fn an_issue_may_belong_to_several_projects() {
1695        let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1696        let issues = vec![Node::new(1, "Shared").with(&["p-aaaa", "p-bbbb"])];
1697        assert_eq!(
1698            membership(&config, &issues, "p-aaaa").unwrap().member_count,
1699            1
1700        );
1701        assert_eq!(
1702            membership(&config, &issues, "p-bbbb").unwrap().member_count,
1703            1
1704        );
1705    }
1706
1707    #[test]
1708    fn an_unassociated_issue_is_in_no_bucket() {
1709        let config = registry(&[("p-aaaa", "One")]);
1710        let issues = vec![Node::new(1, "None"), Node::new(2, "One").with(&["p-aaaa"])];
1711        let index = ProjectIndex::build(&config, &issues);
1712        let id = ProjectId::new("p-aaaa").unwrap();
1713        assert_eq!(index.bucket(&id).unwrap(), [IssueKey::new(2)]);
1714    }
1715
1716    #[test]
1717    fn bucket_is_none_only_for_an_unknown_project() {
1718        // "A project with no members yet" and "not a project" must differ: a
1719        // caller asking for an empty project should not read it as a typo.
1720        let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1721        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1722        let index = ProjectIndex::build(&config, &issues);
1723
1724        let known_empty = ProjectId::new("p-bbbb").unwrap();
1725        assert_eq!(index.bucket(&known_empty), Some(&[][..]));
1726
1727        let unknown = ProjectId::new("p-missing").unwrap();
1728        assert_eq!(index.bucket(&unknown), None);
1729    }
1730
1731    #[test]
1732    fn membership_order_is_stable_across_repeated_builds() {
1733        let config = registry(&[("p-aaaa", "One")]);
1734        // Fed out of number order, to prove the index sorts rather than assumes.
1735        let issues = vec![
1736            Node::new(9, "Nine").with(&["p-aaaa"]),
1737            Node::new(2, "Two").with(&["p-aaaa"]),
1738            Node::new(5, "Five").with(&["p-aaaa"]),
1739        ];
1740        let first = membership(&config, &issues, "p-aaaa").unwrap();
1741        let second = membership(&config, &issues, "p-aaaa").unwrap();
1742        assert_eq!(first, second);
1743        assert_eq!(
1744            first
1745                .members
1746                .iter()
1747                .map(|m| m.issue.number())
1748                .collect::<Vec<_>>(),
1749            vec![2, 5, 9]
1750        );
1751        assert_eq!(first.members[0].display, "#2 Two");
1752    }
1753
1754    #[test]
1755    fn the_free_operations_agree_with_one_built_index() {
1756        // Observable form of "build once per read": the free contract operations
1757        // each build one index, and their results must equal the one index built
1758        // here. A disagreement is a second derivation; two reads of a pure
1759        // accessor proving equal would have proved nothing.
1760        let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1761        let issues = vec![
1762            Node::new(1, "One").with(&["p-aaaa", "p-bbbb"]),
1763            Node::new(2, "Two").with(&["p-aaaa"]),
1764        ];
1765        let index = ProjectIndex::build(&config, &issues);
1766
1767        assert_eq!(membership_index(&config, &issues), *index.buckets());
1768        assert_eq!(
1769            membership(&config, &issues, "p-aaaa").unwrap(),
1770            index.membership("p-aaaa").unwrap()
1771        );
1772        assert_eq!(
1773            validate_references(&config, &issues),
1774            index.missing().to_vec()
1775        );
1776        assert_eq!(
1777            list_projects(&config, &issues),
1778            index
1779                .projects()
1780                .into_iter()
1781                .cloned()
1782                .collect::<Vec<Project>>()
1783        );
1784    }
1785
1786    #[test]
1787    fn associate_leaves_the_registry_record_unchanged() {
1788        // Membership is derived, never stored (T007 step 3, review 2). The
1789        // structural equality alone is guaranteed by the shape (a record has no
1790        // member field), so the observable form is added: the *serialized*
1791        // registry — what would land in `tix.toml` — carries no member key, and
1792        // the change is the only place the members appear.
1793        let config = registry(&[("p-aaaa", "One")]);
1794        let before = config.projects["p-aaaa"].clone();
1795        let issues = vec![Node::new(1, "One")];
1796        let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1797
1798        assert_eq!(config.projects["p-aaaa"], before);
1799        assert_eq!(
1800            config.projects.len(),
1801            1,
1802            "a project record carries no member list"
1803        );
1804        assert_eq!(change.project_ids, vec!["p-aaaa".to_string()]);
1805
1806        let serialized = toml::to_string(&config).expect("the registry serializes");
1807        assert!(
1808            !serialized.contains("member"),
1809            "no member/members key may reach disk: {serialized}"
1810        );
1811    }
1812
1813    // --- T008: association mutations -----------------------------------------
1814
1815    #[test]
1816    fn associate_produces_the_list_and_one_event() {
1817        let config = registry(&[("p-aaaa", "One")]);
1818        let issues = vec![Node::new(1, "One")];
1819        let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1820        assert_eq!(ids(&change), ["p-aaaa"]);
1821        let event = change.event.expect("an accepted change is audited");
1822        assert_eq!(event.kind, ProjectEventKind::ProjectAssociated);
1823        assert_eq!(event.project_id, "p-aaaa");
1824        assert_eq!(event.detail, "project `p-aaaa` associated");
1825    }
1826
1827    #[test]
1828    fn re_association_is_idempotent_and_emits_no_event() {
1829        let config = registry(&[("p-aaaa", "One")]);
1830        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1831        let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1832        assert_eq!(ids(&change), ["p-aaaa"], "no duplicate entry");
1833        assert!(change.event.is_none(), "a no-op is not an audit entry");
1834    }
1835
1836    #[test]
1837    fn associating_an_unknown_project_or_issue_is_refused() {
1838        let config = registry(&[("p-aaaa", "One")]);
1839        let issues = vec![Node::new(1, "One")];
1840        let err = associate(&config, &issues, 1, "p-nope").unwrap_err();
1841        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1842        let err = associate(&config, &issues, 99, "p-aaaa").unwrap_err();
1843        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1844        assert!(err.message().contains("99"));
1845    }
1846
1847    #[test]
1848    fn dissociate_keeps_the_issue_intact() {
1849        let issues = vec![Node::new(1, "One").with(&["p-aaaa", "p-bbbb"])];
1850        let change = dissociate(&issues, 1, "p-aaaa").unwrap();
1851        assert_eq!(ids(&change), ["p-bbbb"], "only the one association drops");
1852        let event = change.event.unwrap();
1853        assert_eq!(event.kind, ProjectEventKind::ProjectDissociated);
1854        assert_eq!(event.detail, "project `p-aaaa` dissociated");
1855        // The node itself is untouched: the service returns a change, it does not
1856        // mutate or delete the issue (C-004).
1857        assert_eq!(issues[0].projects, vec!["p-aaaa", "p-bbbb"]);
1858    }
1859
1860    #[test]
1861    fn dissociating_an_absent_association_is_a_noop() {
1862        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1863        let change = dissociate(&issues, 1, "p-bbbb").unwrap();
1864        assert_eq!(ids(&change), ["p-aaaa"]);
1865        assert!(change.event.is_none());
1866    }
1867
1868    #[test]
1869    fn dissociate_does_not_resolve_a_project_name() {
1870        // Documented behaviour: `dissociate` compares the stored list verbatim, so
1871        // a name is an inert no-op. Callers resolve a name to an id first; this
1872        // keeps a stale/malformed id removable without a registry lookup.
1873        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1874        let change = dissociate(&issues, 1, "One").unwrap();
1875        assert_eq!(ids(&change), ["p-aaaa"], "a name must not match the id");
1876        assert!(change.event.is_none(), "and it is not an audit entry");
1877    }
1878
1879    #[test]
1880    fn dissociate_removes_a_stale_reference_without_a_project() {
1881        // The repair path C-005 promises: no registry lookup, only the issue.
1882        let issues = vec![Node::new(1, "One").with(&["p-deleted"])];
1883        let change = dissociate(&issues, 1, "p-deleted").unwrap();
1884        assert_eq!(change.project_ids, [] as [std::string::String; 0]);
1885        assert!(change.event.is_some());
1886    }
1887
1888    #[test]
1889    fn dissociate_refuses_an_unknown_issue() {
1890        let issues = vec![Node::new(1, "One")];
1891        let err = dissociate(&issues, 42, "p-aaaa").unwrap_err();
1892        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1893    }
1894
1895    #[test]
1896    fn bulk_associate_reports_each_issue_independently() {
1897        let config = registry(&[("p-aaaa", "One")]);
1898        let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1899        let outcomes = bulk_associate(&config, &issues, "p-aaaa", &[1, 99, 2]);
1900
1901        assert_eq!(outcomes.len(), 3, "one entry per selector");
1902        assert_eq!(outcomes[0].selector, 1);
1903        assert!(outcomes[0].outcome.is_ok());
1904        assert_eq!(
1905            outcomes[1].outcome.as_ref().unwrap_err().kind(),
1906            ProjectDiagnosticKind::UnknownIssue,
1907            "the bad selector is reported, not fatal"
1908        );
1909        assert!(
1910            outcomes[2].outcome.is_ok(),
1911            "the good selectors after a bad one still process"
1912        );
1913        // Exactly one event per accepted change, none per rejected one.
1914        let events: usize = outcomes
1915            .iter()
1916            .filter_map(|o| o.outcome.as_ref().ok())
1917            .filter(|change| change.event.is_some())
1918            .count();
1919        assert_eq!(events, 2);
1920    }
1921
1922    #[test]
1923    fn bulk_associate_reports_an_unknown_project_per_selector() {
1924        let config = registry(&[("p-aaaa", "One")]);
1925        let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1926        let outcomes = bulk_associate(&config, &issues, "p-nope", &[1, 2]);
1927        assert!(outcomes.iter().all(|o| {
1928            o.outcome.as_ref().unwrap_err().kind() == ProjectDiagnosticKind::UnknownProject
1929        }));
1930    }
1931
1932    // --- T009: missing-reference diagnosis -----------------------------------
1933
1934    #[test]
1935    fn a_missing_reference_names_the_issue_and_the_id() {
1936        let config = registry(&[("p-aaaa", "One")]);
1937        let issues = vec![Node::new(4, "Four").with(&["p-aaaa", "p-gone"])];
1938        let missing = validate_references(&config, &issues);
1939        assert_eq!(missing.len(), 1);
1940        assert_eq!(missing[0].issue.number(), 4);
1941        assert_eq!(missing[0].issue_display, "#4 Four");
1942        assert_eq!(missing[0].project_id, "p-gone");
1943        assert!(missing[0].reason.contains("#4"));
1944        assert!(missing[0].reason.contains("p-gone"));
1945    }
1946
1947    #[test]
1948    fn a_resolvable_set_reports_no_missing_references() {
1949        let config = registry(&[("p-aaaa", "One")]);
1950        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1951        assert_eq!(
1952            validate_references(&config, &issues),
1953            [] as [crate::projects::MissingProjectReference; 0]
1954        );
1955    }
1956
1957    #[test]
1958    fn a_duplicated_missing_id_is_reported_once() {
1959        let config = Config::default();
1960        let issues = vec![Node::new(1, "One").with(&["p-gone", "p-gone"])];
1961        assert_eq!(validate_references(&config, &issues).len(), 1);
1962    }
1963
1964    #[test]
1965    fn a_malformed_id_is_a_missing_reference_not_a_panic() {
1966        let config = Config::default();
1967        let issues = vec![Node::new(1, "One").with(&["not a valid id"])];
1968        let missing = validate_references(&config, &issues);
1969        assert_eq!(missing.len(), 1);
1970        assert_eq!(missing[0].project_id, "not a valid id");
1971    }
1972
1973    #[test]
1974    fn validate_references_is_deterministic() {
1975        let config = registry(&[("p-aaaa", "One")]);
1976        let issues = vec![
1977            Node::new(3, "Three").with(&["p-z", "p-a"]),
1978            Node::new(1, "One").with(&["p-m"]),
1979        ];
1980        let first = validate_references(&config, &issues);
1981        let second = validate_references(&config, &issues);
1982        assert_eq!(first, second);
1983        assert_eq!(
1984            first
1985                .iter()
1986                .map(|m| (m.issue.number(), m.project_id.as_str()))
1987                .collect::<Vec<_>>(),
1988            vec![(1, "p-m"), (3, "p-a"), (3, "p-z")],
1989            "ordered by (issue number, id)"
1990        );
1991    }
1992
1993    #[test]
1994    fn a_missing_reference_is_accounted_for_in_the_index() {
1995        // It must not silently vanish from the index's accounting (T007 step 5).
1996        let config = Config::default();
1997        let issues = vec![Node::new(1, "One").with(&["p-gone"])];
1998        let index = ProjectIndex::build(&config, &issues);
1999        assert!(index.buckets().is_empty());
2000        assert_eq!(index.missing().len(), 1);
2001    }
2002
2003    // --- T010: operations on in-memory values --------------------------------
2004
2005    #[test]
2006    fn the_operations_run_on_in_memory_values() {
2007        // This pins that the operations run on values built here, not that the
2008        // module is filesystem-free — a test cannot observe the absence of an
2009        // `std::fs` call. The filesystem-free property is a documented,
2010        // inspection-checked claim on the module (no `Store`, no `std::fs`, no
2011        // `tempfile`), which is what keeps this suite free of a `tempdir`.
2012        let mut config = Config::default();
2013        let created = create_project(
2014            &mut config,
2015            &NewProject {
2016                name: "In memory".into(),
2017                ..NewProject::default()
2018            },
2019        )
2020        .unwrap();
2021        let issues = vec![Node::new(1, "One")];
2022        let change = associate(&config, &issues, 1, created.id.as_str()).unwrap();
2023        assert_eq!(change.project_ids, vec![created.id.to_string()]);
2024        assert_eq!(list_projects(&config, &issues).len(), 1);
2025        assert_eq!(
2026            validate_references(&config, &issues),
2027            [] as [crate::projects::MissingProjectReference; 0]
2028        );
2029    }
2030
2031    // --- T015/T016: the one progress and health computation ------------------
2032
2033    #[test]
2034    fn t015_a_project_with_four_members_and_one_complete_reports_one_of_four() {
2035        let config = registry(&[("p-aaaa", "One")]);
2036        let issues = vec![
2037            Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2038            Node::new(2, "Two").with(&["p-aaaa"]),
2039            Node::new(3, "Three").with(&["p-aaaa"]),
2040            Node::new(4, "Four").with(&["p-aaaa"]),
2041        ];
2042        let assigned = assigned_keys(&issues);
2043        let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2044        assert_eq!(report.progress.done, 1);
2045        assert_eq!(report.progress.total, 4);
2046        assert_eq!(
2047            report.progress.progress,
2048            Progress { done: 1, total: 4 },
2049            "the typed planner value is reused, not restated"
2050        );
2051        assert_eq!(report.project_id.as_str(), "p-aaaa");
2052        assert_eq!(report.name, "One");
2053    }
2054
2055    #[test]
2056    fn t015_an_empty_project_reports_zeros() {
2057        let config = registry(&[("p-aaaa", "One")]);
2058        let report =
2059            health::<Node, _>(&config, &[], "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2060        assert_eq!(report.progress.progress, Progress { done: 0, total: 0 });
2061        assert_eq!(report.health.overdue, 0);
2062        assert_eq!(report.health.blocked, 0);
2063        assert_eq!(report.health.unassigned, 0);
2064        assert_eq!(
2065            report.health.missing_references,
2066            [] as [crate::projects::MissingProjectReference; 0]
2067        );
2068    }
2069
2070    #[test]
2071    fn t015_completion_resolves_from_the_configured_column_not_a_literal() {
2072        // The board completes on `shipped` and has no `done` column at all, so a
2073        // literal `"done"` comparison cannot pass this: `done` is not even a
2074        // configured column, and an issue there resolves to no category.
2075        let mut config = custom_completion(
2076            &["triage", "wip", "shipped"],
2077            "shipped",
2078            &[
2079                ("triage", "unstarted"),
2080                ("wip", "active"),
2081                ("shipped", "completed"),
2082            ],
2083        );
2084        config.projects.insert(
2085            "p-aaaa".to_string(),
2086            ProjectConfiguration {
2087                name: "One".to_string(),
2088                ..ProjectConfiguration::default()
2089            },
2090        );
2091        let issues = vec![
2092            Node::new(1, "Shipped")
2093                .in_column("shipped")
2094                .with(&["p-aaaa"]),
2095            Node::new(2, "Literal done")
2096                .in_column("done")
2097                .with(&["p-aaaa"]),
2098        ];
2099        let report = health(&config, &issues, "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2100        assert_eq!(
2101            report.progress.done, 1,
2102            "only the configured completion column counts"
2103        );
2104        assert_eq!(report.progress.total, 2);
2105    }
2106
2107    #[test]
2108    fn t016_health_counts_the_known_fixture_exactly() {
2109        let config = registry(&[("p-aaaa", "One")]);
2110        let issues = vec![
2111            Node::new(1, "Complete")
2112                .in_column("done")
2113                .assigned_to("alice")
2114                .with(&["p-aaaa"]),
2115            Node::new(2, "Overdue")
2116                .assigned_to("bob")
2117                .with(&["p-aaaa", "p-gone"]),
2118            Node::new(3, "Blocked")
2119                .assigned_to("carol")
2120                .with(&["p-aaaa"]),
2121            Node::new(4, "Unassigned").with(&["p-aaaa"]),
2122        ];
2123        let assigned = assigned_keys(&issues);
2124        // #1 is complete **and** in the overdue set: a done issue is not overdue.
2125        let report = health(
2126            &config,
2127            &issues,
2128            "p-aaaa",
2129            &assigned,
2130            &set(&[1, 2]),
2131            &set(&[3]),
2132        )
2133        .unwrap();
2134        assert_eq!(report.progress.done, 1);
2135        assert_eq!(report.progress.total, 4);
2136        assert_eq!(report.health.overdue, 1, "a done issue is not overdue");
2137        assert_eq!(report.health.blocked, 1);
2138        assert_eq!(report.health.unassigned, 1);
2139        assert_eq!(report.health.missing_references.len(), 1);
2140        let missing = &report.health.missing_references[0];
2141        assert_eq!(missing.issue.number(), 2);
2142        assert_eq!(missing.project_id, "p-gone");
2143        assert_ne!(missing.reason, "");
2144    }
2145
2146    #[test]
2147    fn t016_a_done_member_is_never_overdue() {
2148        let config = registry(&[("p-aaaa", "One")]);
2149        let issues = vec![
2150            Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2151            Node::new(2, "Open").with(&["p-aaaa"]),
2152        ];
2153        let report = health(
2154            &config,
2155            &issues,
2156            "p-aaaa",
2157            &set(&[1, 2]),
2158            &set(&[1, 2]),
2159            &set(&[]),
2160        )
2161        .unwrap();
2162        assert_eq!(
2163            report.health.overdue, 1,
2164            "only the open overdue member counts"
2165        );
2166    }
2167
2168    #[test]
2169    fn t016_a_blank_assignee_counts_as_unassigned() {
2170        let config = registry(&[("p-aaaa", "One")]);
2171        let issues = vec![
2172            Node::new(1, "Absent").with(&["p-aaaa"]),
2173            Node::new(2, "Blank").assigned_to("   ").with(&["p-aaaa"]),
2174            Node::new(3, "Assigned")
2175                .assigned_to("alice")
2176                .with(&["p-aaaa"]),
2177        ];
2178        let assigned = assigned_keys(&issues);
2179        assert_eq!(assigned, set(&[3]), "absent and blank are both unassigned");
2180        let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2181        assert_eq!(report.health.unassigned, 2);
2182    }
2183
2184    #[test]
2185    fn t016_a_resolvable_member_set_reports_no_missing_references() {
2186        let config = registry(&[("p-aaaa", "One")]);
2187        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
2188        let report = health(&config, &issues, "p-aaaa", &set(&[1]), &set(&[]), &set(&[])).unwrap();
2189        assert_eq!(
2190            report.health.missing_references,
2191            [] as [crate::projects::MissingProjectReference; 0]
2192        );
2193    }
2194
2195    #[test]
2196    fn t016_health_refuses_an_unknown_project() {
2197        let config = registry(&[("p-aaaa", "One")]);
2198        let err =
2199            health::<Node, _>(&config, &[], "p-nope", &set(&[]), &set(&[]), &set(&[])).unwrap_err();
2200        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
2201    }
2202
2203    // --- T019: determinism and the single-implementation guard ---------------
2204
2205    #[test]
2206    fn t019_c006_the_health_read_is_deterministic_and_correct() {
2207        // Non-vacuous by construction: the fixture's figures are spelled out, so a
2208        // stub returning a constant fails the correctness half, and the repeated
2209        // reads are compared, so a non-deterministic read fails the other half.
2210        let config = registry(&[("p-aaaa", "One")]);
2211        let issues = vec![
2212            Node::new(1, "Complete")
2213                .in_column("done")
2214                .assigned_to("alice")
2215                .with(&["p-aaaa"]),
2216            Node::new(2, "Overdue").assigned_to("bob").with(&["p-aaaa"]),
2217            Node::new(3, "Blocked")
2218                .assigned_to("carol")
2219                .with(&["p-aaaa"]),
2220            Node::new(4, "Unassigned").with(&["p-aaaa"]),
2221        ];
2222        let assigned = assigned_keys(&issues);
2223        let first = health(
2224            &config,
2225            &issues,
2226            "p-aaaa",
2227            &assigned,
2228            &set(&[2]),
2229            &set(&[3]),
2230        )
2231        .unwrap();
2232        assert_eq!(
2233            first.progress,
2234            ProjectProgress {
2235                done: 1,
2236                total: 4,
2237                progress: Progress { done: 1, total: 4 },
2238            }
2239        );
2240        assert_eq!(first.health.overdue, 1);
2241        assert_eq!(first.health.blocked, 1);
2242        assert_eq!(first.health.unassigned, 1);
2243        for _ in 0..3 {
2244            let again = health(
2245                &config,
2246                &issues,
2247                "p-aaaa",
2248                &assigned,
2249                &set(&[2]),
2250                &set(&[3]),
2251            )
2252            .unwrap();
2253            assert_eq!(again, first, "repeated reads of one snapshot are identical");
2254        }
2255    }
2256
2257    #[test]
2258    fn t019_c006_a_noop_association_change_leaves_the_figures_unchanged() {
2259        // A no-op mutation must not move a figure. The returned list is **applied**
2260        // back into the snapshot before the second read — not merely re-read from
2261        // the original slice — so the assertion can actually fail: it observes the
2262        // round trip through `dissociate`'s output, not a second read of the same
2263        // input. (Re-reading the unchanged input would hold for any deterministic
2264        // implementation and would prove nothing.)
2265        let config = registry(&[("p-aaaa", "One")]);
2266        let issues = vec![
2267            Node::new(1, "Complete").in_column("done").with(&["p-aaaa"]),
2268            Node::new(2, "Open").with(&["p-aaaa"]),
2269        ];
2270        let assigned = assigned_keys(&issues);
2271        let before = health(&config, &issues, "p-aaaa", &assigned, &set(&[2]), &set(&[])).unwrap();
2272
2273        let change = dissociate(&issues, 1, "p-bbbb").unwrap();
2274        assert_eq!(ids(&change), ["p-aaaa"], "the list is unchanged");
2275        assert!(change.event.is_none(), "no event is emitted");
2276
2277        let mut applied = issues.clone();
2278        applied[0].projects = change.project_ids.clone();
2279        let assigned_after = assigned_keys(&applied);
2280        let after = health(
2281            &config,
2282            &applied,
2283            "p-aaaa",
2284            &assigned_after,
2285            &set(&[2]),
2286            &set(&[]),
2287        )
2288        .unwrap();
2289        assert_eq!(
2290            before, after,
2291            "applying the no-op leaves the figures unchanged"
2292        );
2293    }
2294}