tix-tracker 0.11.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
//! `tix.toml` config schema, defaults, loading, saving and validation.
//!
//! Mirrors `contracts/tix-toml.md` and `data-model.md` §Config. The
//! [`Config`] type is the Serde surface for `tix.toml`; [`StateConfig`]
//! carries the legacy `[state].next_number` counter, which is tolerated on
//! load and never written back out (issue #52 — the issue number is
//! resolved at creation time by scanning the live issue set). Loading emits
//! a warning whenever a cleartext token is read from the file, and
//! [`Config::save`] never serializes tokens back to disk.

use std::collections::HashSet;
use std::hash::{BuildHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use indexmap::IndexMap;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize, Serializer};

use crate::board_store::BoardStore;
use crate::capacity::config::CapacityConfig;
use crate::cycle_store::{CycleStore, is_implicit_default};
use crate::error::{Result, TixError};
use crate::keybindings::{EffectiveBindings, KeyBindings};
use crate::records::RecordStore;
use crate::templates::registry::TemplateRegistry;

mod ids;
mod io;
mod models;
mod validate;

pub use ids::*;
use io::allocate_stable_id;
pub(crate) use io::restore_section_order;
pub use models::*;
pub(crate) use validate::*;

/// The rules every `tix.toml` registry key obeys.
///
/// `[projects.<project-id>]`, `[epics.<epic-id>]` and
/// `[initiatives.<initiative-id>]` are keyed by a **stable generated identifier**,
/// and the rules that keep such a key stable must not drift between the registries:
/// never blank, never padded, bounded in length, free of the TOML-structural
/// characters a table header cannot carry, and drawn from a shape that cannot hand
/// the same value out twice. Only the prefix and the words used in an error differ,
/// so those are the associated constants and everything else lives here once.
///
/// Hand-written ids are accepted when they pass [`StableId::new`], so a readable
/// handle such as `p-platform` and the generated shape coexist.
pub trait StableId: Sized {
    /// The generated prefix: `p-` for projects, `e-` for epics, `i-` for initiatives,
    /// `cs-` for cycle schedules.
    const PREFIX: &'static str;
    /// The human label used in messages, e.g. `project id`.
    const LABEL: &'static str;
    /// The indefinite article for [`Self::LABEL`]: `a` for a project, `an` for an epic.
    const ARTICLE: &'static str;
    /// The longest id a registry accepts. Long enough for a readable handle, short
    /// enough that a typo cannot turn into a paragraph in a table header.
    const MAX_LEN: usize = 128;

    /// Wrap an id that has already passed [`StableId::new`].
    fn from_validated(raw: String) -> Self;

    /// The id as written.
    fn as_str(&self) -> &str;

    /// Validate an explicit id.
    ///
    /// Ids are chosen by a caller (an `--project-id`/`--epic-id` flag) or generated.
    /// Either way the same rules apply, because the id ends up as a TOML table
    /// header: [`restore_section_order`] reads `[<section>.<id>]`, and an id
    /// containing `.` or a bracket would read as a nested table or a malformed
    /// document.
    fn new(raw: &str) -> Result<Self> {
        if raw.trim().is_empty() {
            return Err(TixError::Config(format!(
                "{} {} must not be blank",
                Self::ARTICLE,
                Self::LABEL
            )));
        }
        if raw.trim() != raw {
            return Err(TixError::Config(format!(
                "{} `{raw}` has leading or trailing whitespace",
                Self::LABEL
            )));
        }
        if raw.chars().count() > Self::MAX_LEN {
            return Err(TixError::Config(format!(
                "{} is {} characters; the limit is {}",
                Self::LABEL,
                raw.chars().count(),
                Self::MAX_LEN
            )));
        }
        if let Some(bad) = raw.chars().find(|c| is_structural_key_char(*c)) {
            return Err(TixError::Config(format!(
                "{} `{raw}` contains `{bad}`; use letters, digits, `-`, `_`, or `:`",
                Self::LABEL
            )));
        }
        Ok(Self::from_validated(raw.to_string()))
    }

    /// Allocate a new id: 64 mixed bits from the process hasher, [`Self::PREFIX`]-prefixed.
    ///
    /// No dependency and no counter stored in `tix.toml`, so there is no allocator
    /// whose state a deletion could reset. The never-reused guarantee rests on the
    /// **id shape** rather than on a persisted high-water mark: the value is drawn
    /// from 2^64 with the process id, a process-monotonic counter, and the wall
    /// clock mixed in, so two draws in one process differ with overwhelming
    /// probability (~2^-64 for a SipHash collision), and a re-draw after a deletion
    /// is as unlikely to repeat a previously handed-out value as any fresh draw is
    /// to collide. On top of that, [`Config::allocate_project_id`],
    /// [`Config::allocate_epic_id`] and [`Config::allocate_initiative_id`] redraw
    /// against the ids already stored, so a *stored* id is never handed out a second
    /// time — that half is exact, not probabilistic.
    #[must_use]
    fn generate() -> Self {
        static COUNTER: AtomicU64 = AtomicU64::new(0);

        let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
        std::process::id().hash(&mut hasher);
        COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_or(0, |since| since.as_nanos())
            .hash(&mut hasher);
        let bits = hasher.finish();
        // The prefix plus 16 hex digits is a valid id by construction, so the private
        // wrapper is used directly rather than re-validating the format.
        Self::from_validated(format!("{}{bits:016x}", Self::PREFIX))
    }
}

