tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
//! The initiative registry: records, explicit associations, validation, and the
//! one atomic write boundary.
//!
//! Mission `roadmap-initiatives-01M3C8SX` (WP01). The record itself
//! ([`InitiativeConfiguration`]) lives beside its two siblings — `[projects.<id>]`
//! and `[epics.<id>]` — in [`crate::config`], because the key rules, the display
//! name rules, the date rule and the atomic save are all **shared** there. This
//! module is what the registry *does*:
//!
//! * the explicit, typed associations (data-model §2) — project **ids**, epic
//!   **ids**, and milestone **names**, the same id/name asymmetry the epic
//!   contract records — with repeated association collapsing to one entry;
//! * the write-path half of the load-time shape checks, so a value that could not
//!   be stored (a malformed id) is refused rather than written;
//! * the **atomic boundary** (T004): every accepted change validates the complete
//!   candidate registry, then writes through the existing atomic
//!   [`Config::save`], and only then updates the caller's in-memory config. A
//!   rejected change writes no bytes and leaves the caller's config untouched —
//!   `tix.toml` stays byte-identical.
//!
//! # No History event
//!
//! An initiative change appends **no** `## History` event. That section belongs to
//! an *issue*, and an initiative is a config record: nothing in this mission writes
//! an issue file, so there is no subject for an event, and a second event log is
//! out of scope (C-001, C-009). The audit is the atomic registry write plus version
//! control, exactly the boundary `[projects]`/`[epics]` established for their own
//! create/edit/delete. This is a deliberate deviation from `data-model.md` §7,
//! recorded here rather than discovered.
//!
//! # No resolution at load
//!
//! References are **never** resolved against another registry here. A dangling
//! reference must stay loadable and diagnosable (C-005), exactly as a deleted
//! project or epic does today; resolution and diagnostics belong to the service
//! (WP03) and to `tix lint`. Only what the record itself can prove — a malformed
//! id — is refused.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::config::{Config, EpicId, InitiativeConfiguration, InitiativeId, ProjectId};
use crate::error::{Result, TixError};
use crate::model::Priority;
use crate::projects::Patch;

// ---------------------------------------------------------------------------
// Preserved unknown metadata
// ---------------------------------------------------------------------------

/// The unknown metadata of one initiative record, preserved verbatim.
///
/// `[initiatives.<id>]` deliberately has **no** `deny_unknown_fields` — unlike its
/// `[projects.<id>]`/`[epics.<id>]` siblings — because the data model lists
/// `extensions` as "preserved unknown data". A key this schema does not know lands
/// here and round-trips unchanged instead of making `tix.toml` unreadable. *That is
/// the trade-off*: a typo'd key is preserved rather than refused, which is the cost
/// of the forward-compatibility guarantee the mission asks for. The map is ordered,
/// so the round trip is stable.
///
/// Compared by its canonical serialization because [`toml::Value`] carries `f64` and
/// so is not `Eq`; two extension areas are the same data exactly when they
/// serialize to the same TOML.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct InitiativeExtensions(toml::Table);

impl InitiativeExtensions {
    /// Whether no unknown metadata is present.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// The number of preserved keys.
    #[must_use]
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Look up a preserved key.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&toml::Value> {
        self.0.get(key)
    }

    /// The preserved keys, in canonical order.
    pub fn keys(&self) -> impl Iterator<Item = &str> {
        self.0.keys().map(String::as_str)
    }

    /// Iterate the preserved `(key, value)` pairs in canonical order.
    pub fn iter(&self) -> impl Iterator<Item = (&str, &toml::Value)> {
        self.0.iter().map(|(k, v)| (k.as_str(), v))
    }

    /// The underlying table, for a caller that must serialize it whole.
    #[must_use]
    pub fn table(&self) -> &toml::Table {
        &self.0
    }

