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
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
#[allow(clippy::wildcard_imports)]
use super::*;

/// Remote tracker credentials/targets embedded in `tix.toml`.
///
/// `token` values are read-only: they are never serialized back out and are
/// never echoed in logs or errors. `base_url` may override the default
/// endpoint for self-hosted GitLab instances.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RemoteConfig {
    /// Optional cleartext token; prefer the `GITLAB_TOKEN`/`LINEAR_TOKEN`
    /// env vars. Never serialized back to disk.
    pub token: Option<String>,
    /// Optional base URL (e.g. self-hosted GitLab).
    pub base_url: Option<String>,
    /// GitLab project path (e.g. `acme/tix`) passed to `glab`.
    pub project: Option<String>,
    /// Linear team name passed to `linear-cli`.
    pub team: Option<String>,
}

/// The `[export]` section: per-target remote configuration.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ExportConfig {
    /// GitLab remote target.
    pub gitlab: Option<RemoteConfig>,
    /// Linear remote target.
    pub linear: Option<RemoteConfig>,
}

impl ExportConfig {
    /// Whether no remote target is configured, for `skip_serializing_if`.
    ///
    /// An empty `[export]` table is never written, so a legacy `tix.toml` gains
    /// no section on save (#1415).
    pub fn is_empty(&self) -> bool {
        self.gitlab.is_none() && self.linear.is_none()
    }
}

/// A named team (`[teams.<label>]` in `tix.toml`). The label lives as the
/// map key; `members` are free-form identities feeding the assignee/reviewer
/// pickers only (never constraining stored values, DM-01M311MRK517KRHR426QPNP6P7).
///
/// An absent/empty `[teams]` section means teams are disabled (today's
/// behavior), and an empty `[teams]` table is never written back out.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Team {
    /// Non-empty per team; unique within the team.
    pub members: Vec<String>,
}

/// A named cycle (`[cycles.<name>]` in `tix.toml`). The name lives as the
/// map key; `start_date`/`due_date` are optional `YYYY-MM-DD` strings with
/// `due_date >= start_date` when both are present.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PlanningCycle {
    /// Optional human description.
    pub description: Option<String>,
    /// Optional start date (`YYYY-MM-DD`).
    pub start_date: Option<String>,
    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
    pub due_date: Option<String>,
}

/// A project record (`[projects.<project-id>]` in `tix.toml`).
///
/// The stable [`ProjectId`] lives as the map key; `name` is a mutable display
/// label and is **not** the identity (C-002). The section is omitted entirely when
/// there are no projects, so a legacy `tix.toml` is byte-untouched (C-001).
///
/// `status` is project metadata and is deliberately a **free-form string**: it is
/// not the issue's board column, and workflow-*category* semantics belong to
/// source issue #183, so this section introduces no global category model (C-007).
/// `lead` is an identity **string** in the same shape the existing `assignee`
/// field tolerates (bare handle or `Name <email>`); it is not a user account, a
/// directory entry, or a team membership (C-007).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectConfiguration {
    /// Display label. Required and unique within the registry, so interactive
    /// selection is unambiguous — but never the identity, which is the map key.
    pub name: String,
    /// Optional long-form context.
    pub description: Option<String>,
    /// Optional project status (free-form; not a board column, not a category).
    pub status: Option<String>,
    /// Optional lead identity string; no lookup, no user-identity concept.
    pub lead: Option<String>,
    /// Optional start date (`YYYY-MM-DD`).
    pub start_date: Option<String>,
    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
    pub due_date: Option<String>,
}