impl From<ProjectId> for String {
    fn from(id: ProjectId) -> Self {
        id.0
    }
}

impl From<EpicId> for String {
    fn from(id: EpicId) -> Self {
        id.0
    }
}

impl From<InitiativeId> for String {
    fn from(id: InitiativeId) -> Self {
        id.0
    }
}

/// The project configuration file (`tix.toml`).
///
/// Not `Eq`, unlike each of the sections it holds: [`Config::views`] preserves
/// unknown view metadata as `toml::Value`, whose float variant includes `nan`, so
/// the reflexive equality `Eq` promises cannot be guaranteed for a config that
/// carries one. `PartialEq` is what every comparison in the tree needs — `save`
/// then `load` round-trip tests included.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    /// Named board configurations (`[kanban]`). Always ≥1 after load.
    #[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
    pub kanban: KanbanSection,
    /// Directory holding issue files, relative to the repo root.
    pub issues_dir: PathBuf,
    /// Directory holding milestone markdown files, relative to the repo
    /// root. `None` → the default `docs/milestones`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub milestones_dir: Option<PathBuf>,
    /// Named teams (`[teams.<label>]`), in declaration order.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub teams: IndexMap<String, Team>,
    /// Named cycles (`[cycles.<name>]`), in declaration order.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub cycles: IndexMap<String, PlanningCycle>,
    /// Named new-issue templates (`[templates.<name>]`), in declaration order.
    ///
    /// Optional and absent by default: a project with no templates writes no
    /// section, and the F4 New Issue mode simply offers none.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub templates: IndexMap<String, IssueTemplate>,
    /// Saved view definitions (`[views.<view-id>]`), in declaration order.
    ///
    /// Mission `saved-issue-views-01M3C926` (WP01). Omitted when empty, so a
    /// repository that has never saved a view gains no key on save. The records
    /// are [`crate::views::registry::SavedView`]s: a stable id, a mutable display
    /// name, explicit scope metadata, the query plan, and preserved extension
    /// fields. Every write goes through
    /// [`crate::views::registry::ViewRegistry::commit`].
    #[serde(
        default,
        skip_serializing_if = "crate::views::registry::ViewRegistry::is_empty"
    )]
    pub views: crate::views::registry::ViewRegistry,
    /// Health policy configuration (`[health]`). Omitted when absent.
    #[serde(default, skip_serializing_if = "HealthConfig::is_empty")]
    pub health: HealthConfig,
    /// Stable-ID issue/form templates (`[issue_templates.<id>]`), in
    /// declaration order.
    ///
    /// As distinct from [`Self::templates`] as an ID is from a name: this is the
    /// registry behind `tix template` and `tix create --template`, keyed by an
    /// immutable ID with an explicit repository/board/team scope, typed defaults
    /// and prompts, typed references, and preserved unknown metadata
    /// ([`crate::templates::registry`]). The legacy name-keyed `templates` above
    /// only pre-fills the TUI New Issue form. Omitted when empty, so a project
    /// with no stable-ID templates gains no section and a legacy file
    /// round-trips byte-identically.
    #[serde(default, skip_serializing_if = "TemplateRegistry::is_empty")]
    pub issue_templates: TemplateRegistry,
    /// Planning presets and the auto-cycle counter (`[planning]`).
    #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
    pub planning: PlanningConfig,
    /// `[categories]`: the global vocabulary and safe default. Omitted when
    /// untouched, so a legacy file gains no key on save.
    #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
    pub categories: CategoryConfig,
    /// `[transitions]`: the workflow policy. Omitted when untouched.
    #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
    pub transitions: TransitionConfig,
    /// `[triage]`: the normal columns that make up the triage inbox.
    ///
    /// Mission `triage-inbox-review-flow-01M3C600`. The inbox is opt-in: `None` means
    /// no section, so no inbox, and every `tix triage` command refuses by naming the
    /// missing section. **`tix init` writes one**, with the board columns it needs, so
    /// a new repository starts with the inbox on; a legacy `tix.toml` gains no key on
    /// save. See [`crate::triage::config`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub triage: Option<crate::triage::TriageConfig>,
    /// Optional `$EDITOR` override for full-file editing.
    pub editor: Option<String>,
    /// Read-only viewer for the TUI's `v` key (tix #1507).
    ///
    /// `None` and `Some("auto")` both mean the built-in probe chain — `mdterm`,
    /// then `batcat`, `most`, `less`, `more` — and every other value names the
    /// program to run directly. Never written when unset, so a legacy file gains
    /// no key on save.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub viewer: Option<String>,
    /// Configured AI agent command (opencode, claude, etc.) launched with
    /// the issue body as its specification. Mirrors the `editor` resolution
    /// chain (explicit → `$TIX_AGENT` → `$AGENT` env → PATH fallback).
    pub agent: Option<String>,
    /// Persisted runtime state (`[state]`). Tolerated on load for legacy
    /// files, never written back to disk (issue #52).
    #[serde(skip_serializing)]
    pub state: StateConfig,
    /// External tracker targets (`[export]`). Omitted when empty, so a legacy
    /// file gains no section on save (#1415).
    #[serde(default, skip_serializing_if = "ExportConfig::is_empty")]
    pub export: ExportConfig,
    /// Sparse TUI keybinding overrides (`[keybindings]`).
    #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
    pub keybindings: KeyBindings,
    /// Top-level frontmatter keys this project uses on purpose (`[custom_attributes]`).
    ///
    /// #1361. `tix` preserves a frontmatter key it does not recognise and warns
    /// about it, because an unrecognised key is usually a typo (`titel:`) and a
    /// typo that round-trips silently is worse than one that is dropped. A project
    /// that *does* use a custom key declares it here once and stops being warned.
    ///
    /// **Top-level names only**, not dotted paths — the thing being declared is a
    /// top-level key, and accepting a path syntax here would imply nested
    /// declarations are checked, which they are not.
    #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
    pub custom_attributes: CustomAttributes,
    /// Whether `tix lint` also lints the foreign repositories reachable from
    /// this store's `related_issues` path references and merges their warnings.
    ///
    /// Default `true`. Skipped when `true` so a `tix.toml` that does not set it
    /// round-trips byte-identically; only an explicit `false` is written.
    #[serde(default = "default_true", skip_serializing_if = "is_true")]
    pub lint_foreign_repositories: bool,
    /// Whether the TUI opens its "did you know?" tips modal on startup (tix #1632).
    ///
    /// Default `true`: a session that reaches the TUI shows one tip, then gets out
    /// of the way. `tix init` writes the key explicitly, so the setting is
    /// discoverable where the rest of the configuration lives, and the modal's `d`
    /// key writes `false` back. Skipped when `true` for the same reason
    /// [`Self::lint_foreign_repositories`] is, so a file that does not mention it
    /// round-trips byte-identically.
    #[serde(default = "default_true", skip_serializing_if = "is_true")]
    pub tips_on_startup: bool,
    /// `[mentions]`: durable `@handle` / `@email` mention tracking.
    ///
    /// OpenSpec change `issue-mentions`. Absent means the defaults — enabled, under
    /// `docs/mentions` — so no legacy file gains a key on save.
    #[serde(default, skip_serializing_if = "MentionsConfig::is_default")]
    pub mentions: MentionsConfig,
    /// `[layout]`: configurable on-disk issue layout.
    #[serde(default, skip_serializing_if = "LayoutConfig::is_default")]
    pub layout: LayoutConfig,
    /// Allowed issue `type:` values (tix #1530 renamed the key from
    /// `custom_types`; the old name is still accepted on read).
    ///
    /// `tix init` writes the default set (see [`DEFAULT_TYPES`]); `tix lint`
    /// accepts an issue's `type:` only from this list. `None` falls back to
    /// [`DEFAULT_TYPES`], so a repository that never configured it still gets a
    /// meaningful check.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        alias = "custom_types"
    )]
    pub types: Option<Vec<String>>,
    /// Extra known issue `labels` for this project, beyond the builtins.
    ///
    /// [`BUILTIN_LABELS`] are always resolved (see [`Self::labels`]), so this key
    /// is an **optional extension**: a project that wants to name its own
    /// additional labels lists them here. `tix init` does **not** write the key —
    /// a builtin needs no key, and emitting a brand-new key would make a fresh
    /// `tix.toml` unreadable to a released binary (`deny_unknown_fields`).
    /// Omitted when unset, so a legacy file gains no key on save.
    ///
    /// Unlike [`Self::types`], `tix lint` does **not** reject an undeclared
    /// label: labels are free-form across the ecosystem (tracker mirrors, import
    /// provenance), and a hard allow-list would flag every one of them.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub labels: Option<Vec<String>>,
    /// Per-type accent colors (`[type_colors]`, tix #1530).
    ///
    /// Maps a type name to a color name. A type with no entry uses the shipped
    /// [`DEFAULT_TYPE_COLORS`]. Omitted when empty, so an untouched file gains
    /// no table on save.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub type_colors: IndexMap<String, String>,
    /// Directory holding tag definition files (`tags_dir`, tix #1530).
    ///
    /// Each `*.md` there names a tag and its `color:`; a tag colors the issue
    /// label of the same name in the TUI and the CLI. `None` → the default
    /// `docs/tags`. Absent by default, so a file gains no key on save.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tags_dir: Option<PathBuf>,
    /// Child repositories this project coordinates (`[repos]`, tix #1529).
    ///
    /// A **parent-only** map: a project-management repository names the
    /// repositories it manages, one level deep, and each value is that child's
    /// project root (the directory holding its `tix.toml`), relative to this
    /// file. Absent by default, so a file gains no section on save. The TUI's
    /// Repos mode lists these and runs `tix` inside the chosen one.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub repos: IndexMap<String, PathBuf>,
    /// The parent repository, when this project is itself a managed child
    /// (tix #1529).
    ///
    /// Set here, the TUI's Repos mode offers a limb that runs `tix` in the
    /// parent. The parent also sets `TIX_PARENT_REPO` when it spawns a child, so
    /// the jump back works even without this key.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_repo: Option<PathBuf>,
    /// Project records (`[projects.<project-id>]`), in declaration order.
    ///
    /// Mission `first-class-projects-01M3BYBW` (WP01). The map key is a stable
    /// generated [`ProjectId`] and never a display name (C-002), so renaming a
    /// project leaves every issue association and derived figure intact. Omitted
    /// entirely when empty, so a project-free repository gains no section on save
    /// and a legacy `tix.toml` round-trips byte-identically (C-001). Issue-side
    /// associations are the `projects` list in issue frontmatter; this registry is
    /// the only place a project's own record lives.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub projects: IndexMap<String, ProjectConfiguration>,
    /// Epic records (`[epics.<epic-id>]`), in declaration order.
    ///
    /// Mission `epics-work-items-01M3BYW4` (WP01). The registry key is a stable
    /// generated [`EpicId`] and never a display name (C-003), so renaming an epic
    /// leaves every member issue's reference and every derived figure intact.
    /// Omitted entirely when empty, so an epic-free repository gains no section on
    /// save and a legacy `tix.toml` round-trips byte-identically (C-004). Member
    /// issues point at an epic through the `epics` list in their frontmatter; this
    /// registry is the only place an epic's own record lives. Reference fields
    /// (`parent`, `projects`, `milestone`, `cycle`) are **shape**-checked only —
    /// resolution and diagnosis belong to the epic service and `tix lint` (C-005).
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub epics: IndexMap<String, EpicConfiguration>,
    /// Initiative records (`[initiatives.<initiative-id>]`), in declaration order.
    ///
    /// Mission `roadmap-initiatives-01M3C8SX` (WP01). The registry key is a stable
    /// generated [`InitiativeId`] and never a display name (C-002), so renaming an
    /// initiative leaves every association and derived figure intact. Omitted
    /// entirely when empty, so an initiative-free repository gains no section on save
    /// and a legacy `tix.toml` round-trips byte-identically (C-001). All writes go
    /// through the atomic boundary in [`crate::roadmap::registry`], which validates
    /// the whole change before [`Config::save`] touches the file; reference fields
    /// (`projects`, `epics`, `milestones`) are **shape**-checked only — resolution and
    /// diagnosis belong to the initiative service (WP03) and `tix lint` (C-005).
    ///
    /// Unlike its two siblings this record preserves unknown metadata rather than
    /// refusing it; see [`InitiativeConfiguration`].
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub initiatives: IndexMap<String, InitiativeConfiguration>,
    /// Cycle-schedule definitions (`[cycle_schedules.<schedule-id>]`), in
    /// declaration order.
    ///
    /// Mission `repeating-cycle-schedules-01M3C7W1` (WP01). A **schedule** is not a
    /// **cycle**: the legacy, name-keyed `[cycles.<name>]` registry above stays
    /// exactly as it is, and issue `cycles` references keep pointing at it (C-002).
    /// A schedule is the stable-ID *definition* — cadence, duration, start date,
    /// timezone, look-ahead, state — from which cycles are explicitly generated
    /// (WP04).
    ///
    /// The registry key is a stable generated [`crate::cycle_schedule::config::CycleScheduleId`]
    /// and never the display name (C-003), so a rename leaves every generated cycle,
    /// cursor and closed snapshot intact. Omitted entirely when empty, so a
    /// schedule-free repository gains no section on save and a legacy `tix.toml`
    /// round-trips byte-identically (C-001). Every field is validated by
    /// [`crate::cycle_schedule::config::validate_schedules`] before persistence, with
    /// a diagnostic that names the field (C-005).
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub cycle_schedules:
        IndexMap<String, crate::cycle_schedule::config::CycleScheduleConfiguration>,
    /// Capacity ceilings (`[capacity.<entry-id>]`), in declaration order.
    ///
    /// Mission `cycle-capacity-planning-01M3C85C` (WP01). Each entry names exactly one
    /// scope — a cycle, a team, or an assignee — and the ceiling it is allowed to commit
    /// to, in an estimate unit the WIP registry already declares (C-003, C-004). The
    /// entry's key is only a handle: the scope is a field of the record, so "which
    /// dimension is this?" is always stated rather than inferred from a table's shape,
    /// and [`crate::capacity::config::validate`] refuses an entry that names none or
    /// more than one.
    ///
    /// Omitted entirely when empty, so a capacity-free repository gains no section on
    /// save and a legacy `tix.toml` round-trips byte-identically (C-001), and unknown
    /// metadata inside an entry is preserved verbatim for a newer `tix`. A closed
    /// cycle's capacity is historical evidence: this section holds no snapshot state,
    /// and [`crate::capacity::config::ensure_cycle_editable`] is the seam that refuses a
    /// configuration edit aimed at a closed cycle (C-008).
    #[serde(default, skip_serializing_if = "CapacityConfig::is_empty")]
    pub capacity: CapacityConfig,
    /// How this repository talks to a [beads](https://github.com/gastownhall/beads)
    /// workspace (`[beads]`).
    ///
    /// Mission-tracking issues tix #1709/#1710. `off` (the default) writes no
    /// section, so a repository that never opts in is unchanged and round-trips
    /// byte-identically. `mirror` reads a beads workspace through the `bd` CLI
    /// without writing anything anywhere; `sync` keeps both sides in step through
    /// two one-way pipes. Every interaction is a `bd` CLI call — tix never opens
    /// the Dolt database. See [`crate::sync::beads`].
    #[serde(default, skip_serializing_if = "BeadsConfig::is_default")]
    pub beads: BeadsConfig,
}