    /// Preserve one `(key, value)` pair, returning the previous value if any.
    pub fn insert(&mut self, key: impl Into<String>, value: toml::Value) -> Option<toml::Value> {
        self.0.insert(key.into(), value)
    }
}

impl PartialEq for InitiativeExtensions {
    fn eq(&self, other: &Self) -> bool {
        toml::Value::Table(self.0.clone()).to_string()
            == toml::Value::Table(other.0.clone()).to_string()
    }
}

impl Eq for InitiativeExtensions {}

impl From<toml::Table> for InitiativeExtensions {
    fn from(table: toml::Table) -> Self {
        Self(table)
    }
}

// ---------------------------------------------------------------------------
// Associations (data-model §2)
// ---------------------------------------------------------------------------

/// The closed kind of an initiative association (data-model §2).
///
/// The two id kinds are spelled with the same asymmetry the epic contract records:
/// a project and an epic are referenced by **stable id**, a milestone by its
/// **name**. The enum exists so a caller cannot pass a milestone name where a
/// project id belongs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AssociationKind {
    /// A project reference, by stable [`ProjectId`].
    Project,
    /// An epic reference, by stable [`EpicId`].
    Epic,
    /// A milestone reference, by the existing name-keyed spelling.
    Milestone,
}

impl AssociationKind {
    /// The field name the record stores this kind under.
    #[must_use]
    pub fn field(self) -> &'static str {
        match self {
            AssociationKind::Project => "projects",
            AssociationKind::Epic => "epics",
            AssociationKind::Milestone => "milestones",
        }
    }

    /// The single reason a reference of this kind is malformed.
    ///
    /// Project and epic references go through the existing stable-id types, so a
    /// value that would not survive a reload is refused here rather than stored.
    /// A milestone is a *name*, so its shape check is only that it is non-blank.
    fn normalize(self, value: &str) -> Result<String> {
        match self {
            AssociationKind::Project => ProjectId::new(value)
                .map(|id| id.to_string())
                .map_err(|err| TixError::Config(format!("`projects`: {err}"))),
            AssociationKind::Epic => EpicId::new(value)
                .map(|id| id.to_string())
                .map_err(|err| TixError::Config(format!("`epics`: {err}"))),
            AssociationKind::Milestone => {
                // A milestone is a **name**, so its shape check is only non-blankness.
                // The value is trimmed rather than stored verbatim: a padded spelling
                // names the same milestone once the registry is consulted, so keeping
                // the padding would make `" 2026-Q4"` and `"2026-Q4"` two different
                // references — a second entry and a second write, breaking the
                // idempotence invariant — and would never match the milestone file.
                let trimmed = value.trim();
                if trimmed.is_empty() {
                    Err(TixError::Config(
                        "`milestones` must not contain a blank value".into(),
                    ))
                } else {
                    Ok(trimmed.to_string())
                }
            }
        }
    }
}

impl std::fmt::Display for AssociationKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.field())
    }
}

/// One explicit, typed reference from an initiative to a source entity.
///
/// A **view**, not stored state: the target id is what the record holds, and the
/// kind is derived from which list it came from. `source_label` and `resolution`
/// (data-model §2) are the service's to derive — this type carries identity only,
/// so no surface can mistake a label for identity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitiativeAssociation {
    /// The initiative that owns the relation.
    pub initiative: InitiativeId,
    /// The relation kind.
    pub kind: AssociationKind,
    /// The stored target: a project id, an epic id, or a milestone name.
    pub target: String,
}

impl InitiativeAssociation {
    /// Build an association view.
    #[must_use]
    pub fn new(initiative: InitiativeId, kind: AssociationKind, target: String) -> Self {
        Self {
            initiative,
            kind,
            target,
        }
    }
}

// ---------------------------------------------------------------------------
// The create/edit specs
// ---------------------------------------------------------------------------