/// An epic record (`[epics.<epic-id>]` in `tix.toml`).
///
/// Mission `epics-work-items-01M3BYW4` (WP01). The stable [`EpicId`] lives as the
/// map key; `name` is a mutable display label and is **not** the identity (C-003),
/// so a rename leaves every member issue's reference and every derived figure
/// intact. The section is omitted entirely when there are no epics, so a legacy
/// `tix.toml` is byte-untouched (C-004).
///
/// `status` is epic metadata and is deliberately a **free-form string**, exactly
/// like [`ProjectConfiguration::status`]: it is not an issue's board column, and
/// workflow-*category* semantics belong to source issue #183, so no global category
/// model is introduced here. `owner` is an identity **string** in the same shape the
/// existing `assignee` field tolerates (bare handle or `Name <email>`); it is not a
/// user account, a directory entry, or a team membership (C-003).
///
/// `weight` is epic metadata used in epic rollups — a positive relative size. It is
/// **not** a size field on issues: `Issue.estimate` stays the free-form string it is,
/// and no typed size concept is introduced (research R3). A missing weight
/// contributes nothing to a weighted figure.
///
/// `parent` is an **epic id** and `projects` are **project ids**, while `milestone`
/// and `cycle` are the **name-keyed** references the existing registries use. That
/// asymmetry is deliberate — it matches how `Issue.cycles`/`Issue.milestones`
/// already work — and is recorded in `contracts/epic-registry.md` so it is not
/// "corrected" by accident.
///
/// References are validated for **shape** only. Whether a project, milestone, cycle,
/// or parent epic exists is resolved by the epic service and diagnosed by
/// `tix lint`; a dangling reference must stay loadable and repairable, exactly as a
/// view's project scope does (C-005).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct EpicConfiguration {
    /// Display label. Required and unique within the registry, so selection is
    /// unambiguous — but never the identity, which is the map key.
    pub name: String,
    /// Optional long-form context.
    pub description: Option<String>,
    /// Optional epic status (free-form; not a board column, not a category).
    pub status: Option<String>,
    /// Optional owner identity string; no lookup, no user-identity concept.
    pub owner: Option<String>,
    /// Optional start date (`YYYY-MM-DD`).
    pub start_date: Option<String>,
    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
    pub due_date: Option<String>,
    /// Optional positive relative size, used in epic rollups. Never an issue size.
    pub weight: Option<i64>,
    /// Optional parent epic **id**; shape is checked here, existence is not.
    pub parent: Option<String>,
    /// Project **ids** this epic concerns; shape is checked here, existence is not.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub projects: Vec<String>,
    /// Optional milestone **name** (the existing name-keyed reference).
    pub milestone: Option<String>,
    /// Optional cycle **name** (the existing name-keyed reference).
    pub cycle: Option<String>,
}

/// An initiative record (`[initiatives.<initiative-id>]` in `tix.toml`).
///
/// Mission `roadmap-initiatives-01M3C8SX` (WP01). The third stable-ID record, after
/// [`ProjectConfiguration`] and [`EpicConfiguration`]: the [`InitiativeId`] is the
/// map key and `name` is a mutable display label, so a rename leaves every
/// association and derived figure intact (C-002). The section is omitted entirely
/// when there are no initiatives, so a legacy `tix.toml` is byte-untouched (C-001).
///
/// # Preserved extensions — deliberately unlike its siblings
///
/// This record does **not** set `deny_unknown_fields`. The data model lists
/// `extensions` as preserved unknown data, so an unknown key is carried through
/// [`InitiativeExtensions`](crate::roadmap::registry::InitiativeExtensions) and
/// round-trips unchanged rather than making `tix.toml` unreadable. *The trade-off:
/// a typo'd key is preserved rather than refused here*, which is the cost of the
/// forward-compatibility guarantee — the siblings that deny unknown fields pay the
/// opposite cost.
///
/// # Metadata is not issue state
///
/// `status` and `priority` are initiative metadata, **not** an issue's board column
/// or priority: `status` is free-form exactly like [`ProjectConfiguration::status`],
/// and `priority` reuses [`crate::model::Priority`] because its serde shape is a
/// plain string that round-trips an unknown value verbatim (a known spelling is
/// normalized to lowercase, the same rule issues already use). `owner` is an
/// identity **string** with no lookup, never inferred from anything.
///
/// # Associations are explicit, shape-checked, and never resolved here
///
/// `projects` are project **ids**, `epics` are epic **ids**, and `milestones` are
/// the **names** the existing milestone registry uses — the same id/name asymmetry
/// the epic contract records. Every entry is validated for **shape** only: whether
/// its target exists is resolved by the service (WP03) and diagnosed by `tix lint`,
/// so a dangling reference stays loadable and repairable (C-005). A repeated entry
/// is idempotent (see [`crate::roadmap::registry`]).
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct InitiativeConfiguration {
    /// Display label. Required and unique within the registry (case-folded), so
    /// selection is unambiguous — but never the identity, which is the map key.
    pub name: String,
    /// Optional strategic outcome.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub outcome: Option<String>,
    /// Optional owner identity **string**; no lookup, never inferred from anything.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    /// Optional initiative status (free-form; not a board column, not a category).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// Optional initiative priority (see [`crate::model::Priority`]).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub priority: Option<crate::model::Priority>,
    /// Filterable labels; a repeat collapses to one on the write path.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub labels: Vec<String>,
    /// Optional calendar target (`YYYY-MM-DD`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_date: Option<String>,
    /// Project **ids** this initiative concerns; shape checked here, existence not.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub projects: Vec<String>,
    /// Epic **ids** this initiative concerns; shape checked here, existence not.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub epics: Vec<String>,
    /// Milestone **names** this initiative concerns (the name-keyed reference).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub milestones: Vec<String>,
    /// Unknown metadata, preserved verbatim (see the type-level note).
    #[serde(flatten)]
    pub extensions: crate::roadmap::registry::InitiativeExtensions,
}

