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    use super::*;
1353
1354    /// An in-memory stand-in for the shape WP03 will expose on `Issue`.
1355    ///
1356    /// The inline suite is deliberately filesystem-free (T010 step 4): every
1357    /// test below builds a [`Config`] and a `Vec<Node>` by hand, with no `Store`
1358    /// and no `tempdir`.
1359    #[derive(Debug, Clone, PartialEq, Eq)]
1360    struct Node {
1361        number: u32,
1362        title: String,
1363        status: String,
1364        projects: Vec<String>,
1365        assignee: Option<String>,
1366    }
1367
1368    impl Node {
1369        fn new(number: u32, title: &str) -> Self {
1370            Self {
1371                number,
1372                title: title.to_string(),
1373                status: "todo".to_string(),
1374                projects: Vec::new(),
1375                assignee: None,
1376            }
1377        }
1378
1379        fn with(mut self, ids: &[&str]) -> Self {
1380            self.projects = ids.iter().map(|id| (*id).to_string()).collect();
1381            self
1382        }
1383
1384        fn in_column(mut self, status: &str) -> Self {
1385            self.status = status.to_string();
1386            self
1387        }
1388
1389        fn assigned_to(mut self, assignee: &str) -> Self {
1390            self.assignee = Some(assignee.to_string());
1391            self
1392        }
1393    }
1394
1395    impl ProjectNode for Node {
1396        fn number(&self) -> u32 {
1397            self.number
1398        }
1399        fn title(&self) -> &str {
1400            &self.title
1401        }
1402        fn status(&self) -> &str {
1403            &self.status
1404        }
1405        fn project_ids(&self) -> &[String] {
1406            &self.projects
1407        }
1408    }
1409
1410    impl HealthNode for Node {
1411        fn assignee(&self) -> Option<&str> {
1412            self.assignee.as_deref()
1413        }
1414    }
1415
1416    /// A registry holding `(id, name)` pairs in declaration order.
1417    fn registry(entries: &[(&str, &str)]) -> Config {
1418        let mut config = Config::default();
1419        for (id, name) in entries {
1420            config.projects.insert(
1421                (*id).to_string(),
1422                ProjectConfiguration {
1423                    name: (*name).to_string(),
1424                    ..ProjectConfiguration::default()
1425                },
1426            );
1427        }
1428        config
1429    }
1430
1431    fn ids(change: &ProjectChange) -> Vec<&str> {
1432        change.project_ids.iter().map(String::as_str).collect()
1433    }
1434
1435    /// A set of issue numbers, as the consumed health inputs are keyed.
1436    fn set(numbers: &[u32]) -> HashSet<u32> {
1437        numbers.iter().copied().collect()
1438    }
1439
1440    /// A config whose default board completes on a **custom** column, so a literal
1441    /// `"done"` comparison cannot pass the tests that use it.
1442    fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
1443        let mut config = Config::default();
1444        config.kanban.configurations.insert(
1445            "default".to_string(),
1446            crate::config::BoardConfiguration {
1447                columns: columns.iter().map(|c| (*c).to_string()).collect(),
1448                done_column: Some(done_column.to_string()),
1449                category_map: map
1450                    .iter()
1451                    .map(|(column, key)| ((*column).to_string(), (*key).to_string()))
1452                    .collect(),
1453                ..Default::default()
1454            },
1455        );
1456        config
1457    }
1458
1459    // --- T006: create / edit / delete / list / resolve -----------------------
1460
1461    #[test]
1462    fn create_project_allocates_a_stable_id() {
1463        let mut config = Config::default();
1464        let project = create_project(
1465            &mut config,
1466            &NewProject {
1467                name: "Platform hardening".into(),
1468                description: Some("reduce risk".into()),
1469                ..NewProject::default()
1470            },
1471        )
1472        .unwrap();
1473
1474        assert!(project.id.as_str().starts_with("p-"));
1475        assert_eq!(project.name, "Platform hardening");
1476        assert_eq!(project.member_count, 0);
1477        assert!(config.projects.contains_key(project.id.as_str()));
1478        // The id is the key, the name is stored inside the record.
1479        assert_eq!(
1480            config.projects[project.id.as_str()].name,
1481            "Platform hardening"
1482        );
1483    }
1484
1485    #[test]
1486    fn create_project_refuses_a_duplicate_name_case_folded() {
1487        let mut config = registry(&[("p-aaaa", "Platform")]);
1488        let err = create_project(
1489            &mut config,
1490            &NewProject {
1491                name: "platform".into(),
1492                ..NewProject::default()
1493            },
1494        )
1495        .unwrap_err();
1496        assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
1497        assert_eq!(config.projects.len(), 1, "a refusal writes nothing");
1498    }
1499
1500    #[test]
1501    fn create_project_refuses_inverted_dates() {
1502        let mut config = Config::default();
1503        let err = create_project(
1504            &mut config,
1505            &NewProject {
1506                name: "Bad dates".into(),
1507                start_date: Some("2026-12-01".into()),
1508                due_date: Some("2026-01-01".into()),
1509                ..NewProject::default()
1510            },
1511        )
1512        .unwrap_err();
1513        assert_eq!(err.kind(), ProjectDiagnosticKind::InvalidDates);
1514        assert!(config.projects.is_empty());
1515    }
1516
1517    #[test]
1518    fn rename_changes_the_label_only() {
1519        let mut config = registry(&[("p-3f9a2c", "Platform hardening")]);
1520        let issues = vec![Node::new(1, "One").with(&["p-3f9a2c"])];
1521
1522        let edited = edit_project(
1523            &mut config,
1524            "p-3f9a2c",
1525            &ProjectEdit {
1526                name: Patch::Set("Platform".into()),
1527                ..ProjectEdit::default()
1528            },
1529        )
1530        .unwrap();
1531
1532        // The key is untouched, so the association resolves exactly as before.
1533        assert!(config.projects.contains_key("p-3f9a2c"));
1534        assert_eq!(edited.id.as_str(), "p-3f9a2c");
1535        assert_eq!(edited.name, "Platform");
1536        let membership = membership(&config, &issues, "p-3f9a2c").unwrap();
1537        assert_eq!(membership.member_count, 1);
1538    }
1539
1540    #[test]
1541    fn edit_project_clears_an_optional_field() {
1542        let mut config = registry(&[("p-aaaa", "One")]);
1543        config.projects["p-aaaa"].lead = Some("Alice".into());
1544        let edited = edit_project(
1545            &mut config,
1546            "p-aaaa",
1547            &ProjectEdit {
1548                lead: Patch::Clear,
1549                ..ProjectEdit::default()
1550            },
1551        )
1552        .unwrap();
1553        assert_eq!(edited.lead, None);
1554        assert_eq!(config.projects["p-aaaa"].lead, None);
1555    }
1556
1557    #[test]
1558    fn edit_project_refuses_an_unknown_reference() {
1559        let mut config = registry(&[("p-aaaa", "One")]);
1560        let err = edit_project(
1561            &mut config,
1562            "does-not-exist",
1563            &ProjectEdit {
1564                name: Patch::Set("X".into()),
1565                ..ProjectEdit::default()
1566            },
1567        )
1568        .unwrap_err();
1569        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1570    }
1571
1572    #[test]
1573    fn delete_reports_affected_issues_and_keeps_them() {
1574        let mut config = registry(&[("p-aaaa", "One")]);
1575        let issues = vec![
1576            Node::new(2, "Two").with(&["p-aaaa"]),
1577            Node::new(7, "Seven").with(&["p-aaaa"]),
1578        ];
1579
1580        let impact = delete_project(&mut config, "One", &issues).unwrap();
1581
1582        assert_eq!(impact.id.as_str(), "p-aaaa");
1583        assert_eq!(impact.name, "One");
1584        assert_eq!(
1585            impact
1586                .affected
1587                .iter()
1588                .map(|m| m.issue.number())
1589                .collect::<Vec<_>>(),
1590            vec![2, 7],
1591            "affected issues are ordered by issue number"
1592        );
1593        // The registry entry is gone; the issues keep their references.
1594        assert!(config.projects.is_empty());
1595        assert_eq!(issues[0].projects, vec!["p-aaaa".to_string()]);
1596        // And the retained references are now diagnosable.
1597        assert_eq!(validate_references(&config, &issues).len(), 2);
1598    }
1599
1600    #[test]
1601    fn successive_create_draws_after_a_delete_are_distinct() {
1602        // Honest scope: this pins only that two successive `ProjectId::generate`
1603        // draws differ. It cannot observe *non-reuse of the deleted id* — a
1604        // fresh 2^64-space draw repeating one would fail here with probability
1605        // ~2^-64, and would fail anyway. Deleted-id non-reuse is WP01's
1606        // guarantee, carried by the id shape plus the stored-id redraw; the
1607        // rationale is recorded on `ProjectId::generate` and `allocate_project_id`.
1608        let mut config = Config::default();
1609        let first = create_project(
1610            &mut config,
1611            &NewProject {
1612                name: "One".into(),
1613                ..NewProject::default()
1614            },
1615        )
1616        .unwrap();
1617        delete_project(&mut config, first.id.as_str(), &[] as &[Node]).unwrap();
1618        let second = create_project(
1619            &mut config,
1620            &NewProject {
1621                name: "One".into(),
1622                ..NewProject::default()
1623            },
1624        )
1625        .unwrap();
1626        assert_ne!(first.id, second.id, "two draws must be distinct");
1627    }
1628
1629    #[test]
1630    fn resolve_by_id_and_by_name_agree() {
1631        let config = registry(&[("p-aaaa", "Platform"), ("p-bbbb", "Docs")]);
1632        let issues = vec![Node::new(1, "One").with(&["p-bbbb"])];
1633        let by_id = resolve(&config, &issues, "p-bbbb").unwrap();
1634        let by_name = resolve(&config, &issues, "Docs").unwrap();
1635        assert_eq!(by_id, by_name);
1636        assert_eq!(by_id.member_count, 1);
1637    }
1638
1639    #[test]
1640    fn id_resolution_takes_precedence_over_a_name_lookalike() {
1641        let mut config = registry(&[("p-aaaa", "Platform"), ("Docs", "Docs refresh")]);
1642        // `Docs` is a valid id shape *and* another project's name.
1643        config.projects["Docs"].name = "Docs refresh".into();
1644        let resolved = resolve_project_id(&config, "Docs").unwrap();
1645        assert_eq!(resolved.as_str(), "Docs", "the id is matched first");
1646    }
1647
1648    #[test]
1649    fn unknown_id_and_unknown_name_are_refused() {
1650        let config = registry(&[("p-aaaa", "Platform")]);
1651        for reference in ["p-missing", "No such name"] {
1652            let err = resolve_project_id(&config, reference).unwrap_err();
1653            assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1654            assert!(err.message().contains(reference));
1655        }
1656    }
1657
1658    #[test]
1659    fn list_projects_is_declaration_ordered_with_member_counts() {
1660        let config = registry(&[("p-zeta", "Zeta"), ("p-alpha", "Alpha")]);
1661        let issues = vec![
1662            Node::new(3, "Three").with(&["p-alpha"]),
1663            Node::new(1, "One").with(&["p-zeta", "p-alpha"]),
1664        ];
1665        let projects = list_projects(&config, &issues);
1666        assert_eq!(
1667            projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
1668            ["Zeta", "Alpha"],
1669            "declaration order is the display order"
1670        );
1671        assert_eq!(projects[0].member_count, 1);
1672        assert_eq!(projects[1].member_count, 2);
1673    }
1674
1675    #[test]
1676    fn list_projects_never_errors_on_an_empty_registry() {
1677        let config = Config::default();
1678        assert_eq!(
1679            list_projects(&config, &[] as &[Node]),
1680            [] as [crate::projects::Project; 0]
1681        );
1682    }
1683
1684    #[test]
1685    fn project_display_shows_the_id_beside_the_name() {
1686        let id = ProjectId::new("p-3f9a2c").unwrap();
1687        assert_eq!(project_display(&id, "Platform"), "Platform (p-3f9a2c)");
1688    }
1689
1690    // --- T007: the derived index and membership ------------------------------
1691
1692    #[test]
1693    fn an_issue_may_belong_to_several_projects() {
1694        let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1695        let issues = vec![Node::new(1, "Shared").with(&["p-aaaa", "p-bbbb"])];
1696        assert_eq!(
1697            membership(&config, &issues, "p-aaaa").unwrap().member_count,
1698            1
1699        );
1700        assert_eq!(
1701            membership(&config, &issues, "p-bbbb").unwrap().member_count,
1702            1
1703        );
1704    }
1705
1706    #[test]
1707    fn an_unassociated_issue_is_in_no_bucket() {
1708        let config = registry(&[("p-aaaa", "One")]);
1709        let issues = vec![Node::new(1, "None"), Node::new(2, "One").with(&["p-aaaa"])];
1710        let index = ProjectIndex::build(&config, &issues);
1711        let id = ProjectId::new("p-aaaa").unwrap();
1712        assert_eq!(index.bucket(&id).unwrap(), [IssueKey::new(2)]);
1713    }
1714
1715    #[test]
1716    fn bucket_is_none_only_for_an_unknown_project() {
1717        // "A project with no members yet" and "not a project" must differ: a
1718        // caller asking for an empty project should not read it as a typo.
1719        let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1720        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1721        let index = ProjectIndex::build(&config, &issues);
1722
1723        let known_empty = ProjectId::new("p-bbbb").unwrap();
1724        assert_eq!(index.bucket(&known_empty), Some(&[][..]));
1725
1726        let unknown = ProjectId::new("p-missing").unwrap();
1727        assert_eq!(index.bucket(&unknown), None);
1728    }
1729
1730    #[test]
1731    fn membership_order_is_stable_across_repeated_builds() {
1732        let config = registry(&[("p-aaaa", "One")]);
1733        // Fed out of number order, to prove the index sorts rather than assumes.
1734        let issues = vec![
1735            Node::new(9, "Nine").with(&["p-aaaa"]),
1736            Node::new(2, "Two").with(&["p-aaaa"]),
1737            Node::new(5, "Five").with(&["p-aaaa"]),
1738        ];
1739        let first = membership(&config, &issues, "p-aaaa").unwrap();
1740        let second = membership(&config, &issues, "p-aaaa").unwrap();
1741        assert_eq!(first, second);
1742        assert_eq!(
1743            first
1744                .members
1745                .iter()
1746                .map(|m| m.issue.number())
1747                .collect::<Vec<_>>(),
1748            vec![2, 5, 9]
1749        );
1750        assert_eq!(first.members[0].display, "#2 Two");
1751    }
1752
1753    #[test]
1754    fn the_free_operations_agree_with_one_built_index() {
1755        // Observable form of "build once per read": the free contract operations
1756        // each build one index, and their results must equal the one index built
1757        // here. A disagreement is a second derivation; two reads of a pure
1758        // accessor proving equal would have proved nothing.
1759        let config = registry(&[("p-aaaa", "One"), ("p-bbbb", "Two")]);
1760        let issues = vec![
1761            Node::new(1, "One").with(&["p-aaaa", "p-bbbb"]),
1762            Node::new(2, "Two").with(&["p-aaaa"]),
1763        ];
1764        let index = ProjectIndex::build(&config, &issues);
1765
1766        assert_eq!(membership_index(&config, &issues), *index.buckets());
1767        assert_eq!(
1768            membership(&config, &issues, "p-aaaa").unwrap(),
1769            index.membership("p-aaaa").unwrap()
1770        );
1771        assert_eq!(
1772            validate_references(&config, &issues),
1773            index.missing().to_vec()
1774        );
1775        assert_eq!(
1776            list_projects(&config, &issues),
1777            index
1778                .projects()
1779                .into_iter()
1780                .cloned()
1781                .collect::<Vec<Project>>()
1782        );
1783    }
1784
1785    #[test]
1786    fn associate_leaves_the_registry_record_unchanged() {
1787        // Membership is derived, never stored (T007 step 3, review 2). The
1788        // structural equality alone is guaranteed by the shape (a record has no
1789        // member field), so the observable form is added: the *serialized*
1790        // registry — what would land in `tix.toml` — carries no member key, and
1791        // the change is the only place the members appear.
1792        let config = registry(&[("p-aaaa", "One")]);
1793        let before = config.projects["p-aaaa"].clone();
1794        let issues = vec![Node::new(1, "One")];
1795        let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1796
1797        assert_eq!(config.projects["p-aaaa"], before);
1798        assert_eq!(
1799            config.projects.len(),
1800            1,
1801            "a project record carries no member list"
1802        );
1803        assert_eq!(change.project_ids, vec!["p-aaaa".to_string()]);
1804
1805        let serialized = toml::to_string(&config).expect("the registry serializes");
1806        assert!(
1807            !serialized.contains("member"),
1808            "no member/members key may reach disk: {serialized}"
1809        );
1810    }
1811
1812    // --- T008: association mutations -----------------------------------------
1813
1814    #[test]
1815    fn associate_produces_the_list_and_one_event() {
1816        let config = registry(&[("p-aaaa", "One")]);
1817        let issues = vec![Node::new(1, "One")];
1818        let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1819        assert_eq!(ids(&change), ["p-aaaa"]);
1820        let event = change.event.expect("an accepted change is audited");
1821        assert_eq!(event.kind, ProjectEventKind::ProjectAssociated);
1822        assert_eq!(event.project_id, "p-aaaa");
1823        assert_eq!(event.detail, "project `p-aaaa` associated");
1824    }
1825
1826    #[test]
1827    fn re_association_is_idempotent_and_emits_no_event() {
1828        let config = registry(&[("p-aaaa", "One")]);
1829        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1830        let change = associate(&config, &issues, 1, "p-aaaa").unwrap();
1831        assert_eq!(ids(&change), ["p-aaaa"], "no duplicate entry");
1832        assert!(change.event.is_none(), "a no-op is not an audit entry");
1833    }
1834
1835    #[test]
1836    fn associating_an_unknown_project_or_issue_is_refused() {
1837        let config = registry(&[("p-aaaa", "One")]);
1838        let issues = vec![Node::new(1, "One")];
1839        let err = associate(&config, &issues, 1, "p-nope").unwrap_err();
1840        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
1841        let err = associate(&config, &issues, 99, "p-aaaa").unwrap_err();
1842        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1843        assert!(err.message().contains("99"));
1844    }
1845
1846    #[test]
1847    fn dissociate_keeps_the_issue_intact() {
1848        let issues = vec![Node::new(1, "One").with(&["p-aaaa", "p-bbbb"])];
1849        let change = dissociate(&issues, 1, "p-aaaa").unwrap();
1850        assert_eq!(ids(&change), ["p-bbbb"], "only the one association drops");
1851        let event = change.event.unwrap();
1852        assert_eq!(event.kind, ProjectEventKind::ProjectDissociated);
1853        assert_eq!(event.detail, "project `p-aaaa` dissociated");
1854        // The node itself is untouched: the service returns a change, it does not
1855        // mutate or delete the issue (C-004).
1856        assert_eq!(issues[0].projects, vec!["p-aaaa", "p-bbbb"]);
1857    }
1858
1859    #[test]
1860    fn dissociating_an_absent_association_is_a_noop() {
1861        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1862        let change = dissociate(&issues, 1, "p-bbbb").unwrap();
1863        assert_eq!(ids(&change), ["p-aaaa"]);
1864        assert!(change.event.is_none());
1865    }
1866
1867    #[test]
1868    fn dissociate_does_not_resolve_a_project_name() {
1869        // Documented behaviour: `dissociate` compares the stored list verbatim, so
1870        // a name is an inert no-op. Callers resolve a name to an id first; this
1871        // keeps a stale/malformed id removable without a registry lookup.
1872        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1873        let change = dissociate(&issues, 1, "One").unwrap();
1874        assert_eq!(ids(&change), ["p-aaaa"], "a name must not match the id");
1875        assert!(change.event.is_none(), "and it is not an audit entry");
1876    }
1877
1878    #[test]
1879    fn dissociate_removes_a_stale_reference_without_a_project() {
1880        // The repair path C-005 promises: no registry lookup, only the issue.
1881        let issues = vec![Node::new(1, "One").with(&["p-deleted"])];
1882        let change = dissociate(&issues, 1, "p-deleted").unwrap();
1883        assert_eq!(change.project_ids, [] as [std::string::String; 0]);
1884        assert!(change.event.is_some());
1885    }
1886
1887    #[test]
1888    fn dissociate_refuses_an_unknown_issue() {
1889        let issues = vec![Node::new(1, "One")];
1890        let err = dissociate(&issues, 42, "p-aaaa").unwrap_err();
1891        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownIssue);
1892    }
1893
1894    #[test]
1895    fn bulk_associate_reports_each_issue_independently() {
1896        let config = registry(&[("p-aaaa", "One")]);
1897        let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1898        let outcomes = bulk_associate(&config, &issues, "p-aaaa", &[1, 99, 2]);
1899
1900        assert_eq!(outcomes.len(), 3, "one entry per selector");
1901        assert_eq!(outcomes[0].selector, 1);
1902        assert!(outcomes[0].outcome.is_ok());
1903        assert_eq!(
1904            outcomes[1].outcome.as_ref().unwrap_err().kind(),
1905            ProjectDiagnosticKind::UnknownIssue,
1906            "the bad selector is reported, not fatal"
1907        );
1908        assert!(
1909            outcomes[2].outcome.is_ok(),
1910            "the good selectors after a bad one still process"
1911        );
1912        // Exactly one event per accepted change, none per rejected one.
1913        let events: usize = outcomes
1914            .iter()
1915            .filter_map(|o| o.outcome.as_ref().ok())
1916            .filter(|change| change.event.is_some())
1917            .count();
1918        assert_eq!(events, 2);
1919    }
1920
1921    #[test]
1922    fn bulk_associate_reports_an_unknown_project_per_selector() {
1923        let config = registry(&[("p-aaaa", "One")]);
1924        let issues = vec![Node::new(1, "One"), Node::new(2, "Two")];
1925        let outcomes = bulk_associate(&config, &issues, "p-nope", &[1, 2]);
1926        assert!(outcomes.iter().all(|o| {
1927            o.outcome.as_ref().unwrap_err().kind() == ProjectDiagnosticKind::UnknownProject
1928        }));
1929    }
1930
1931    // --- T009: missing-reference diagnosis -----------------------------------
1932
1933    #[test]
1934    fn a_missing_reference_names_the_issue_and_the_id() {
1935        let config = registry(&[("p-aaaa", "One")]);
1936        let issues = vec![Node::new(4, "Four").with(&["p-aaaa", "p-gone"])];
1937        let missing = validate_references(&config, &issues);
1938        assert_eq!(missing.len(), 1);
1939        assert_eq!(missing[0].issue.number(), 4);
1940        assert_eq!(missing[0].issue_display, "#4 Four");
1941        assert_eq!(missing[0].project_id, "p-gone");
1942        assert!(missing[0].reason.contains("#4"));
1943        assert!(missing[0].reason.contains("p-gone"));
1944    }
1945
1946    #[test]
1947    fn a_resolvable_set_reports_no_missing_references() {
1948        let config = registry(&[("p-aaaa", "One")]);
1949        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
1950        assert_eq!(
1951            validate_references(&config, &issues),
1952            [] as [crate::projects::MissingProjectReference; 0]
1953        );
1954    }
1955
1956    #[test]
1957    fn a_duplicated_missing_id_is_reported_once() {
1958        let config = Config::default();
1959        let issues = vec![Node::new(1, "One").with(&["p-gone", "p-gone"])];
1960        assert_eq!(validate_references(&config, &issues).len(), 1);
1961    }
1962
1963    #[test]
1964    fn a_malformed_id_is_a_missing_reference_not_a_panic() {
1965        let config = Config::default();
1966        let issues = vec![Node::new(1, "One").with(&["not a valid id"])];
1967        let missing = validate_references(&config, &issues);
1968        assert_eq!(missing.len(), 1);
1969        assert_eq!(missing[0].project_id, "not a valid id");
1970    }
1971
1972    #[test]
1973    fn validate_references_is_deterministic() {
1974        let config = registry(&[("p-aaaa", "One")]);
1975        let issues = vec![
1976            Node::new(3, "Three").with(&["p-z", "p-a"]),
1977            Node::new(1, "One").with(&["p-m"]),
1978        ];
1979        let first = validate_references(&config, &issues);
1980        let second = validate_references(&config, &issues);
1981        assert_eq!(first, second);
1982        assert_eq!(
1983            first
1984                .iter()
1985                .map(|m| (m.issue.number(), m.project_id.as_str()))
1986                .collect::<Vec<_>>(),
1987            vec![(1, "p-m"), (3, "p-a"), (3, "p-z")],
1988            "ordered by (issue number, id)"
1989        );
1990    }
1991
1992    #[test]
1993    fn a_missing_reference_is_accounted_for_in_the_index() {
1994        // It must not silently vanish from the index's accounting (T007 step 5).
1995        let config = Config::default();
1996        let issues = vec![Node::new(1, "One").with(&["p-gone"])];
1997        let index = ProjectIndex::build(&config, &issues);
1998        assert!(index.buckets().is_empty());
1999        assert_eq!(index.missing().len(), 1);
2000    }
2001
2002    // --- T010: operations on in-memory values --------------------------------
2003
2004    #[test]
2005    fn the_operations_run_on_in_memory_values() {
2006        // This pins that the operations run on values built here, not that the
2007        // module is filesystem-free — a test cannot observe the absence of an
2008        // `std::fs` call. The filesystem-free property is a documented,
2009        // inspection-checked claim on the module (no `Store`, no `std::fs`, no
2010        // `tempfile`), which is what keeps this suite free of a `tempdir`.
2011        let mut config = Config::default();
2012        let created = create_project(
2013            &mut config,
2014            &NewProject {
2015                name: "In memory".into(),
2016                ..NewProject::default()
2017            },
2018        )
2019        .unwrap();
2020        let issues = vec![Node::new(1, "One")];
2021        let change = associate(&config, &issues, 1, created.id.as_str()).unwrap();
2022        assert_eq!(change.project_ids, vec![created.id.to_string()]);
2023        assert_eq!(list_projects(&config, &issues).len(), 1);
2024        assert_eq!(
2025            validate_references(&config, &issues),
2026            [] as [crate::projects::MissingProjectReference; 0]
2027        );
2028    }
2029
2030    // --- T015/T016: the one progress and health computation ------------------
2031
2032    #[test]
2033    fn t015_a_project_with_four_members_and_one_complete_reports_one_of_four() {
2034        let config = registry(&[("p-aaaa", "One")]);
2035        let issues = vec![
2036            Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2037            Node::new(2, "Two").with(&["p-aaaa"]),
2038            Node::new(3, "Three").with(&["p-aaaa"]),
2039            Node::new(4, "Four").with(&["p-aaaa"]),
2040        ];
2041        let assigned = assigned_keys(&issues);
2042        let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2043        assert_eq!(report.progress.done, 1);
2044        assert_eq!(report.progress.total, 4);
2045        assert_eq!(
2046            report.progress.progress,
2047            Progress { done: 1, total: 4 },
2048            "the typed planner value is reused, not restated"
2049        );
2050        assert_eq!(report.project_id.as_str(), "p-aaaa");
2051        assert_eq!(report.name, "One");
2052    }
2053
2054    #[test]
2055    fn t015_an_empty_project_reports_zeros() {
2056        let config = registry(&[("p-aaaa", "One")]);
2057        let report =
2058            health::<Node, _>(&config, &[], "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2059        assert_eq!(report.progress.progress, Progress { done: 0, total: 0 });
2060        assert_eq!(report.health.overdue, 0);
2061        assert_eq!(report.health.blocked, 0);
2062        assert_eq!(report.health.unassigned, 0);
2063        assert_eq!(
2064            report.health.missing_references,
2065            [] as [crate::projects::MissingProjectReference; 0]
2066        );
2067    }
2068
2069    #[test]
2070    fn t015_completion_resolves_from_the_configured_column_not_a_literal() {
2071        // The board completes on `shipped` and has no `done` column at all, so a
2072        // literal `"done"` comparison cannot pass this: `done` is not even a
2073        // configured column, and an issue there resolves to no category.
2074        let mut config = custom_completion(
2075            &["triage", "wip", "shipped"],
2076            "shipped",
2077            &[
2078                ("triage", "unstarted"),
2079                ("wip", "active"),
2080                ("shipped", "completed"),
2081            ],
2082        );
2083        config.projects.insert(
2084            "p-aaaa".to_string(),
2085            ProjectConfiguration {
2086                name: "One".to_string(),
2087                ..ProjectConfiguration::default()
2088            },
2089        );
2090        let issues = vec![
2091            Node::new(1, "Shipped")
2092                .in_column("shipped")
2093                .with(&["p-aaaa"]),
2094            Node::new(2, "Literal done")
2095                .in_column("done")
2096                .with(&["p-aaaa"]),
2097        ];
2098        let report = health(&config, &issues, "p-aaaa", &set(&[]), &set(&[]), &set(&[])).unwrap();
2099        assert_eq!(
2100            report.progress.done, 1,
2101            "only the configured completion column counts"
2102        );
2103        assert_eq!(report.progress.total, 2);
2104    }
2105
2106    #[test]
2107    fn t016_health_counts_the_known_fixture_exactly() {
2108        let config = registry(&[("p-aaaa", "One")]);
2109        let issues = vec![
2110            Node::new(1, "Complete")
2111                .in_column("done")
2112                .assigned_to("alice")
2113                .with(&["p-aaaa"]),
2114            Node::new(2, "Overdue")
2115                .assigned_to("bob")
2116                .with(&["p-aaaa", "p-gone"]),
2117            Node::new(3, "Blocked")
2118                .assigned_to("carol")
2119                .with(&["p-aaaa"]),
2120            Node::new(4, "Unassigned").with(&["p-aaaa"]),
2121        ];
2122        let assigned = assigned_keys(&issues);
2123        // #1 is complete **and** in the overdue set: a done issue is not overdue.
2124        let report = health(
2125            &config,
2126            &issues,
2127            "p-aaaa",
2128            &assigned,
2129            &set(&[1, 2]),
2130            &set(&[3]),
2131        )
2132        .unwrap();
2133        assert_eq!(report.progress.done, 1);
2134        assert_eq!(report.progress.total, 4);
2135        assert_eq!(report.health.overdue, 1, "a done issue is not overdue");
2136        assert_eq!(report.health.blocked, 1);
2137        assert_eq!(report.health.unassigned, 1);
2138        assert_eq!(report.health.missing_references.len(), 1);
2139        let missing = &report.health.missing_references[0];
2140        assert_eq!(missing.issue.number(), 2);
2141        assert_eq!(missing.project_id, "p-gone");
2142        assert_ne!(missing.reason, "");
2143    }
2144
2145    #[test]
2146    fn t016_a_done_member_is_never_overdue() {
2147        let config = registry(&[("p-aaaa", "One")]);
2148        let issues = vec![
2149            Node::new(1, "Done").in_column("done").with(&["p-aaaa"]),
2150            Node::new(2, "Open").with(&["p-aaaa"]),
2151        ];
2152        let report = health(
2153            &config,
2154            &issues,
2155            "p-aaaa",
2156            &set(&[1, 2]),
2157            &set(&[1, 2]),
2158            &set(&[]),
2159        )
2160        .unwrap();
2161        assert_eq!(
2162            report.health.overdue, 1,
2163            "only the open overdue member counts"
2164        );
2165    }
2166
2167    #[test]
2168    fn t016_a_blank_assignee_counts_as_unassigned() {
2169        let config = registry(&[("p-aaaa", "One")]);
2170        let issues = vec![
2171            Node::new(1, "Absent").with(&["p-aaaa"]),
2172            Node::new(2, "Blank").assigned_to("   ").with(&["p-aaaa"]),
2173            Node::new(3, "Assigned")
2174                .assigned_to("alice")
2175                .with(&["p-aaaa"]),
2176        ];
2177        let assigned = assigned_keys(&issues);
2178        assert_eq!(assigned, set(&[3]), "absent and blank are both unassigned");
2179        let report = health(&config, &issues, "p-aaaa", &assigned, &set(&[]), &set(&[])).unwrap();
2180        assert_eq!(report.health.unassigned, 2);
2181    }
2182
2183    #[test]
2184    fn t016_a_resolvable_member_set_reports_no_missing_references() {
2185        let config = registry(&[("p-aaaa", "One")]);
2186        let issues = vec![Node::new(1, "One").with(&["p-aaaa"])];
2187        let report = health(&config, &issues, "p-aaaa", &set(&[1]), &set(&[]), &set(&[])).unwrap();
2188        assert_eq!(
2189            report.health.missing_references,
2190            [] as [crate::projects::MissingProjectReference; 0]
2191        );
2192    }
2193
2194    #[test]
2195    fn t016_health_refuses_an_unknown_project() {
2196        let config = registry(&[("p-aaaa", "One")]);
2197        let err =
2198            health::<Node, _>(&config, &[], "p-nope", &set(&[]), &set(&[]), &set(&[])).unwrap_err();
2199        assert_eq!(err.kind(), ProjectDiagnosticKind::UnknownProject);
2200    }
2201
2202    // --- T019: determinism and the single-implementation guard ---------------
2203
2204    #[test]
2205    fn t019_c006_the_health_read_is_deterministic_and_correct() {
2206        // Non-vacuous by construction: the fixture's figures are spelled out, so a
2207        // stub returning a constant fails the correctness half, and the repeated
2208        // reads are compared, so a non-deterministic read fails the other half.
2209        let config = registry(&[("p-aaaa", "One")]);
2210        let issues = vec![
2211            Node::new(1, "Complete")
2212                .in_column("done")
2213                .assigned_to("alice")
2214                .with(&["p-aaaa"]),
2215            Node::new(2, "Overdue").assigned_to("bob").with(&["p-aaaa"]),
2216            Node::new(3, "Blocked")
2217                .assigned_to("carol")
2218                .with(&["p-aaaa"]),
2219            Node::new(4, "Unassigned").with(&["p-aaaa"]),
2220        ];
2221        let assigned = assigned_keys(&issues);
2222        let first = health(
2223            &config,
2224            &issues,
2225            "p-aaaa",
2226            &assigned,
2227            &set(&[2]),
2228            &set(&[3]),
2229        )
2230        .unwrap();
2231        assert_eq!(
2232            first.progress,
2233            ProjectProgress {
2234                done: 1,
2235                total: 4,
2236                progress: Progress { done: 1, total: 4 },
2237            }
2238        );
2239        assert_eq!(first.health.overdue, 1);
2240        assert_eq!(first.health.blocked, 1);
2241        assert_eq!(first.health.unassigned, 1);
2242        for _ in 0..3 {
2243            let again = health(
2244                &config,
2245                &issues,
2246                "p-aaaa",
2247                &assigned,
2248                &set(&[2]),
2249                &set(&[3]),
2250            )
2251            .unwrap();
2252            assert_eq!(again, first, "repeated reads of one snapshot are identical");
2253        }
2254    }
2255
2256    #[test]
2257    fn t019_c006_a_noop_association_change_leaves_the_figures_unchanged() {
2258        // A no-op mutation must not move a figure. The returned list is **applied**
2259        // back into the snapshot before the second read — not merely re-read from
2260        // the original slice — so the assertion can actually fail: it observes the
2261        // round trip through `dissociate`'s output, not a second read of the same
2262        // input. (Re-reading the unchanged input would hold for any deterministic
2263        // implementation and would prove nothing.)
2264        let config = registry(&[("p-aaaa", "One")]);
2265        let issues = vec![
2266            Node::new(1, "Complete").in_column("done").with(&["p-aaaa"]),
2267            Node::new(2, "Open").with(&["p-aaaa"]),
2268        ];
2269        let assigned = assigned_keys(&issues);
2270        let before = health(&config, &issues, "p-aaaa", &assigned, &set(&[2]), &set(&[])).unwrap();
2271
2272        let change = dissociate(&issues, 1, "p-bbbb").unwrap();
2273        assert_eq!(ids(&change), ["p-aaaa"], "the list is unchanged");
2274        assert!(change.event.is_none(), "no event is emitted");
2275
2276        let mut applied = issues.clone();
2277        applied[0].projects = change.project_ids.clone();
2278        let assigned_after = assigned_keys(&applied);
2279        let after = health(
2280            &config,
2281            &applied,
2282            "p-aaaa",
2283            &assigned_after,
2284            &set(&[2]),
2285            &set(&[]),
2286        )
2287        .unwrap();
2288        assert_eq!(
2289            before, after,
2290            "applying the no-op leaves the figures unchanged"
2291        );
2292    }
2293}