/// The spec for a new initiative (name plus optional metadata and associations).
///
/// Mirrors [`crate::epics::NewEpic`]: the caller supplies what a user typed; the
/// service normalizes it, allocates the stable id, and validates the whole
/// registry before a single byte is written.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NewInitiative {
    /// Required display label. Never identity: the id is allocated, not typed.
    pub name: String,
    /// Optional strategic outcome.
    pub outcome: Option<String>,
    /// Optional owner identity **string**; no lookup, never inferred.
    pub owner: Option<String>,
    /// Optional initiative status (free-form; not an issue board column).
    pub status: Option<String>,
    /// Optional initiative priority (free-form; the shared [`Priority`] shape).
    pub priority: Option<Priority>,
    /// Filterable labels; a repeat collapses to one.
    pub labels: Vec<String>,
    /// Optional calendar target (`YYYY-MM-DD`).
    pub target_date: Option<String>,
    /// Project **ids** this initiative concerns.
    pub projects: Vec<String>,
    /// Epic **ids** this initiative concerns.
    pub epics: Vec<String>,
    /// Milestone **names** this initiative concerns.
    pub milestones: Vec<String>,
    /// Unknown metadata to carry through, preserved verbatim.
    pub extensions: InitiativeExtensions,
}

/// The fields of an initiative edit. An unchanged field is left alone; `Clear`
/// removes an optional value, `Set` replaces it.
///
/// `name` is a label change only: the registry key is the stable identity and is
/// never touched by an edit (C-002), so a rename leaves every association and
/// derived value intact.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InitiativeEdit {
    /// The display label. A rename keeps the id.
    pub name: Patch<String>,
    /// Strategic outcome.
    pub outcome: Patch<String>,
    /// Owner identity string.
    pub owner: Patch<String>,
    /// Initiative status.
    pub status: Patch<String>,
    /// Initiative priority.
    pub priority: Patch<Priority>,
    /// Filterable labels.
    pub labels: Patch<Vec<String>>,
    /// Calendar target date.
    pub target_date: Patch<String>,
    /// Project ids.
    pub projects: Patch<Vec<String>>,
    /// Epic ids.
    pub epics: Patch<Vec<String>>,
    /// Milestone names.
    pub milestones: Patch<Vec<String>>,
}

// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------

/// Collapse a written-but-blank optional value to absent.
///
/// The loader refuses a present-but-blank key, so collapsing it on the write path is
/// what keeps a record this service writes loadable. Non-blank values are kept
/// verbatim.
fn normalize_optional(value: Option<String>) -> Option<String> {
    value.filter(|v| !v.trim().is_empty())
}

/// Validate and de-duplicate a list of references, preserving the caller's order.
///
/// A repeated reference is **idempotent**: it collapses to one entry rather than
/// appearing twice (the mission's association invariant). Each entry is
/// shape-checked through its kind, so a value that would not survive a reload is
/// refused here instead of stored. A well-formed id that names nothing is **kept** —
/// that is the diagnosable dangling reference C-005 promises, never silently
/// dropped and never inferred from owner or labels.
fn normalize_references(kind: AssociationKind, values: &[String]) -> Result<Vec<String>> {
    let mut out: Vec<String> = Vec::new();
    for value in values {
        let normalized = kind.normalize(value)?;
        if !out.iter().any(|stored| stored == &normalized) {
            out.push(normalized);
        }
    }
    Ok(out)
}

/// De-duplicate labels: a repeated label collapses to one, order preserved.
///
/// Blankness is deliberately **not** judged here: `[initiatives.<id>].labels` is
/// refused by the loader ([`crate::config::validate_initiatives`]) with the record's
/// real id in the message, so the rule keeps exactly one owner instead of a second
/// spelling that could drift.
fn normalize_labels(values: &[String]) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for value in values {
        if !out.iter().any(|stored| stored == value) {
            out.push(value.clone());
        }
    }
    out
}