/// The `[planning]` section: the monotonic auto-cycle counter plus the
/// picker presets written by `tix init` (`contracts/tix-toml-planning.md`).
///
/// `cycle_number` is the next auto "Cycle N" number: it starts at 1, is bumped
/// on every unnamed cycle create, and never decrements (FR-003,
/// DM-01M311BJKMN3PPCR9GR9Y7ESD2). Contrary to the legacy `[state]` counter,
/// this section **is** serialized once mutated.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PlanningConfig {
    /// Monotonic next auto-cycle number; `>= 1`. Default `1`.
    pub cycle_number: u32,
    /// Optional picker preset naming a cycle.
    pub default_cycle: Option<String>,
    /// Optional picker preset naming a milestone.
    pub default_milestone: Option<String>,
    /// Optional picker preset naming a team.
    pub default_team: Option<String>,
}

impl PlanningConfig {
    /// Whether this section is untouched (all values at their defaults); such
    /// a section is not written to disk so legacy `tix.toml` files keep
    /// round-tripping byte-identically (NFR-003).
    pub fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

impl Default for PlanningConfig {
    fn default() -> Self {
        Self {
            cycle_number: 1,
            default_cycle: None,
            default_milestone: None,
            default_team: None,
        }
    }
}

/// A named new-issue template (`[templates.<name>]` in `tix.toml`).
///
/// The F4 New Issue mode (`tui-new-issue-mode`) lists these by name and, on
/// selection, pre-fills the form's rows from the fields the template sets.
/// Every field is optional, the section is absent by default, and an empty
/// `[templates]` table writes no key — so a project that never writes one pays
/// nothing and its `tix.toml` keeps round-tripping unchanged.
///
/// A template carries **content**, not identity: `number`, `created`,
/// `filename` and `History` are creation outputs rather than inputs, so they are
/// not fields here. `body` and `labels` are the two the mode's spec requires;
/// the rest exist because the form already has a row for them, and a template
/// that could not reach a row would only half-fill the form.
///
/// ```toml
/// [templates.bug]
/// labels = ["bug"]
/// body = "## Steps to reproduce\n\n"
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IssueTemplate {
    /// Pre-filled title. Optional: a template that sets none leaves the title to
    /// the user, and the title stays required at submit either way.
    pub title: Option<String>,
    /// Pre-filled issue body (Markdown).
    pub body: Option<String>,
    /// Pre-filled labels.
    pub labels: Vec<String>,
    /// Pre-filled status column.
    pub status: Option<String>,
    /// Pre-filled priority.
    pub priority: Option<String>,
    /// Pre-filled assignee.
    pub assignee: Option<String>,
    /// Pre-filled estimate.
    pub estimate: Option<String>,
    /// Pre-filled reviewer.
    pub reviewer: Option<String>,
    /// Pre-filled cycles.
    pub cycles: Vec<String>,
    /// Pre-filled milestones.
    pub milestones: Vec<String>,
    /// Pre-filled start date (`YYYY-MM-DD`).
    pub start_date: Option<String>,
    /// Pre-filled due date (`YYYY-MM-DD`).
    pub due_date: Option<String>,
}

/// The `[state]` section — deprecated runtime bookkeeping that is still
/// tolerated on load for legacy `tix.toml` files but is never serialized.
///
/// The monotonic `next_number` counter this held was removed in issue #52:
/// issue numbers are now resolved at creation time by scanning the live
/// issue set, so [`Config::save`] writes no `[state]` section at all.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StateConfig {
    /// Legacy monotonic issue-number counter (C-007). Read-only — kept so
    /// old `tix.toml` files with `[state].next_number` still load, and
    /// never persisted once saved again.
    #[serde(skip_serializing)]
    pub next_number: u32,
}

/// Card ordering for the rendered board (`[kanban.<name>].order_by`).
///
/// Applied per column in the board builders; the store's underlying order is
/// untouched so the CLI keeps its own sequencing (board-display FR-02).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum OrderBy {
    /// Most recently created issues first (the default).
    #[default]
    #[serde(rename = "created_at_desc")]
    CreatedAtDesc,
    /// Oldest issues first.
    #[serde(rename = "created_at_asc")]
    CreatedAtAsc,
    /// Preserve the underlying store (filename/created-date ascending) order.
    #[serde(rename = "none")]
    None,
}

impl OrderBy {
    /// The TOML spelling of this variant.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::CreatedAtDesc => "created_at_desc",
            Self::CreatedAtAsc => "created_at_asc",
            Self::None => "none",
        }
    }

    /// The next variant when cycling the setting in the TUI settings modal.
    pub fn next(self) -> Self {
        match self {
            Self::CreatedAtDesc => Self::CreatedAtAsc,
            Self::CreatedAtAsc => Self::None,
            Self::None => Self::CreatedAtDesc,
        }
    }
}