/// Serde default for a boolean that defaults to `true`.
///
/// Shared with [`crate::capacity::config::CapacityEntry`], whose `enabled` follows the
/// same rule the WIP limits set: writing the default is not writing anything.
pub(crate) fn default_true() -> bool {
    true
}
/// `skip_serializing_if` for a boolean whose default is `true`: only an explicit
/// `false` is written to disk.
#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&bool) -> bool`
pub(crate) fn is_true(value: &bool) -> bool {
    *value
}

impl Default for Config {
    fn default() -> Self {
        Config {
            kanban: KanbanSection {
                configurations: IndexMap::from([(
                    "default".to_string(),
                    BoardConfiguration::default(),
                )]),
            },
            issues_dir: PathBuf::from("docs/issues"),
            milestones_dir: None,
            teams: IndexMap::new(),
            cycles: IndexMap::new(),
            templates: IndexMap::new(),
            views: crate::views::registry::ViewRegistry::new(),
            health: HealthConfig::default(),
            issue_templates: TemplateRegistry::default(),
            planning: PlanningConfig::default(),
            categories: CategoryConfig::default(),
            transitions: TransitionConfig::default(),
            // The bare default has no inbox, and that stays true: the mechanism is
            // opt-in, so a config with no `[triage]` section has no inbox and every
            // `tix triage` command refuses, naming the section. What changed is the
            // *starting point*: `tix init` writes the section (and the board columns it
            // needs), so a new repository begins with an inbox rather than with a
            // refusal. See [`crate::commands::init`].
            triage: None,
            editor: None,
            viewer: None,
            state: StateConfig::default(),
            export: ExportConfig::default(),
            agent: None,
            keybindings: KeyBindings::default(),
            custom_attributes: CustomAttributes::default(),
            lint_foreign_repositories: true,
            tips_on_startup: true,
            mentions: MentionsConfig::default(),
            layout: LayoutConfig::default(),
            types: None,
            labels: None,
            type_colors: IndexMap::new(),
            tags_dir: None,
            repos: IndexMap::new(),
            parent_repo: None,
            projects: IndexMap::new(),
            epics: IndexMap::new(),
            initiatives: IndexMap::new(),
            cycle_schedules: IndexMap::new(),
            capacity: CapacityConfig::new(),
            beads: BeadsConfig::default(),
        }
    }
}