/// The record a [`NewInitiative`] describes, before its id is allocated.
fn record_from_new(new: &NewInitiative) -> Result<InitiativeConfiguration> {
    Ok(InitiativeConfiguration {
        name: new.name.clone(),
        outcome: normalize_optional(new.outcome.clone()),
        owner: normalize_optional(new.owner.clone()),
        status: normalize_optional(new.status.clone()),
        priority: new.priority.clone(),
        labels: normalize_labels(&new.labels),
        target_date: new.target_date.clone(),
        projects: normalize_references(AssociationKind::Project, &new.projects)?,
        epics: normalize_references(AssociationKind::Epic, &new.epics)?,
        milestones: normalize_references(AssociationKind::Milestone, &new.milestones)?,
        extensions: new.extensions.clone(),
    })
}

/// Resolve an initiative reference: the stable **id first**, then a name
/// convenience.
///
/// The id is the identity (C-002), so a reference that parses as an id and is
/// present wins even when it also spells another initiative's name. A name is
/// accepted because a human selects initiatives by name; the loader's
/// case-folded uniqueness rule guarantees it is unambiguous.
pub fn resolve_initiative_id(config: &Config, reference: &str) -> Result<InitiativeId> {
    if let Ok(id) = InitiativeId::new(reference) {
        if config.initiatives.contains_key(id.as_str()) {
            return Ok(id);
        }
    }
    let normalized = reference.trim().to_lowercase();
    if let Some((raw, _)) = config
        .initiatives
        .iter()
        .find(|(_, record)| record.name.to_lowercase() == normalized)
    {
        return InitiativeId::new(raw)
            .map_err(|_| TixError::InvalidInput(format!("unknown initiative `{reference}`")));
    }
    Err(TixError::InvalidInput(format!(
        "unknown initiative `{reference}`"
    )))
}

// ---------------------------------------------------------------------------
// Read helpers
// ---------------------------------------------------------------------------

/// The stored initiative ids in declaration order.
pub fn ids(config: &Config) -> impl Iterator<Item = &str> {
    config.initiatives.keys().map(String::as_str)
}

/// Look up one initiative record by stable id.
#[must_use]
pub fn get<'a>(config: &'a Config, id: &InitiativeId) -> Option<&'a InitiativeConfiguration> {
    config.initiatives.get(id.as_str())
}

/// Every stored association of `id`, in record order: projects, then epics, then
/// milestones, each in declared order.
///
/// A **read-only** view of the ids the record holds. Nothing here resolves them
/// against another registry, so a missing reference is still returned, which is
/// what keeps it visibly diagnosable (C-005).
#[must_use]
pub fn associations(config: &Config, id: &InitiativeId) -> Vec<InitiativeAssociation> {
    let Some(record) = config.initiatives.get(id.as_str()) else {
        return Vec::new();
    };
    let mut out = Vec::new();
    for (kind, targets) in [
        (AssociationKind::Project, &record.projects),
        (AssociationKind::Epic, &record.epics),
        (AssociationKind::Milestone, &record.milestones),
    ] {
        out.extend(
            targets
                .iter()
                .map(|target| InitiativeAssociation::new(id.clone(), kind, target.clone())),
        );
    }
    out
}

/// Whether `id` already records exactly this association.
#[must_use]
pub fn contains_association(
    config: &Config,
    id: &InitiativeId,
    kind: AssociationKind,
    target: &str,
) -> bool {
    associations(config, id)
        .iter()
        .any(|stored| stored.kind == kind && stored.target == target)
}

// ---------------------------------------------------------------------------
// The atomic write boundary (T004)
// ---------------------------------------------------------------------------