/// The fixed, global category vocabulary (C-002, contract §1).
///
/// One list, referenced by the config validator, the per-board mapping and the
/// service WP02 builds. A vocabulary spelled out in three places is a vocabulary
/// that disagrees with itself the first time one of them changes.
///
/// `TERMINAL` is the subset that ends the workflow. `canceled` is terminal but
/// **not** complete — `COMPLETE` is deliberately a separate one-element list rather
/// than `!TERMINAL.contains(...)`, because "terminal" and "complete" are different
/// claims and the contract makes conflating them an escalation.
pub const CATEGORY_KEYS: &[&str] = &[
    "backlog",
    "unstarted",
    "active",
    "blocked",
    "completed",
    "canceled",
];

/// Categories that end the workflow.
pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];

/// The **only** category that means done. `canceled` is terminal without being
/// complete, so this is a one-element list and not "everything terminal".
pub const COMPLETE_CATEGORY: &str = "completed";

/// The documented safe default for a column with no mapping (contract §4).
///
/// Non-terminal and non-complete, so an unmapped column is never silently counted
/// as done. A default that resolved to `completed` would change completion
/// meaning by accident, which the contract makes an escalation rather than a
/// convenience — [`validate_categories`] refuses one.
pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";

/// `[categories]`: the global vocabulary and the safe default.
///
/// Omitted from disk when untouched, so a legacy `tix.toml` round-trips
/// byte-identically and gains no new keys (NFR-003, C-007).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CategoryConfig {
    /// The vocabulary, in the project's preferred order for presentation.
    ///
    /// Defaults to [`CATEGORY_KEYS`]. A configured list is still validated
    /// against that vocabulary: a board maps columns *into* the global
    /// vocabulary, it does not define its own (C-002), so adding a key here is
    /// an `unknown_category` error rather than a new category.
    pub order: Vec<String>,
    /// The category an unmapped column resolves to. Must be in `order`, and must
    /// not be [`COMPLETE_CATEGORY`].
    pub default: String,
}

impl Default for CategoryConfig {
    fn default() -> Self {
        Self {
            order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
            default: SAFE_DEFAULT_CATEGORY.to_string(),
        }
    }
}

impl CategoryConfig {
    /// Whether this section is untouched, and so is not written to disk.
    pub fn is_default(&self) -> bool {
        self == &Self::default()
    }

    /// Whether `key` is in the configured vocabulary.
    pub fn contains(&self, key: &str) -> bool {
        self.order.iter().any(|k| k == key)
    }
}

/// `[transitions]`: the workflow policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TransitionConfig {
    /// Whether an issue in a terminal category refuses an ordinary move.
    ///
    /// Default `true`. "Ordinary" is load-bearing: the escape hatch WP02 settles is
    /// a *reasoned* transition out of a terminal category, not a silent
    /// reassignment, so this switch governs the second and not the first.
    pub terminal_locked: bool,
}

impl Default for TransitionConfig {
    fn default() -> Self {
        Self {
            terminal_locked: true,
        }
    }
}

impl TransitionConfig {
    pub fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

/// The default number of lines a wrapped card title may occupy
/// ([`BoardConfiguration::title_lines`]). Named so the manual `Serialize` can
/// recognise it without repeating the literal (#1415).
pub const DEFAULT_TITLE_LINES: usize = 3;

/// The `done_column` a board gets when the key is absent.
///
/// `BoardConfiguration` is `#[serde(default)]` at the container level, so a
/// missing `done_column` is filled from [`BoardConfiguration::default`] — this
/// value, not an inference from `columns`. Named so the manual `Serialize` can
/// omit a `done_column` that already equals it (#1415).
pub const DEFAULT_DONE_COLUMN: &str = "done";

/// Health policy configuration (`[health]` in `tix.toml`; mission
/// `project-issue-health-status-01M3C8HX`, WP01 T002/T003).
///
/// One key, and that is the design: the only thing a repository *chooses* about
/// health is which **version** of the derivation policy it is on. The vocabulary
/// and the formula live in the build ([`crate::health::policy`]), because changing
/// either changes what every stored value means — an escalation with its own plan
/// (C-003), not a line in a config file. A version this build does not implement is
/// refused at load, by name.
///
/// Omitted when absent, so a repository that has never mentioned health gains no
/// section and a legacy `tix.toml` round-trips byte-identically.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HealthConfig {
    /// The policy version in force; absent means [`crate::health::HEALTH_POLICY_VERSION`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy: Option<String>,
}

impl HealthConfig {
    /// Whether nothing is configured: the state the schema omits on save.
    pub fn is_empty(&self) -> bool {
        self.policy.is_none()
    }

    /// The policy in force: the configured version, or the current one (T002).
    pub fn policy(&self) -> std::result::Result<crate::health::HealthPolicy, TixError> {
        match self.policy.as_deref() {
            Some(version) => crate::health::HealthPolicy::for_version(version)
                .map_err(|error| TixError::Config(error.render())),
            None => Ok(crate::health::HealthPolicy::v1()),
        }
    }
}