impl Config {
    /// The configuration a **new repository** starts with — what `tix init` writes.
    ///
    /// Deliberately not [`Default`]: that is the fallback for a missing or partial
    /// `tix.toml` (and the base hundreds of tests build fixtures on), and it stays
    /// minimal. The *starting point* is a product decision: a new repository gets the
    /// board [`starter_board`] lays out and a working `[triage]` section, so
    /// `tix triage list` works on a fresh checkout instead of refusing and asking for
    /// configuration first.
    ///
    /// The three keys match the board: unreviewed work sits in `triage`, an accept
    /// lands in `todo`, a decline in `wontfix`. `deferred_status` is left unset
    /// because it defaults to `entry_status`, and a default that is never written is a
    /// default that cannot drift from the meaning it stands for.
    #[must_use]
    pub fn starter() -> Config {
        let mut config = Config::default();
        config
            .kanban
            .configurations
            .insert("default".to_string(), starter_board());
        config.triage = Some(crate::triage::TriageConfig {
            entry_status: Some("triage".to_string()),
            accepted_status: Some("todo".to_string()),
            declined_status: Some("wontfix".to_string()),
            deferred_status: None,
        });
        // `tix init` writes the default issue types explicitly, so a new project
        // sees and can edit them; a repository without the key falls back to the
        // same list at lint time.
        config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
        // `labels` is deliberately NOT written: `decision-pending` is a builtin
        // (`BUILTIN_LABELS`), so no key is needed, and emitting a brand-new key
        // would make a fresh `tix.toml` unreadable to a released binary whose
        // `Config` denies unknown fields.
        // Ship the tag directory explicitly (tix #1530), so a new project has
        // somewhere to drop `docs/tags/*.md` without having to know the key.
        config.tags_dir = Some(PathBuf::from("docs/tags"));
        config
    }

    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
    /// the `Config` error variant with a human message. Errors name the
    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
    pub(crate) fn validate(&self) -> Result<()> {
        if self.issues_dir.as_os_str().is_empty() {
            return Err(TixError::Config("`issues_dir` must not be blank".into()));
        }
        if self.milestones_dir().as_os_str().is_empty() {
            return Err(TixError::Config(
                "`milestones_dir` must not be blank".into(),
            ));
        }
        if self.tags_dir().as_os_str().is_empty() {
            return Err(TixError::Config("`tags_dir` must not be blank".into()));
        }
        validate_repos(&self.repos)?;
        // The health policy version is a closed set, and a repository naming one
        // this build does not implement is refused here rather than at the first
        // rollup: a value derived under a formula nobody can name is worse than a
        // file that will not load.
        self.health.policy()?;
        if self
            .parent_repo
            .as_deref()
            .is_some_and(|p| p.as_os_str().is_empty())
        {
            return Err(TixError::Config("`parent_repo` must not be blank".into()));
        }
        // A blank `viewer` is a mistake with a clear spelling: the way to ask for
        // the probe chain is `auto` (or omitting the key). Rejected here rather
        // than silently treated as auto, so a typo cannot quietly change the
        // meaning of a saved file.
        if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
            return Err(TixError::Config(
                "`viewer` must not be blank; use `auto` for the default chain".into(),
            ));
        }
        validate_planning(&self.planning)?;
        crate::layout::validate(&self.layout)?;
        validate_categories(&self.categories)?;
        self.effective_keybindings()?;
        if self.kanban.configurations.is_empty() {
            return Err(TixError::Config(
                "`[kanban]` must define at least one configuration".into(),
            ));
        }
        for (name, board) in &self.kanban.configurations {
            validate_board(name, board)?;
            validate_category_map(name, board, &self.categories)?;
        }
        for (label, team) in &self.teams {
            validate_team(label, team)?;
        }
        for (name, cycle) in &self.cycles {
            validate_cycle(name, cycle)?;
        }
        for (name, template) in &self.templates {
            validate_template(name, template)?;
        }
        // Project records: a valid stable id as the key, a non-blank unique display
        // name, present-but-blank optional text refused, and the shared date rules.
        // Project *resolution* (does a view/issue reference an existing project?) is
        // not `Config` validation, for the same reason view scopes are not: a
        // reference to a deleted project must stay loadable and diagnosable (C-005).
        validate_projects(&self.projects)?;
        // Epic records: the same structural rules as projects (a valid stable id as
        // the key, a non-blank unique display name, present-but-blank optional text
        // refused, the shared date rules), plus a positive `weight` and the
        // epic-specific reference *shapes*. Epic *resolution* — does a project,
        // milestone, cycle, or parent epic exist? — is deliberately not `Config`
        // validation, for the same reason project resolution is not: a dangling
        // reference caused by a deletion or a hand edit must stay loadable and
        // diagnosable (C-005).
        validate_epics(&self.epics)?;
        // Initiative records: the same structural rules as projects and epics (a
        // valid stable id as the key, a non-blank unique display name, the shared
        // calendar-date rule), plus the initiative-specific reference *shapes* —
        // project/epic ids for `projects`/`epics` and non-blank names for
        // `milestones`. Initiative *resolution* — does a referenced project, epic, or
        // milestone exist? — is deliberately not `Config` validation, for the same
        // reason project/epic resolution is not: a dangling reference caused by a
        // deletion or a hand edit must stay loadable and diagnosable (C-005).
        validate_initiatives(&self.initiatives)?;
        crate::cycle_schedule::config::validate_schedules(&self.cycle_schedules)?;
        // `[capacity]`'s rootless half — the entry handle, exactly one scope, the
        // ceiling, and the *declared unit* — is validated here, because every
        // validate-then-save path (`tix wip unit --remove`, the schedule registry, the MCP
        // write tools) validates with this method before it writes: without the unit check
        // there, removing a unit a ceiling names would persist a file the next load
        // refuses. The half that needs the cycle **records** — resolving a cycle reference
        // to a generated cycle's identity — is validated by the loader
        // (`Config::load_with_warning`) and by `crate::capacity::config::set_capacity`,
        // the only two gates with a project root; see `crate::capacity::config::validate`.
        crate::capacity::config::validate(&self.capacity, self)?;
        // View records are validated for *structure* here: a blank name, a scope
        // that contradicts itself, an id that disagrees with its table key. Project
        // *resolution* is deliberately not part of `Config` validation — it needs
        // the project registry, and a view pointing at a project that was deleted
        // must stay loadable (and diagnosable) rather than making `tix.toml`
        // unreadable. The write path refuses it instead
        // (`ViewRegistry::commit`, C-008).
        self.views
            .check_structure()
            .map_err(|refusal| TixError::Config(refusal.render()))?;
        // Stable-ID templates: scope binding, prompt shape, references and
        // preserved metadata. Field-aware errors name `issue_templates.<id>...`.
        self.issue_templates.validate(self)?;
        // Last, deliberately. `[triage]` names columns from the active board
        // *and* from that board's `category_map`, so it can only be judged once
        // both are; running it earlier would hand `workflow::resolution` an
        // unvalidated vocabulary, which is the `unreachable!` it documents
        // (C-002).
        crate::triage::config::validate(self)?;
        Ok(())
    }

    /// Resolve sparse user overrides over the built-in TUI registry.
    pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
        KeyBindings::resolve(&self.keybindings)
    }

    /// Allocate a [`ProjectId`] that is not already in the registry.
    ///
    /// Redraws against the stored ids, so a stored id is never handed out a second
    /// time. The rest of the never-reused guarantee is carried by the id shape
    /// itself (see [`StableId::generate`]); there is no counter persisted in
    /// `tix.toml`, so a deletion cannot reset an allocator — the id a deleted
    /// project carried stays spent because no draw can repeat one this process
    /// already produced.
    #[must_use]
    pub fn allocate_project_id(&self) -> ProjectId {
        self.allocate_project_id_with(ProjectId::generate)
    }

    /// [`Self::allocate_project_id`] with the draw injected, so the redraw loop
    /// itself is testable: a real generator collides so rarely that a test driving
    /// it can only ever observe the first iteration.
    fn allocate_project_id_with(&self, draw: impl FnMut() -> ProjectId) -> ProjectId {
        allocate_stable_id(&self.projects, draw)
    }

    /// Allocate an [`EpicId`] that is not already in the registry.
    ///
    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
    /// ids, so a stored id is never handed out a second time, and the rest of the
    /// never-reused guarantee is carried by the id shape itself
    /// ([`StableId::generate`]) — a deleted epic's id stays spent, keeping a member
    /// issue's dangling reference diagnosable rather than silently rebound (C-005).
    #[must_use]
    pub fn allocate_epic_id(&self) -> EpicId {
        self.allocate_epic_id_with(EpicId::generate)
    }

    /// [`Self::allocate_epic_id`] with the draw injected, so the redraw loop itself
    /// is testable.
    fn allocate_epic_id_with(&self, draw: impl FnMut() -> EpicId) -> EpicId {
        allocate_stable_id(&self.epics, draw)
    }

    /// Allocate an [`InitiativeId`] that is not already in the registry.
    ///
    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
    /// ids, so a stored id is never handed out a second time, and the rest of the
    /// never-reused guarantee is carried by the id shape itself
    /// ([`StableId::generate`]) — a deleted initiative's id stays spent, keeping the
    /// record's identity distinct from any later one.
    #[must_use]
    pub fn allocate_initiative_id(&self) -> InitiativeId {
        self.allocate_initiative_id_with(InitiativeId::generate)
    }

    /// [`Self::allocate_initiative_id`] with the draw injected, so the redraw loop
    /// itself is testable.
    fn allocate_initiative_id_with(&self, draw: impl FnMut() -> InitiativeId) -> InitiativeId {
        allocate_stable_id(&self.initiatives, draw)
    }

    /// Allocate a fresh [`crate::cycle_schedule::config::CycleScheduleId`].
    ///
    /// Mirrors [`Self::allocate_initiative_id`]: the draw is redrawn against the
    /// stored ids, so a stored id is never handed out a second time, and the rest of
    /// the never-reused guarantee is carried by the id shape itself
    /// ([`StableId::generate`]) — a deleted schedule's id stays spent, keeping the
    /// record's identity distinct from any later one (C-003).
    #[must_use]
    pub fn allocate_cycle_schedule_id(&self) -> crate::cycle_schedule::config::CycleScheduleId {
        self.allocate_cycle_schedule_id_with(
            crate::cycle_schedule::config::CycleScheduleId::generate,
        )
    }

    /// [`Self::allocate_cycle_schedule_id`] with the draw injected, so the redraw loop
    /// itself is testable: a real generator collides with a stored id so rarely that
    /// the loop is otherwise unverifiable.
    fn allocate_cycle_schedule_id_with(
        &self,
        draw: impl FnMut() -> crate::cycle_schedule::config::CycleScheduleId,
    ) -> crate::cycle_schedule::config::CycleScheduleId {
        allocate_stable_id(&self.cycle_schedules, draw)
    }

    /// Restore the canonical declaration order of named configurations,
    /// which the TOML parser loses (it sorts table keys). The order comes
    /// from `[kanban.<name>]` section headers in the source document.
    fn restore_declaration_order(&mut self, contents: &str) {
        crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
        crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
        crate::config::restore_section_order(&mut self.teams, contents, "teams");
        crate::config::restore_section_order(&mut self.templates, contents, "templates");
        self.views.restore_declaration_order(contents);
        crate::config::restore_section_order(
            &mut self.issue_templates.templates,
            contents,
            "issue_templates",
        );
        crate::config::restore_section_order(&mut self.projects, contents, "projects");
        crate::config::restore_section_order(&mut self.epics, contents, "epics");
        crate::config::restore_section_order(&mut self.initiatives, contents, "initiatives");
        crate::config::restore_section_order(
            &mut self.cycle_schedules,
            contents,
            "cycle_schedules",
        );
        self.capacity.restore_declaration_order(contents);
    }

    /// Declared custom top-level frontmatter keys, lowercased for comparison.
    ///
    /// Lowercased because YAML keys are case-sensitive but nobody has ever
    /// intended `Titel` and `titel` to be different keys, and a suppression list
    /// that silently fails to match is worse than no list at all.
    pub fn custom_attributes(&self) -> Vec<String> {
        self.custom_attributes
            .keys
            .iter()
            .map(|k| k.trim().to_ascii_lowercase())
            .collect()
    }

    /// The allowed issue types: the configured `types`, or [`DEFAULT_TYPES`]
    /// when unset. Compared case-insensitively by callers.
    #[must_use]
    pub fn types(&self) -> Vec<String> {
        self.types
            .clone()
            .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
    }

    /// The pre-#1530 name for [`Self::types`], kept so existing call sites and
    /// tests read the same list under the old spelling.
    #[must_use]
    pub fn custom_types(&self) -> Vec<String> {
        self.types()
    }

    /// The recognised issue labels: [`BUILTIN_LABELS`], plus any extra labels the
    /// project declares under `labels` in `tix.toml`.
    ///
    /// Builtins come first and are always present, so `decision-pending` resolves
    /// in a repository that never configured the key — which is why `tix init`
    /// does not write one.
    #[must_use]
    pub fn labels(&self) -> Vec<String> {
        let mut out: Vec<String> = BUILTIN_LABELS.iter().map(|s| (*s).to_string()).collect();
        if let Some(extra) = &self.labels {
            for label in extra {
                if !out.contains(label) {
                    out.push(label.clone());
                }
            }
        }
        out
    }

    /// The color name for issue `type`, from `[type_colors]` when set for it, else
    /// the shipped [`DEFAULT_TYPE_COLORS`] entry. `None` for a type with no color
    /// anywhere (an unknown type). Compared case-insensitively (tix #1530).
    #[must_use]
    pub fn type_color(&self, type_name: &str) -> Option<String> {
        let needle = type_name.trim().to_ascii_lowercase();
        self.type_colors
            .iter()
            .find(|(name, _)| name.to_ascii_lowercase() == needle)
            .map(|(_, color)| color.clone())
            .or_else(|| {
                DEFAULT_TYPE_COLORS
                    .iter()
                    .find(|(name, _)| *name == needle)
                    .map(|(_, color)| (*color).to_string())
            })
    }

    /// The attribute list shown and edited in the Config view: the declared
    /// `[custom_attributes].keys`, or the built-in attributes tix models when
    /// none are declared. Mirrors [`Self::custom_types`] (`configured-or-defaults`).
    #[must_use]
    pub fn attributes(&self) -> Vec<String> {
        if self.custom_attributes.keys.is_empty() {
            crate::model::KNOWN_FRONTMATTER_KEYS
                .iter()
                .filter(|key| **key != "links")
                .map(|key| (*key).to_string())
                .collect()
        } else {
            self.custom_attributes.keys.clone()
        }
    }

    /// The resolved milestones directory: the configured value, or the
    /// contract default `docs/milestones` when unset.
    pub fn milestones_dir(&self) -> &Path {
        self.milestones_dir
            .as_deref()
            .unwrap_or(Path::new("docs/milestones"))
    }

    /// The resolved tags directory: the configured `tags_dir`, or the contract
    /// default `docs/tags` when unset (tix #1530).
    pub fn tags_dir(&self) -> &Path {
        self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
    }

    /// The child repositories this project coordinates (`[repos]`, tix #1529).
    pub fn repos(&self) -> &IndexMap<String, PathBuf> {
        &self.repos
    }

    /// The parent repository, when this project is a managed child (tix #1529).
    pub fn parent_repo(&self) -> Option<&Path> {
        self.parent_repo.as_deref()
    }

    /// Absolutize the paths that are resolved against the project root:
    /// an explicitly configured `tags_dir`, every `[repos]` entry, and
    /// `parent_repo` (tix #1529/#1530).
    ///
    /// Only a **relative** configured value is rewritten, so an unset key stays
    /// `None` (and is not materialized on save) and an absolute one is left
    /// alone. `issues_dir`/`milestones_dir` are resolved elsewhere; this covers
    /// the newer keys so the TUI can spawn `tix` in a child from any working
    /// directory.
    pub fn resolve_repo_paths(&mut self, base: &Path) {
        if let Some(path) = self.tags_dir.as_ref() {
            if path.is_relative() {
                self.tags_dir = Some(base.join(path));
            }
        }
        for path in self.repos.values_mut() {
            if path.is_relative() {
                *path = base.join(&*path);
            }
        }
        if let Some(path) = self.parent_repo.clone() {
            if path.is_relative() {
                self.parent_repo = Some(base.join(path));
            }
        }
    }

    /// The configuration used by the CLI and other single-config surfaces:
    /// the one named `default` if present, else the first declared.
    pub fn default_config(&self) -> &BoardConfiguration {
        self.kanban
            .configurations
            .get("default")
            .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
            .expect("validate() guarantees at least one configuration")
    }

    /// Named configurations in declaration order (tab order = declaration
    /// order, D-02).
    pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
        self.kanban
            .configurations
            .iter()
            .map(|(name, board)| (name.as_str(), board))
    }
}

#[cfg(test)]
mod tests;