/// Apply `mutate` to a **candidate** clone, validate the whole config, write it
/// atomically, and only then adopt it — the one write path (T004, C-008).
///
/// The order is the guarantee:
///
/// 1. `mutate` runs on a clone of `config`; if it refuses, nothing has changed;
/// 2. the **complete candidate** is validated, so every config invariant holds for
///    what is about to be written;
/// 3. the existing atomic [`Config::save`] writes it (temp file, fsync, rename);
/// 4. only then is the caller's `Config` replaced.
///
/// So a rejected change leaves `tix.toml` byte-identical and the caller's config
/// untouched, and a failed save leaves both as they were.
fn transact<T>(
    config: &mut Config,
    path: &Path,
    mutate: impl FnOnce(&mut Config) -> Result<T>,
) -> Result<T> {
    let mut candidate = config.clone();
    let outcome = mutate(&mut candidate)?;
    candidate.validate()?;
    candidate.save(path)?;
    *config = candidate;
    Ok(outcome)
}

/// Create an initiative, allocating a fresh stable id (FR-001, T001).
///
/// The id is drawn from the shared [`Config::allocate_initiative_id`] and never
/// reused; the name, dates, labels and reference shapes are validated before the
/// write, so a refusal persists nothing. The record is persisted through the atomic
/// boundary, so an accepted create leaves the complete record on disk.
pub fn create_initiative(
    config: &mut Config,
    path: &Path,
    new: &NewInitiative,
) -> Result<InitiativeId> {
    let record = record_from_new(new)?;
    transact(config, path, |candidate| {
        let id = candidate.allocate_initiative_id();
        candidate.initiatives.insert(id.to_string(), record.clone());
        Ok(id)
    })
}

/// Edit an initiative in place, addressed by its stable id (FR-001, T002).
///
/// A rename changes the label only: the map key is untouched, so every association
/// and derived value survives (C-002). An edit that changes nothing is an
/// idempotent no-op and writes no bytes.
pub fn edit_initiative(
    config: &mut Config,
    path: &Path,
    id: &InitiativeId,
    edit: &InitiativeEdit,
) -> Result<InitiativeId> {
    let Some(current) = config.initiatives.get(id.as_str()).cloned() else {
        return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
    };
    let edited = apply_edit(&current, edit)?;
    if edited == current {
        return Ok(id.clone());
    }
    transact(config, path, |candidate| {
        candidate.initiatives.insert(id.to_string(), edited.clone());
        Ok(id.clone())
    })
}

/// Apply an [`InitiativeEdit`] to a record, normalizing what the write path must.
fn apply_edit(
    record: &InitiativeConfiguration,
    edit: &InitiativeEdit,
) -> Result<InitiativeConfiguration> {
    let mut next = record.clone();
    match &edit.name {
        Patch::Unchanged => {}
        // `Clear` on a required name is a blank name, which the loader refuses;
        // applying it here is what lets that one rule own the refusal.
        Patch::Clear => next.name.clear(),
        Patch::Set(value) => next.name = value.clone(),
    }
    apply_text_patch(&mut next.outcome, &edit.outcome);
    apply_text_patch(&mut next.owner, &edit.owner);
    apply_text_patch(&mut next.status, &edit.status);
    apply_patch(&mut next.priority, &edit.priority);
    if let Patch::Set(labels) = &edit.labels {
        next.labels = normalize_labels(labels);
    } else if matches!(edit.labels, Patch::Clear) {
        next.labels.clear();
    }
    apply_text_patch(&mut next.target_date, &edit.target_date);
    apply_reference_patch(&mut next.projects, AssociationKind::Project, &edit.projects)?;
    apply_reference_patch(&mut next.epics, AssociationKind::Epic, &edit.epics)?;
    apply_reference_patch(
        &mut next.milestones,
        AssociationKind::Milestone,
        &edit.milestones,
    )?;
    Ok(next)
}

/// Apply a three-state patch to an optional field, collapsing blanks to absent.
fn apply_patch<T: Clone>(field: &mut Option<T>, patch: &Patch<T>) {
    match patch {
        Patch::Unchanged => {}
        Patch::Clear => *field = None,
        Patch::Set(value) => *field = Some(value.clone()),
    }
}