/// A named board configuration (`[kanban.<name>]` in `tix.toml`).
///
/// Carries the ordered column list plus per-configuration metadata: the done
/// column, columns hidden from the rendered board, and whether the computed
/// "Mismatched" pseudo-column is shown (C-002 / DM-01M2MH4D84NHG0JVWEPR6ZH4VR).
///
/// `Serialize` is written by hand (below) so every field equal to the value the
/// loader would supply is omitted on save, keeping a legacy `tix.toml`
/// byte-identical; `Deserialize` stays derived.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoardConfiguration {
    /// Ordered, case-sensitive, non-empty, unique column names.
    pub columns: Vec<String>,
    /// The column treated as "done" (commit prompting). Defaults to `"done"`
    /// when that name is present in `columns`, else the last configured
    /// column (contracts/tix-toml.md field rules).
    ///
    /// **Its meaning is unchanged by this mission.** It remains the commit-prompt
    /// trigger and the legacy completion signal. WP02 reads the *category* for the
    /// completion decision; this field is not reinterpreted here, and a
    /// `category_map` does not change what it does.
    pub done_column: Option<String>,
    /// Column name → category key (contract §2). Omitted when empty, so a board
    /// without a mapping gains no key on save.
    ///
    /// A board with **no** map is a legacy board: its `done_column` resolves to
    /// `completed` and every other column to the safe default. See
    /// [`BoardConfiguration::category_for`], which is the single place that
    /// fallback lives — duplicating it in the service is how a board ends up with
    /// two notions of its own default.
    pub category_map: IndexMap<String, String>,
    /// Columns hidden from the rendered board. Must be a subset of `columns`;
    /// their cards stay in board state and remain reachable via filters/search.
    pub hidden_columns: Vec<String>,
    /// Whether the computed "Mismatched" pseudo-column is rendered. Default
    /// `false`. Never a configured column.
    pub show_mismatch: bool,
    /// Whether long card titles wrap onto multiple lines. Default `true`.
    pub wrap_titles: bool,
    /// The maximum number of lines a wrapped card may occupy. Default `3`;
    /// validated `>= 1` in [`validate_board`]. Ignored when `wrap_titles`
    /// is `false`.
    pub title_lines: usize,
    /// Sparse manual card order persisted by drag-and-drop reordering. When
    /// non-empty, this order takes precedence over [`order_by`] for the cards
    /// whose positions differ from the automatic ordering.
    pub card_order: Vec<String>,
    /// Ordering of cards within each rendered column. Default
    /// `created_at_desc` (most recently created first).
    pub order_by: OrderBy,
    /// Per-column work-in-progress limits and the estimate units they count in
    /// (mission `configurable-wip-limits-01M3C6WX`). Absent by default and omitted
    /// when empty, so a board that configures no limits gains no key on save — and
    /// **no status semantics change**: the columns, their categories and the done
    /// column mean exactly what they meant before (C-002).
    pub wip: crate::wip::WipConfig,
}

impl Default for BoardConfiguration {
    fn default() -> Self {
        Self {
            columns: ["todo", "in progress", "in review", "done"]
                .into_iter()
                .map(String::from)
                .collect(),
            done_column: Some(DEFAULT_DONE_COLUMN.to_string()),
            category_map: IndexMap::new(),
            hidden_columns: Vec::new(),
            show_mismatch: false,
            wrap_titles: true,
            title_lines: DEFAULT_TITLE_LINES,
            card_order: Vec::new(),
            order_by: OrderBy::default(),
            wip: crate::wip::WipConfig::default(),
        }
    }
}

impl Serialize for BoardConfiguration {
    /// Serialize a board as a `[kanban.<name>]` table, omitting **every field
    /// that already equals the value the loader would supply** (#1415).
    ///
    /// `columns` is always written — a board without columns is not a board —
    /// and `category_map`/`card_order` were already omitted when empty. The
    /// display and ordering keys (`hidden_columns`, `show_mismatch`,
    /// `wrap_titles`, `title_lines`, `order_by`) are omitted at their defaults,
    /// and `done_column` when it already equals [`DEFAULT_DONE_COLUMN`] — the
    /// value the loader fills in when the key is absent, since the container is
    /// `#[serde(default)]`. `Deserialize` stays derived, so the round trip is a
    /// fixed point: omitting a default writes nothing and loading a missing key
    /// restores the same value.
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let done = self
            .done_column
            .as_deref()
            .filter(|done| *done != DEFAULT_DONE_COLUMN);
        let order_by = (self.order_by != OrderBy::default()).then_some(self.order_by);

        let mut len = 1; // `columns` is always written.
        len += usize::from(done.is_some());
        len += usize::from(!self.category_map.is_empty());
        len += usize::from(!self.hidden_columns.is_empty());
        len += usize::from(self.show_mismatch);
        len += usize::from(!self.wrap_titles);
        len += usize::from(self.title_lines != DEFAULT_TITLE_LINES);
        len += usize::from(!self.card_order.is_empty());
        len += usize::from(order_by.is_some());
        len += usize::from(!self.wip.is_empty());