/// Apply a three-state patch to an optional **text** field: a written-but-blank
/// value collapses to absent, so the write path never stores what the loader refuses.
fn apply_text_patch(field: &mut Option<String>, patch: &Patch<String>) {
    match patch {
        Patch::Unchanged => {}
        Patch::Clear => *field = None,
        Patch::Set(value) => *field = normalize_optional(Some(value.clone())),
    }
}

/// Apply a three-state patch to a reference list, validating and de-duplicating
/// every entry through its kind (shape only, never resolution).
fn apply_reference_patch(
    field: &mut Vec<String>,
    kind: AssociationKind,
    patch: &Patch<Vec<String>>,
) -> Result<()> {
    match patch {
        Patch::Unchanged => Ok(()),
        Patch::Clear => {
            field.clear();
            Ok(())
        }
        Patch::Set(values) => {
            *field = normalize_references(kind, values)?;
            Ok(())
        }
    }
}

/// Delete an initiative definition, addressed by its stable id (FR-001).
///
/// Remove is **definition-only**: this mission stores no issue-side state for an
/// initiative, so nothing else is touched and no association is silently repaired.
/// The id stays spent — the shared allocator holds no state a deletion could rewind.
/// A delete that changes nothing writes no bytes.
pub fn delete_initiative(config: &mut Config, path: &Path, id: &InitiativeId) -> Result<()> {
    if !config.initiatives.contains_key(id.as_str()) {
        return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
    }
    transact(config, path, |candidate| {
        candidate.initiatives.shift_remove(id.as_str());
        Ok(())
    })
}

/// Record an explicit association on an initiative (FR-002, T003).
///
/// The target is shape-checked for its kind and **never** resolved: a well-formed id
/// that names nothing is stored and stays diagnosable (C-005). Repeated association
/// is **idempotent** — associating the same target twice records one entry and the
/// second call writes no bytes. Returns whether a new association was recorded.
pub fn associate(
    config: &mut Config,
    path: &Path,
    reference: &str,
    kind: AssociationKind,
    target: &str,
) -> Result<bool> {
    let target = kind.normalize(target)?;
    let id = resolve_initiative_id(config, reference)?;
    if contains_association(config, &id, kind, &target) {
        return Ok(false);
    }
    transact(config, path, |candidate| {
        let Some(record) = candidate.initiatives.get_mut(id.as_str()) else {
            return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
        };
        let field = match kind {
            AssociationKind::Project => &mut record.projects,
            AssociationKind::Epic => &mut record.epics,
            AssociationKind::Milestone => &mut record.milestones,
        };
        if !field.iter().any(|stored| stored == &target) {
            field.push(target.clone());
        }
        Ok(true)
    })
}

/// Drop an explicit association from an initiative (FR-002, T003).
///
/// Dropping an association that is not recorded is an idempotent no-op that writes
/// no bytes. Returns whether an association was removed.
pub fn dissociate(
    config: &mut Config,
    path: &Path,
    reference: &str,
    kind: AssociationKind,
    target: &str,
) -> Result<bool> {
    // The same shape check and normalization the record path applies, so a padded or
    // differently-spelled reference addresses exactly the reference it names on the
    // way in: add and remove stay symmetric.
    let target = kind.normalize(target)?;
    let id = resolve_initiative_id(config, reference)?;
    if !contains_association(config, &id, kind, &target) {
        return Ok(false);
    }
    transact(config, path, |candidate| {
        let Some(record) = candidate.initiatives.get_mut(id.as_str()) else {
            return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
        };
        let field = match kind {
            AssociationKind::Project => &mut record.projects,
            AssociationKind::Epic => &mut record.epics,
            AssociationKind::Milestone => &mut record.milestones,
        };
        field.retain(|stored| stored != &target);
        Ok(true)
    })
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::config::InitiativeConfiguration;

    /// Two extension areas are the same data exactly when they serialize the same
    /// way: the wrapper exists because `toml::Value` carries `f64` and so is not `Eq`.
    #[test]
    fn extension_equality_is_by_canonical_serialization() {
        let mut left = InitiativeExtensions::default();
        left.insert("a", toml::Value::String("1".into()));
        let mut right = InitiativeExtensions::default();
        right.insert("a", toml::Value::String("1".into()));
        assert_eq!(left, right);
        assert!(!left.is_empty());
        assert_eq!(left.len(), 1);
        assert_eq!(left.get("a").and_then(toml::Value::as_str), Some("1"));

        right.insert("a", toml::Value::String("2".into()));
        assert_ne!(left, right);
        assert!(InitiativeExtensions::default().is_empty());
    }

    /// A repeated reference collapses to one, order is the caller's, and a
    /// well-formed but unknown id is kept rather than resolved away (C-005).
    #[test]
    fn references_collapse_and_are_never_resolved() {
        let stored = normalize_references(
            AssociationKind::Project,
            &["p-b".into(), "p-a".into(), "p-b".into(), "p-missing".into()],
        )
        .unwrap();
        assert_eq!(stored, ["p-b", "p-a", "p-missing"]);

        let milestones = normalize_references(
            AssociationKind::Milestone,
            &["  2026-Q4 ".into(), "2026-Q4".into()],
        )
        .unwrap();
        assert_eq!(
            milestones,
            ["2026-Q4"],
            "a padded spelling is the same milestone once trimmed"
        );
    }

    /// The two id kinds are shape-checked through the existing stable-id types; a
    /// milestone is a name, so only its blankness is checkable.
    #[test]
    fn a_malformed_reference_names_its_field() {
        let err = normalize_references(AssociationKind::Project, &["p.1".into()])
            .unwrap_err()
            .to_string();
        assert!(err.contains("projects"), "got {err}");

        let err = normalize_references(AssociationKind::Epic, &["e 1".into()])
            .unwrap_err()
            .to_string();
        assert!(err.contains("epics"), "got {err}");

        let err = normalize_references(AssociationKind::Milestone, &["   ".into()])
            .unwrap_err()
            .to_string();
        assert!(err.contains("milestones"), "got {err}");
    }

    /// Labels de-duplicate, the first spelling wins, and order survives.
    #[test]
    fn labels_collapse_keeping_the_first_spelling() {
        assert_eq!(
            normalize_labels(&["a".into(), "b".into(), "a".into()]),
            ["a", "b"]
        );
        assert_eq!(normalize_labels(&[]), [] as [std::string::String; 0]);
    }

    /// The association view reads the record in kind order and derives its kind from
    /// the list it came from — no stored kind to drift.
    #[test]
    fn associations_enumerate_in_kind_order() {
        let mut config = Config::default();
        let id = config.allocate_initiative_id();
        config.initiatives.insert(
            id.to_string(),
            InitiativeConfiguration {
                name: "One".into(),
                projects: vec!["p-a".into()],
                epics: vec!["e-a".into()],
                milestones: vec!["2026-Q4".into()],
                ..InitiativeConfiguration::default()
            },
        );
        let listed = associations(&config, &id);
        assert_eq!(
            listed
                .iter()
                .map(|a| (a.kind, a.target.as_str()))
                .collect::<Vec<_>>(),
            [
                (AssociationKind::Project, "p-a"),
                (AssociationKind::Epic, "e-a"),
                (AssociationKind::Milestone, "2026-Q4"),
            ]
        );
        assert!(contains_association(
            &config,
            &id,
            AssociationKind::Project,
            "p-a"
        ));
        assert!(!contains_association(
            &config,
            &id,
            AssociationKind::Project,
            "p-b"
        ));
        assert_eq!(
            associations(&config, &InitiativeId::new("i-none").unwrap()),
            [] as [crate::roadmap::registry::InitiativeAssociation; 0]
        );
    }
}