        let mut board = serializer.serialize_struct("BoardConfiguration", len)?;
        board.serialize_field("columns", &self.columns)?;
        if let Some(done) = done {
            board.serialize_field("done_column", done)?;
        }
        if !self.category_map.is_empty() {
            board.serialize_field("category_map", &self.category_map)?;
        }
        if !self.hidden_columns.is_empty() {
            board.serialize_field("hidden_columns", &self.hidden_columns)?;
        }
        if self.show_mismatch {
            board.serialize_field("show_mismatch", &self.show_mismatch)?;
        }
        if !self.wrap_titles {
            board.serialize_field("wrap_titles", &self.wrap_titles)?;
        }
        if self.title_lines != DEFAULT_TITLE_LINES {
            board.serialize_field("title_lines", &self.title_lines)?;
        }
        if !self.card_order.is_empty() {
            board.serialize_field("card_order", &self.card_order)?;
        }
        if let Some(order_by) = order_by {
            board.serialize_field("order_by", &order_by)?;
        }
        if !self.wip.is_empty() {
            board.serialize_field("wip", &self.wip)?;
        }
        board.end()
    }
}

/// The board a **new repository** starts with (`tix init`), rather than the bare
/// [`BoardConfiguration::default`] a config-less or partial file falls back to.
///
/// It carries the two columns the triage inbox needs: `triage` (where unreviewed work
/// lands — `[triage].entry_status`) and `wontfix` (where a decline goes; it has to
/// exist, because `declined_status` must resolve to the `canceled` category).
///
/// **Only those two are mapped**, and that is deliberate. The map is what makes a
/// board `explicit_policy`, and an explicit policy brings *adjacency*: a category may
/// move to itself or forward through the declared order, never backwards. Mapping the
/// working columns would have to call them `unstarted` or `active`, and then
/// `in progress -> todo` — a move people make every day when a review finds a problem
/// — would be refused outright, with no `--reason` to override it. Leaving them
/// unmapped resolves them all to the same safe default, so movement among them stays
/// free while `done`/`wontfix` are terminal, which is the discipline a new repository
/// can *opt into* later (`tix lint` names the unmapped columns and how to map them).
pub fn starter_board() -> BoardConfiguration {
    BoardConfiguration {
        columns: [
            "triage",
            "todo",
            "in progress",
            "in review",
            "done",
            "wontfix",
        ]
        .into_iter()
        .map(String::from)
        .collect(),
        category_map: IndexMap::from([
            ("done".to_string(), "completed".to_string()),
            ("wontfix".to_string(), "canceled".to_string()),
        ]),
        ..BoardConfiguration::default()
    }
}

impl BoardConfiguration {
    /// The category `column` resolves to, given the global configuration.
    ///
    /// The single place the fallback lives, and the seam WP02's service builds
    /// on. Three cases, in order:
    ///
    /// 1. An explicit `category_map` entry wins.
    /// 2. Otherwise, on a board with **no** map at all, `done_column` is
    ///    `completed` and every other column is the safe default.
    /// 3. Otherwise (a map exists but omits this column), the safe default.
    ///
    /// Case 2 is a reconciliation, and the contract's two rules collide without
    /// it: *"a board without a `category_map` inherits `[categories].default` for
    /// every column"* (§2) plus *"no column maps to `completed` is a configuration
    /// error"* (§3) would reject every legacy `tix.toml`, while §2 also requires
    /// that one to load unchanged (C-007). `done_column` is the resolution: it is
    /// always a configured column, it already means "this column completes work",
    /// and using it keeps a legacy board's completion behaviour exactly what it
    /// was. A board that *does* declare a map gets no such help — a map that
    /// forgot `completed` is a real mistake and is reported as one.
    pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
        if let Some(mapped) = self.category_map.get(column) {
            return mapped;
        }
        if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
            return COMPLETE_CATEGORY;
        }
        &categories.default
    }

    /// The resolved done column: the explicit `done_column`, else the one
    /// [`BoardConfiguration::inferred_done_column`] derives from `columns`.
    pub fn effective_done_column(&self) -> Option<&str> {
        self.done_column
            .as_deref()
            .or_else(|| self.inferred_done_column())
    }

    /// The `done_column` inferred from `columns` alone, for a board built in
    /// code rather than read from a file: `"done"` when that name is present,
    /// else the last configured column. `None` only for empty `columns` (which
    /// validation rejects).
    ///
    /// Split out of [`BoardConfiguration::effective_done_column`] so the
    /// inference lives in one place.
    fn inferred_done_column(&self) -> Option<&str> {
        if self.columns.iter().any(|c| c == DEFAULT_DONE_COLUMN) {
            Some(DEFAULT_DONE_COLUMN)
        } else {
            self.columns.last().map(String::as_str)
        }
    }
}

/// The `[kanban]` section: named configurations in declaration order.
///
/// `#[serde(transparent)]` maps the TOML table `[kanban.<name>]` straight
/// onto the [`IndexMap`]. The `toml` parser sorts table keys, so
/// [`Config::load_with_warning`] re-orders the map from the source
/// document's `[kanban.<name>]` header order via
/// [`restore_declaration_order`] (D-02: declaration order = tab order,
/// validated by the round-trip test). The map is never empty after load —
/// [`Config::load_with_warning`] synthesizes the shipped `default`
/// configuration when `tix.toml` omits the section.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, transparent)]
pub struct KanbanSection {
    /// Named configurations, keyed by name, in declaration order.
    pub configurations: IndexMap<String, BoardConfiguration>,
}

impl KanbanSection {
    /// Whether no board is configured. An empty section writes no `[kanban]`
    /// table, which is how a board-file project keeps its `tix.toml` free of
    /// board records.
    pub fn is_empty(&self) -> bool {
        self.configurations.is_empty()
    }
}

/// The `[beads]` section: how this repository talks to a beads workspace.
///
/// The `binary` and `id_prefix` are stated rather than inferred so a workspace
/// that renames its `bd` binary, or wants its exported rows under a different
/// prefix, can say so without patching tix.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BeadsConfig {
    /// Whether tix talks to a beads workspace at all, and how.
    pub mode: BeadsMode,
    /// The binary shelled out to. Default `bd`.
    pub binary: String,
    /// The beads workspace to talk to, when it is not the one `bd` would find.
    ///
    /// Set into `BEADS_DIR` for every `bd` call, which is beads' own override.
    /// `None` means "wherever `bd` looks", which is what a beads workspace in
    /// this repository (or in a parent) needs.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub workspace: Option<PathBuf>,
    /// The prefix exported rows carry: issue `#1705` becomes `tix-1705`.
    ///
    /// Deterministic, which is what makes a re-export upsert rather than
    /// duplicate.
    pub id_prefix: String,
}

impl Default for BeadsConfig {
    fn default() -> Self {
        BeadsConfig {
            mode: BeadsMode::default(),
            binary: crate::sync::beads::DEFAULT_BINARY.to_string(),
            workspace: None,
            id_prefix: crate::sync::beads::DEFAULT_ID_PREFIX.to_string(),
        }
    }
}

impl BeadsConfig {
    /// Whether this section carries nothing worth writing: the default section is
    /// omitted, so a repository that never opted in gains no `[beads]` key on save
    /// — and an older binary (whose `Config` is `deny_unknown_fields`) can still
    /// read the file.
    #[must_use]
    pub fn is_default(&self) -> bool {
        *self == BeadsConfig::default()
    }
}

/// How tix talks to a beads workspace ([`BeadsConfig::mode`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BeadsMode {
    /// No beads interaction. The default, and what a repository without beads
    /// carries.
    #[default]
    Off,
    /// Read a beads workspace through `bd` for viewing only; tix writes nothing —
    /// neither to beads nor to an issue file.
    Mirror,
    /// Keep tix and beads in step through two one-way pipes
    /// (`tix` → `bd import`, and beads' own hooks → `tix beads pull`), joined by a
    /// watermark so neither side echoes the other's change back.
    Sync,
}

impl BeadsMode {
    /// The `--beads` spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            BeadsMode::Off => "off",
            BeadsMode::Mirror => "mirror",
            BeadsMode::Sync => "sync",
        }
    }

    /// Parse the `--beads` spelling, or `None` for anything outside the vocabulary
    /// (which the caller reports rather than coercing).
    #[must_use]
    pub fn from_key(key: &str) -> Option<Self> {
        match key {
            "off" => Some(BeadsMode::Off),
            "mirror" => Some(BeadsMode::Mirror),
            "sync" => Some(BeadsMode::Sync),
            _ => None,
        }
    }

    /// Every mode, in the vocabulary's order.
    pub const ALL: [BeadsMode; 3] = [BeadsMode::Off, BeadsMode::Mirror, BeadsMode::Sync];
}

/// The `[custom_attributes]` section: top-level frontmatter keys this project
/// carries on purpose (#1361).
///
/// A **section** rather than a bare top-level array so there is somewhere to put
/// the next option, and so the declaration reads as a named decision rather than as
/// one more anonymous list in a file that is mostly named sections.
///
/// ```toml
/// [custom_attributes]
/// keys = ["build_id", "reviewed_by"]
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CustomAttributes {
    /// Top-level frontmatter key names. Names only — no dotted paths; the thing
    /// being declared is a top-level key, and a path syntax here would imply nested
    /// declarations are checked when they are not.
    pub keys: Vec<String>,
}

impl CustomAttributes {
    /// Whether anything is declared, for `skip_serializing_if`.
    pub fn is_empty(&self) -> bool {
        self.keys.is_empty()
    }
}

/// The default issue types: what `tix init` writes into `types`, and what
/// `tix lint` accepts when a repository carries no `types`.
///
/// `default` is the value an issue without a `type:` falls back to (in `[layout]`
/// and as an explicit option). `bug`, `open` and `duplicate` are the requested
/// baseline, with the usual companions.
pub const DEFAULT_TYPES: &[&str] = &[
    "default",
    "bug",
    "feature",
    "task",
    "improvement",
    "documentation",
    "question",
    "duplicate",
    "chore",
    "open",
];

/// Labels tix itself ships and always recognises — no configuration required.
///
/// `decision-pending` is the label behind the `DECISION:` convention: an open
/// question a human must settle before the issue can move. It is a **builtin**,
/// not a config default, so every repository resolves it whether or not its
/// `tix.toml` names it — and `tix init` does not write a `labels` key for it (a
/// new key would be rejected by an older binary's `deny_unknown_fields`).
pub const BUILTIN_LABELS: &[&str] = &["decision-pending"];

/// A shipped accent color per default type (tix #1530).
///
/// The color names are the ones [`crate::ui::color::parse`] understands. A type
/// absent here — and absent from `[type_colors]` — renders uncolored. `open` and
/// `default` are deliberately neutral (gray/white) so a type chip does not
/// compete with the priority and blocked markers beside it.
pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
    ("default", "gray"),
    ("bug", "red"),
    ("feature", "green"),
    ("task", "blue"),
    ("improvement", "cyan"),
    ("documentation", "yellow"),
    ("question", "magenta"),
    ("duplicate", "dark_gray"),
    ("chore", "dark_gray"),
    ("open", "white"),
];

/// The `[mentions]` section: durable `@handle` / `@email` mention tracking.
///
/// Default **enabled**, unlike the opt-in `[triage]` section: the feature is a scan that runs
/// on an issue write that already happened, and its footprint is bounded on the other side —
/// a mention file is created lazily, only once a real mention is found, and a bare `user@host`
/// or a `@word` inside code is not a mention. Setting `enabled = false` is the off switch, and
/// it stops both the scan and every write.
///
/// Omitted from the file when it is the default, so a `tix.toml` that has never configured
/// mentions round-trips byte-identically and gains no key on save.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MentionsConfig {
    /// Whether issue writes scan for mentions and record them.
    pub enabled: bool,
    /// Directory holding the per-target mention files, relative to the project root unless
    /// absolute. `None` is not a state: the default is [`DEFAULT_MENTIONS_DIR`].
    pub dir: PathBuf,
}

/// The directory mention files live in, relative to the project root.
pub const DEFAULT_MENTIONS_DIR: &str = "docs/mentions";

impl Default for MentionsConfig {
    fn default() -> Self {
        MentionsConfig {
            enabled: true,
            dir: PathBuf::from(DEFAULT_MENTIONS_DIR),
        }
    }
}

impl MentionsConfig {
    /// Whether this is the default, for `skip_serializing_if`.
    #[must_use]
    pub fn is_default(&self) -> bool {
        *self == Self::default()
    }

    /// The absolute directory the store reads and writes.
    ///
    /// A relative `dir` resolves against the **project root** — the directory holding
    /// `tix.toml` — not the invocation directory, the same rule `issues_dir` follows
    /// ([`crate::storage::Store::open`]).
    #[must_use]
    pub fn resolve_dir(&self, issues_dir: &Path) -> PathBuf {
        if self.dir.is_absolute() {
            return self.dir.clone();
        }
        match Config::project_root(issues_dir) {
            Some(root) => root.join(&self.dir),
            None => issues_dir.parent().unwrap_or(issues_dir).join(&self.dir),
        }
    }
}

/// The `[layout]` section: configurable on-disk issue layout.
///
/// Tokens are validated at config load ([`crate::layout::validate`]). The
/// defaults reproduce the documented layout — `{assignee}/{type}/{status}` for
/// the directory, `{date}-{slug}.md` for the filename, moving files when a
/// templated value changes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LayoutConfig {
    /// Directory template, relative to `issues_dir`.
    pub directory: String,
    /// Filename template; must end in `.md`.
    pub filename: String,
    /// Relocate an issue when a templated value changes (status/assignee/type).
    pub move_on_change: bool,
}

impl Default for LayoutConfig {
    fn default() -> Self {
        LayoutConfig {
            directory: "{assignee}/{type}/{status}".to_string(),
            // Date first, then the id: a lexicographic listing of the directory
            // is then creation order, while the id keeps the name globally
            // unique across branches (and disambiguates same-day, same-title
            // issues). `number` is deliberately absent — it can collide across
            // branches and is not an identity.
            filename: "{date}-{id}-{slug}.md".to_string(),
            move_on_change: true,
        }
    }
}

impl LayoutConfig {
    /// Whether this is the default, for `skip_serializing_if`.
    #[must_use]
    pub fn is_default(&self) -> bool {
        *self == Self::default()
    }
}