Skip to main content

tix/
config.rs

1//! `tix.toml` config schema, defaults, loading, saving and validation.
2//!
3//! Mirrors `contracts/tix-toml.md` and `data-model.md` §Config. The
4//! [`Config`] type is the Serde surface for `tix.toml`; [`StateConfig`]
5//! carries the legacy `[state].next_number` counter, which is tolerated on
6//! load and never written back out (issue #52 — the issue number is
7//! resolved at creation time by scanning the live issue set). Loading emits
8//! a warning whenever a cleartext token is read from the file, and
9//! [`Config::save`] never serializes tokens back to disk.
10
11use std::collections::HashSet;
12use std::hash::{BuildHasher, Hash, Hasher};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use indexmap::IndexMap;
18use serde::ser::SerializeStruct;
19use serde::{Deserialize, Serialize, Serializer};
20
21use crate::board_store::BoardStore;
22use crate::capacity::config::CapacityConfig;
23use crate::cycle_store::{CycleStore, is_implicit_default};
24use crate::error::{Result, TixError};
25use crate::keybindings::{EffectiveBindings, KeyBindings};
26use crate::records::RecordStore;
27use crate::templates::registry::TemplateRegistry;
28
29/// Remote tracker credentials/targets embedded in `tix.toml`.
30///
31/// `token` values are read-only: they are never serialized back out and are
32/// never echoed in logs or errors. `base_url` may override the default
33/// endpoint for self-hosted GitLab instances.
34#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(default, deny_unknown_fields)]
36pub struct RemoteConfig {
37    /// Optional cleartext token; prefer the `GITLAB_TOKEN`/`LINEAR_TOKEN`
38    /// env vars. Never serialized back to disk.
39    pub token: Option<String>,
40    /// Optional base URL (e.g. self-hosted GitLab).
41    pub base_url: Option<String>,
42    /// GitLab project path (e.g. `acme/tix`) passed to `glab`.
43    pub project: Option<String>,
44    /// Linear team name passed to `linear-cli`.
45    pub team: Option<String>,
46}
47
48/// The `[export]` section: per-target remote configuration.
49#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(default, deny_unknown_fields)]
51pub struct ExportConfig {
52    /// GitLab remote target.
53    pub gitlab: Option<RemoteConfig>,
54    /// Linear remote target.
55    pub linear: Option<RemoteConfig>,
56}
57
58impl ExportConfig {
59    /// Whether no remote target is configured, for `skip_serializing_if`.
60    ///
61    /// An empty `[export]` table is never written, so a legacy `tix.toml` gains
62    /// no section on save (#1415).
63    pub fn is_empty(&self) -> bool {
64        self.gitlab.is_none() && self.linear.is_none()
65    }
66}
67
68/// A named team (`[teams.<label>]` in `tix.toml`). The label lives as the
69/// map key; `members` are free-form identities feeding the assignee/reviewer
70/// pickers only (never constraining stored values, DM-01M311MRK517KRHR426QPNP6P7).
71///
72/// An absent/empty `[teams]` section means teams are disabled (today's
73/// behavior), and an empty `[teams]` table is never written back out.
74#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
75#[serde(default, deny_unknown_fields)]
76pub struct Team {
77    /// Non-empty per team; unique within the team.
78    pub members: Vec<String>,
79}
80
81/// A named cycle (`[cycles.<name>]` in `tix.toml`). The name lives as the
82/// map key; `start_date`/`due_date` are optional `YYYY-MM-DD` strings with
83/// `due_date >= start_date` when both are present.
84#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
85#[serde(default, deny_unknown_fields)]
86pub struct PlanningCycle {
87    /// Optional human description.
88    pub description: Option<String>,
89    /// Optional start date (`YYYY-MM-DD`).
90    pub start_date: Option<String>,
91    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
92    pub due_date: Option<String>,
93}
94
95/// The rules every `tix.toml` registry key obeys.
96///
97/// `[projects.<project-id>]`, `[epics.<epic-id>]` and
98/// `[initiatives.<initiative-id>]` are keyed by a **stable generated identifier**,
99/// and the rules that keep such a key stable must not drift between the registries:
100/// never blank, never padded, bounded in length, free of the TOML-structural
101/// characters a table header cannot carry, and drawn from a shape that cannot hand
102/// the same value out twice. Only the prefix and the words used in an error differ,
103/// so those are the associated constants and everything else lives here once.
104///
105/// Hand-written ids are accepted when they pass [`StableId::new`], so a readable
106/// handle such as `p-platform` and the generated shape coexist.
107pub trait StableId: Sized {
108    /// The generated prefix: `p-` for projects, `e-` for epics, `i-` for initiatives,
109    /// `cs-` for cycle schedules.
110    const PREFIX: &'static str;
111    /// The human label used in messages, e.g. `project id`.
112    const LABEL: &'static str;
113    /// The indefinite article for [`Self::LABEL`]: `a` for a project, `an` for an epic.
114    const ARTICLE: &'static str;
115    /// The longest id a registry accepts. Long enough for a readable handle, short
116    /// enough that a typo cannot turn into a paragraph in a table header.
117    const MAX_LEN: usize = 128;
118
119    /// Wrap an id that has already passed [`StableId::new`].
120    fn from_validated(raw: String) -> Self;
121
122    /// The id as written.
123    fn as_str(&self) -> &str;
124
125    /// Validate an explicit id.
126    ///
127    /// Ids are chosen by a caller (an `--project-id`/`--epic-id` flag) or generated.
128    /// Either way the same rules apply, because the id ends up as a TOML table
129    /// header: [`restore_section_order`] reads `[<section>.<id>]`, and an id
130    /// containing `.` or a bracket would read as a nested table or a malformed
131    /// document.
132    fn new(raw: &str) -> Result<Self> {
133        if raw.trim().is_empty() {
134            return Err(TixError::Config(format!(
135                "{} {} must not be blank",
136                Self::ARTICLE,
137                Self::LABEL
138            )));
139        }
140        if raw.trim() != raw {
141            return Err(TixError::Config(format!(
142                "{} `{raw}` has leading or trailing whitespace",
143                Self::LABEL
144            )));
145        }
146        if raw.chars().count() > Self::MAX_LEN {
147            return Err(TixError::Config(format!(
148                "{} is {} characters; the limit is {}",
149                Self::LABEL,
150                raw.chars().count(),
151                Self::MAX_LEN
152            )));
153        }
154        if let Some(bad) = raw.chars().find(|c| is_structural_key_char(*c)) {
155            return Err(TixError::Config(format!(
156                "{} `{raw}` contains `{bad}`; use letters, digits, `-`, `_`, or `:`",
157                Self::LABEL
158            )));
159        }
160        Ok(Self::from_validated(raw.to_string()))
161    }
162
163    /// Allocate a new id: 64 mixed bits from the process hasher, [`Self::PREFIX`]-prefixed.
164    ///
165    /// No dependency and no counter stored in `tix.toml`, so there is no allocator
166    /// whose state a deletion could reset. The never-reused guarantee rests on the
167    /// **id shape** rather than on a persisted high-water mark: the value is drawn
168    /// from 2^64 with the process id, a process-monotonic counter, and the wall
169    /// clock mixed in, so two draws in one process differ with overwhelming
170    /// probability (~2^-64 for a SipHash collision), and a re-draw after a deletion
171    /// is as unlikely to repeat a previously handed-out value as any fresh draw is
172    /// to collide. On top of that, [`Config::allocate_project_id`],
173    /// [`Config::allocate_epic_id`] and [`Config::allocate_initiative_id`] redraw
174    /// against the ids already stored, so a *stored* id is never handed out a second
175    /// time — that half is exact, not probabilistic.
176    #[must_use]
177    fn generate() -> Self {
178        static COUNTER: AtomicU64 = AtomicU64::new(0);
179
180        let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
181        std::process::id().hash(&mut hasher);
182        COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
183        SystemTime::now()
184            .duration_since(UNIX_EPOCH)
185            .map_or(0, |since| since.as_nanos())
186            .hash(&mut hasher);
187        let bits = hasher.finish();
188        // The prefix plus 16 hex digits is a valid id by construction, so the private
189        // wrapper is used directly rather than re-validating the format.
190        Self::from_validated(format!("{}{bits:016x}", Self::PREFIX))
191    }
192}
193
194/// A stable project identity (`[projects.<project-id>]` in `tix.toml`).
195///
196/// Never blank, never containing whitespace or a TOML-structural character, and
197/// **never reused**. It is the registry key and every issue association points at
198/// it, so a rename changes the display label only and leaves every association and
199/// derived figure intact (C-002, SC-005). The type exists so "this string is an
200/// id" is checked once, at the boundary, instead of at every lookup — the same
201/// problem [`crate::views::registry::ViewId`] solves for views. The rules
202/// themselves are shared with [`EpicId`] through [`StableId`].
203#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
204#[serde(try_from = "String", into = "String")]
205pub struct ProjectId(String);
206
207impl StableId for ProjectId {
208    const PREFIX: &'static str = "p-";
209    const LABEL: &'static str = "project id";
210    const ARTICLE: &'static str = "a";
211
212    fn from_validated(raw: String) -> Self {
213        Self(raw)
214    }
215
216    fn as_str(&self) -> &str {
217        &self.0
218    }
219}
220
221impl ProjectId {
222    /// The longest id the registry accepts (see [`StableId::MAX_LEN`]).
223    pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
224
225    /// Validate an explicit id; see [`StableId::new`].
226    pub fn new(raw: &str) -> Result<Self> {
227        <Self as StableId>::new(raw)
228    }
229
230    /// Allocate a new id, `p-`-prefixed; see [`StableId::generate`] for the
231    /// never-reused guarantee.
232    #[must_use]
233    pub fn generate() -> Self {
234        <Self as StableId>::generate()
235    }
236
237    /// The id as written.
238    #[must_use]
239    pub fn as_str(&self) -> &str {
240        <Self as StableId>::as_str(self)
241    }
242}
243
244/// A stable epic identity (`[epics.<epic-id>]` in `tix.toml`).
245///
246/// The same rules as [`ProjectId`], shared through [`StableId`], with an `e-`
247/// prefix: never blank, never padded, never carrying a TOML-structural character,
248/// and **never reused**. It is the registry key and never a display name (C-003), so
249/// a rename changes the label only and a deleted epic's id stays spent — a member
250/// issue's dangling reference is then diagnosed and repaired, never silently
251/// rebound to a new epic (C-005, NFR-002).
252#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
253#[serde(try_from = "String", into = "String")]
254pub struct EpicId(String);
255
256impl StableId for EpicId {
257    const PREFIX: &'static str = "e-";
258    const LABEL: &'static str = "epic id";
259    const ARTICLE: &'static str = "an";
260
261    fn from_validated(raw: String) -> Self {
262        Self(raw)
263    }
264
265    fn as_str(&self) -> &str {
266        &self.0
267    }
268}
269
270impl EpicId {
271    /// The longest id the registry accepts (see [`StableId::MAX_LEN`]).
272    pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
273
274    /// Validate an explicit epic id; see [`StableId::new`].
275    pub fn new(raw: &str) -> Result<Self> {
276        <Self as StableId>::new(raw)
277    }
278
279    /// Allocate a new epic id, `e-`-prefixed; see [`StableId::generate`] for the
280    /// never-reused guarantee.
281    #[must_use]
282    pub fn generate() -> Self {
283        <Self as StableId>::generate()
284    }
285
286    /// The id as written.
287    #[must_use]
288    pub fn as_str(&self) -> &str {
289        <Self as StableId>::as_str(self)
290    }
291}
292
293impl std::fmt::Display for ProjectId {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        f.write_str(&self.0)
296    }
297}
298
299impl TryFrom<String> for ProjectId {
300    type Error = TixError;
301
302    fn try_from(raw: String) -> Result<Self> {
303        Self::new(&raw)
304    }
305}
306
307impl From<ProjectId> for String {
308    fn from(id: ProjectId) -> Self {
309        id.0
310    }
311}
312
313impl std::fmt::Display for EpicId {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.write_str(&self.0)
316    }
317}
318
319impl TryFrom<String> for EpicId {
320    type Error = TixError;
321
322    fn try_from(raw: String) -> Result<Self> {
323        Self::new(&raw)
324    }
325}
326
327impl From<EpicId> for String {
328    fn from(id: EpicId) -> Self {
329        id.0
330    }
331}
332
333/// A stable initiative identity (`[initiatives.<initiative-id>]` in `tix.toml`).
334///
335/// The third [`StableId`], after [`ProjectId`] and [`EpicId`], with an `i-` prefix
336/// and the same rules through the same trait: never blank, never padded, never
337/// carrying a TOML-structural character, and **never reused**. It is the registry
338/// key and an initiative's associations and derived values hang off it, so a rename
339/// changes the display label only (C-002). A deleted initiative's id stays spent, so
340/// nothing is silently rebound to a later record.
341#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
342#[serde(try_from = "String", into = "String")]
343pub struct InitiativeId(String);
344
345impl StableId for InitiativeId {
346    const PREFIX: &'static str = "i-";
347    const LABEL: &'static str = "initiative id";
348    const ARTICLE: &'static str = "an";
349
350    fn from_validated(raw: String) -> Self {
351        Self(raw)
352    }
353
354    fn as_str(&self) -> &str {
355        &self.0
356    }
357}
358
359impl InitiativeId {
360    /// The longest id the registry accepts (see [`StableId::MAX_LEN`]).
361    pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
362
363    /// Validate an explicit id; see [`StableId::new`].
364    pub fn new(raw: &str) -> Result<Self> {
365        <Self as StableId>::new(raw)
366    }
367
368    /// Allocate a new id, `i-`-prefixed; see [`StableId::generate`] for the
369    /// never-reused guarantee.
370    #[must_use]
371    pub fn generate() -> Self {
372        <Self as StableId>::generate()
373    }
374
375    /// The id as written.
376    #[must_use]
377    pub fn as_str(&self) -> &str {
378        <Self as StableId>::as_str(self)
379    }
380}
381
382impl std::fmt::Display for InitiativeId {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        f.write_str(&self.0)
385    }
386}
387
388impl TryFrom<String> for InitiativeId {
389    type Error = TixError;
390
391    fn try_from(raw: String) -> Result<Self> {
392        Self::new(&raw)
393    }
394}
395
396impl From<InitiativeId> for String {
397    fn from(id: InitiativeId) -> Self {
398        id.0
399    }
400}
401
402/// Characters that cannot appear in a config table key: whitespace, and the
403/// TOML/table syntax characters an id would otherwise be able to break out of.
404fn is_structural_key_char(c: char) -> bool {
405    c.is_whitespace()
406        || matches!(c, '.' | '"' | '\'' | '[' | ']' | '#' | '=' | '\\')
407        || c.is_control()
408}
409
410/// A project record (`[projects.<project-id>]` in `tix.toml`).
411///
412/// The stable [`ProjectId`] lives as the map key; `name` is a mutable display
413/// label and is **not** the identity (C-002). The section is omitted entirely when
414/// there are no projects, so a legacy `tix.toml` is byte-untouched (C-001).
415///
416/// `status` is project metadata and is deliberately a **free-form string**: it is
417/// not the issue's board column, and workflow-*category* semantics belong to
418/// source issue #183, so this section introduces no global category model (C-007).
419/// `lead` is an identity **string** in the same shape the existing `assignee`
420/// field tolerates (bare handle or `Name <email>`); it is not a user account, a
421/// directory entry, or a team membership (C-007).
422#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
423#[serde(default, deny_unknown_fields)]
424pub struct ProjectConfiguration {
425    /// Display label. Required and unique within the registry, so interactive
426    /// selection is unambiguous — but never the identity, which is the map key.
427    pub name: String,
428    /// Optional long-form context.
429    pub description: Option<String>,
430    /// Optional project status (free-form; not a board column, not a category).
431    pub status: Option<String>,
432    /// Optional lead identity string; no lookup, no user-identity concept.
433    pub lead: Option<String>,
434    /// Optional start date (`YYYY-MM-DD`).
435    pub start_date: Option<String>,
436    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
437    pub due_date: Option<String>,
438}
439
440/// An epic record (`[epics.<epic-id>]` in `tix.toml`).
441///
442/// Mission `epics-work-items-01M3BYW4` (WP01). The stable [`EpicId`] lives as the
443/// map key; `name` is a mutable display label and is **not** the identity (C-003),
444/// so a rename leaves every member issue's reference and every derived figure
445/// intact. The section is omitted entirely when there are no epics, so a legacy
446/// `tix.toml` is byte-untouched (C-004).
447///
448/// `status` is epic metadata and is deliberately a **free-form string**, exactly
449/// like [`ProjectConfiguration::status`]: it is not an issue's board column, and
450/// workflow-*category* semantics belong to source issue #183, so no global category
451/// model is introduced here. `owner` is an identity **string** in the same shape the
452/// existing `assignee` field tolerates (bare handle or `Name <email>`); it is not a
453/// user account, a directory entry, or a team membership (C-003).
454///
455/// `weight` is epic metadata used in epic rollups — a positive relative size. It is
456/// **not** a size field on issues: `Issue.estimate` stays the free-form string it is,
457/// and no typed size concept is introduced (research R3). A missing weight
458/// contributes nothing to a weighted figure.
459///
460/// `parent` is an **epic id** and `projects` are **project ids**, while `milestone`
461/// and `cycle` are the **name-keyed** references the existing registries use. That
462/// asymmetry is deliberate — it matches how `Issue.cycles`/`Issue.milestones`
463/// already work — and is recorded in `contracts/epic-registry.md` so it is not
464/// "corrected" by accident.
465///
466/// References are validated for **shape** only. Whether a project, milestone, cycle,
467/// or parent epic exists is resolved by the epic service and diagnosed by
468/// `tix lint`; a dangling reference must stay loadable and repairable, exactly as a
469/// view's project scope does (C-005).
470#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
471#[serde(default, deny_unknown_fields)]
472pub struct EpicConfiguration {
473    /// Display label. Required and unique within the registry, so selection is
474    /// unambiguous — but never the identity, which is the map key.
475    pub name: String,
476    /// Optional long-form context.
477    pub description: Option<String>,
478    /// Optional epic status (free-form; not a board column, not a category).
479    pub status: Option<String>,
480    /// Optional owner identity string; no lookup, no user-identity concept.
481    pub owner: Option<String>,
482    /// Optional start date (`YYYY-MM-DD`).
483    pub start_date: Option<String>,
484    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
485    pub due_date: Option<String>,
486    /// Optional positive relative size, used in epic rollups. Never an issue size.
487    pub weight: Option<i64>,
488    /// Optional parent epic **id**; shape is checked here, existence is not.
489    pub parent: Option<String>,
490    /// Project **ids** this epic concerns; shape is checked here, existence is not.
491    #[serde(default, skip_serializing_if = "Vec::is_empty")]
492    pub projects: Vec<String>,
493    /// Optional milestone **name** (the existing name-keyed reference).
494    pub milestone: Option<String>,
495    /// Optional cycle **name** (the existing name-keyed reference).
496    pub cycle: Option<String>,
497}
498
499/// An initiative record (`[initiatives.<initiative-id>]` in `tix.toml`).
500///
501/// Mission `roadmap-initiatives-01M3C8SX` (WP01). The third stable-ID record, after
502/// [`ProjectConfiguration`] and [`EpicConfiguration`]: the [`InitiativeId`] is the
503/// map key and `name` is a mutable display label, so a rename leaves every
504/// association and derived figure intact (C-002). The section is omitted entirely
505/// when there are no initiatives, so a legacy `tix.toml` is byte-untouched (C-001).
506///
507/// # Preserved extensions — deliberately unlike its siblings
508///
509/// This record does **not** set `deny_unknown_fields`. The data model lists
510/// `extensions` as preserved unknown data, so an unknown key is carried through
511/// [`InitiativeExtensions`](crate::roadmap::registry::InitiativeExtensions) and
512/// round-trips unchanged rather than making `tix.toml` unreadable. *The trade-off:
513/// a typo'd key is preserved rather than refused here*, which is the cost of the
514/// forward-compatibility guarantee — the siblings that deny unknown fields pay the
515/// opposite cost.
516///
517/// # Metadata is not issue state
518///
519/// `status` and `priority` are initiative metadata, **not** an issue's board column
520/// or priority: `status` is free-form exactly like [`ProjectConfiguration::status`],
521/// and `priority` reuses [`crate::model::Priority`] because its serde shape is a
522/// plain string that round-trips an unknown value verbatim (a known spelling is
523/// normalized to lowercase, the same rule issues already use). `owner` is an
524/// identity **string** with no lookup, never inferred from anything.
525///
526/// # Associations are explicit, shape-checked, and never resolved here
527///
528/// `projects` are project **ids**, `epics` are epic **ids**, and `milestones` are
529/// the **names** the existing milestone registry uses — the same id/name asymmetry
530/// the epic contract records. Every entry is validated for **shape** only: whether
531/// its target exists is resolved by the service (WP03) and diagnosed by `tix lint`,
532/// so a dangling reference stays loadable and repairable (C-005). A repeated entry
533/// is idempotent (see [`crate::roadmap::registry`]).
534#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
535#[serde(default)]
536pub struct InitiativeConfiguration {
537    /// Display label. Required and unique within the registry (case-folded), so
538    /// selection is unambiguous — but never the identity, which is the map key.
539    pub name: String,
540    /// Optional strategic outcome.
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub outcome: Option<String>,
543    /// Optional owner identity **string**; no lookup, never inferred from anything.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub owner: Option<String>,
546    /// Optional initiative status (free-form; not a board column, not a category).
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub status: Option<String>,
549    /// Optional initiative priority (see [`crate::model::Priority`]).
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub priority: Option<crate::model::Priority>,
552    /// Filterable labels; a repeat collapses to one on the write path.
553    #[serde(default, skip_serializing_if = "Vec::is_empty")]
554    pub labels: Vec<String>,
555    /// Optional calendar target (`YYYY-MM-DD`).
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub target_date: Option<String>,
558    /// Project **ids** this initiative concerns; shape checked here, existence not.
559    #[serde(default, skip_serializing_if = "Vec::is_empty")]
560    pub projects: Vec<String>,
561    /// Epic **ids** this initiative concerns; shape checked here, existence not.
562    #[serde(default, skip_serializing_if = "Vec::is_empty")]
563    pub epics: Vec<String>,
564    /// Milestone **names** this initiative concerns (the name-keyed reference).
565    #[serde(default, skip_serializing_if = "Vec::is_empty")]
566    pub milestones: Vec<String>,
567    /// Unknown metadata, preserved verbatim (see the type-level note).
568    #[serde(flatten)]
569    pub extensions: crate::roadmap::registry::InitiativeExtensions,
570}
571
572/// The `[planning]` section: the monotonic auto-cycle counter plus the
573/// picker presets written by `tix init` (`contracts/tix-toml-planning.md`).
574///
575/// `cycle_number` is the next auto "Cycle N" number: it starts at 1, is bumped
576/// on every unnamed cycle create, and never decrements (FR-003,
577/// DM-01M311BJKMN3PPCR9GR9Y7ESD2). Contrary to the legacy `[state]` counter,
578/// this section **is** serialized once mutated.
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580#[serde(default, deny_unknown_fields)]
581pub struct PlanningConfig {
582    /// Monotonic next auto-cycle number; `>= 1`. Default `1`.
583    pub cycle_number: u32,
584    /// Optional picker preset naming a cycle.
585    pub default_cycle: Option<String>,
586    /// Optional picker preset naming a milestone.
587    pub default_milestone: Option<String>,
588    /// Optional picker preset naming a team.
589    pub default_team: Option<String>,
590}
591
592impl PlanningConfig {
593    /// Whether this section is untouched (all values at their defaults); such
594    /// a section is not written to disk so legacy `tix.toml` files keep
595    /// round-tripping byte-identically (NFR-003).
596    pub fn is_default(&self) -> bool {
597        self == &Self::default()
598    }
599}
600
601impl Default for PlanningConfig {
602    fn default() -> Self {
603        Self {
604            cycle_number: 1,
605            default_cycle: None,
606            default_milestone: None,
607            default_team: None,
608        }
609    }
610}
611
612/// A named new-issue template (`[templates.<name>]` in `tix.toml`).
613///
614/// The F4 New Issue mode (`tui-new-issue-mode`) lists these by name and, on
615/// selection, pre-fills the form's rows from the fields the template sets.
616/// Every field is optional, the section is absent by default, and an empty
617/// `[templates]` table writes no key — so a project that never writes one pays
618/// nothing and its `tix.toml` keeps round-tripping unchanged.
619///
620/// A template carries **content**, not identity: `number`, `created`,
621/// `filename` and `History` are creation outputs rather than inputs, so they are
622/// not fields here. `body` and `labels` are the two the mode's spec requires;
623/// the rest exist because the form already has a row for them, and a template
624/// that could not reach a row would only half-fill the form.
625///
626/// ```toml
627/// [templates.bug]
628/// labels = ["bug"]
629/// body = "## Steps to reproduce\n\n"
630/// ```
631#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
632#[serde(default, deny_unknown_fields)]
633pub struct IssueTemplate {
634    /// Pre-filled title. Optional: a template that sets none leaves the title to
635    /// the user, and the title stays required at submit either way.
636    pub title: Option<String>,
637    /// Pre-filled issue body (Markdown).
638    pub body: Option<String>,
639    /// Pre-filled labels.
640    pub labels: Vec<String>,
641    /// Pre-filled status column.
642    pub status: Option<String>,
643    /// Pre-filled priority.
644    pub priority: Option<String>,
645    /// Pre-filled assignee.
646    pub assignee: Option<String>,
647    /// Pre-filled estimate.
648    pub estimate: Option<String>,
649    /// Pre-filled reviewer.
650    pub reviewer: Option<String>,
651    /// Pre-filled cycles.
652    pub cycles: Vec<String>,
653    /// Pre-filled milestones.
654    pub milestones: Vec<String>,
655    /// Pre-filled start date (`YYYY-MM-DD`).
656    pub start_date: Option<String>,
657    /// Pre-filled due date (`YYYY-MM-DD`).
658    pub due_date: Option<String>,
659}
660
661/// The `[state]` section — deprecated runtime bookkeeping that is still
662/// tolerated on load for legacy `tix.toml` files but is never serialized.
663///
664/// The monotonic `next_number` counter this held was removed in issue #52:
665/// issue numbers are now resolved at creation time by scanning the live
666/// issue set, so [`Config::save`] writes no `[state]` section at all.
667#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
668#[serde(default, deny_unknown_fields)]
669pub struct StateConfig {
670    /// Legacy monotonic issue-number counter (C-007). Read-only — kept so
671    /// old `tix.toml` files with `[state].next_number` still load, and
672    /// never persisted once saved again.
673    #[serde(skip_serializing)]
674    pub next_number: u32,
675}
676
677/// Card ordering for the rendered board (`[kanban.<name>].order_by`).
678///
679/// Applied per column in the board builders; the store's underlying order is
680/// untouched so the CLI keeps its own sequencing (board-display FR-02).
681#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
682pub enum OrderBy {
683    /// Most recently created issues first (the default).
684    #[default]
685    #[serde(rename = "created_at_desc")]
686    CreatedAtDesc,
687    /// Oldest issues first.
688    #[serde(rename = "created_at_asc")]
689    CreatedAtAsc,
690    /// Preserve the underlying store (filename/created-date ascending) order.
691    #[serde(rename = "none")]
692    None,
693}
694
695impl OrderBy {
696    /// The TOML spelling of this variant.
697    pub fn as_str(self) -> &'static str {
698        match self {
699            Self::CreatedAtDesc => "created_at_desc",
700            Self::CreatedAtAsc => "created_at_asc",
701            Self::None => "none",
702        }
703    }
704
705    /// The next variant when cycling the setting in the TUI settings modal.
706    pub fn next(self) -> Self {
707        match self {
708            Self::CreatedAtDesc => Self::CreatedAtAsc,
709            Self::CreatedAtAsc => Self::None,
710            Self::None => Self::CreatedAtDesc,
711        }
712    }
713}
714
715/// The fixed, global category vocabulary (C-002, contract §1).
716///
717/// One list, referenced by the config validator, the per-board mapping and the
718/// service WP02 builds. A vocabulary spelled out in three places is a vocabulary
719/// that disagrees with itself the first time one of them changes.
720///
721/// `TERMINAL` is the subset that ends the workflow. `canceled` is terminal but
722/// **not** complete — `COMPLETE` is deliberately a separate one-element list rather
723/// than `!TERMINAL.contains(...)`, because "terminal" and "complete" are different
724/// claims and the contract makes conflating them an escalation.
725pub const CATEGORY_KEYS: &[&str] = &[
726    "backlog",
727    "unstarted",
728    "active",
729    "blocked",
730    "completed",
731    "canceled",
732];
733
734/// Categories that end the workflow.
735pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
736
737/// The **only** category that means done. `canceled` is terminal without being
738/// complete, so this is a one-element list and not "everything terminal".
739pub const COMPLETE_CATEGORY: &str = "completed";
740
741/// The documented safe default for a column with no mapping (contract §4).
742///
743/// Non-terminal and non-complete, so an unmapped column is never silently counted
744/// as done. A default that resolved to `completed` would change completion
745/// meaning by accident, which the contract makes an escalation rather than a
746/// convenience — [`validate_categories`] refuses one.
747pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
748
749/// `[categories]`: the global vocabulary and the safe default.
750///
751/// Omitted from disk when untouched, so a legacy `tix.toml` round-trips
752/// byte-identically and gains no new keys (NFR-003, C-007).
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754#[serde(default, deny_unknown_fields)]
755pub struct CategoryConfig {
756    /// The vocabulary, in the project's preferred order for presentation.
757    ///
758    /// Defaults to [`CATEGORY_KEYS`]. A configured list is still validated
759    /// against that vocabulary: a board maps columns *into* the global
760    /// vocabulary, it does not define its own (C-002), so adding a key here is
761    /// an `unknown_category` error rather than a new category.
762    pub order: Vec<String>,
763    /// The category an unmapped column resolves to. Must be in `order`, and must
764    /// not be [`COMPLETE_CATEGORY`].
765    pub default: String,
766}
767
768impl Default for CategoryConfig {
769    fn default() -> Self {
770        Self {
771            order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
772            default: SAFE_DEFAULT_CATEGORY.to_string(),
773        }
774    }
775}
776
777impl CategoryConfig {
778    /// Whether this section is untouched, and so is not written to disk.
779    pub fn is_default(&self) -> bool {
780        self == &Self::default()
781    }
782
783    /// Whether `key` is in the configured vocabulary.
784    pub fn contains(&self, key: &str) -> bool {
785        self.order.iter().any(|k| k == key)
786    }
787}
788
789/// `[transitions]`: the workflow policy.
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(default, deny_unknown_fields)]
792pub struct TransitionConfig {
793    /// Whether an issue in a terminal category refuses an ordinary move.
794    ///
795    /// Default `true`. "Ordinary" is load-bearing: the escape hatch WP02 settles is
796    /// a *reasoned* transition out of a terminal category, not a silent
797    /// reassignment, so this switch governs the second and not the first.
798    pub terminal_locked: bool,
799}
800
801impl Default for TransitionConfig {
802    fn default() -> Self {
803        Self {
804            terminal_locked: true,
805        }
806    }
807}
808
809impl TransitionConfig {
810    pub fn is_default(&self) -> bool {
811        self == &Self::default()
812    }
813}
814
815/// The default number of lines a wrapped card title may occupy
816/// ([`BoardConfiguration::title_lines`]). Named so the manual `Serialize` can
817/// recognise it without repeating the literal (#1415).
818const DEFAULT_TITLE_LINES: usize = 3;
819
820/// The `done_column` a board gets when the key is absent.
821///
822/// `BoardConfiguration` is `#[serde(default)]` at the container level, so a
823/// missing `done_column` is filled from [`BoardConfiguration::default`] — this
824/// value, not an inference from `columns`. Named so the manual `Serialize` can
825/// omit a `done_column` that already equals it (#1415).
826const DEFAULT_DONE_COLUMN: &str = "done";
827
828/// Health policy configuration (`[health]` in `tix.toml`; mission
829/// `project-issue-health-status-01M3C8HX`, WP01 T002/T003).
830///
831/// One key, and that is the design: the only thing a repository *chooses* about
832/// health is which **version** of the derivation policy it is on. The vocabulary
833/// and the formula live in the build ([`crate::health::policy`]), because changing
834/// either changes what every stored value means — an escalation with its own plan
835/// (C-003), not a line in a config file. A version this build does not implement is
836/// refused at load, by name.
837///
838/// Omitted when absent, so a repository that has never mentioned health gains no
839/// section and a legacy `tix.toml` round-trips byte-identically.
840#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
841#[serde(default, deny_unknown_fields)]
842pub struct HealthConfig {
843    /// The policy version in force; absent means [`crate::health::HEALTH_POLICY_VERSION`].
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub policy: Option<String>,
846}
847
848impl HealthConfig {
849    /// Whether nothing is configured: the state the schema omits on save.
850    pub fn is_empty(&self) -> bool {
851        self.policy.is_none()
852    }
853
854    /// The policy in force: the configured version, or the current one (T002).
855    pub fn policy(&self) -> std::result::Result<crate::health::HealthPolicy, TixError> {
856        match self.policy.as_deref() {
857            Some(version) => crate::health::HealthPolicy::for_version(version)
858                .map_err(|error| TixError::Config(error.render())),
859            None => Ok(crate::health::HealthPolicy::v1()),
860        }
861    }
862}
863
864/// A named board configuration (`[kanban.<name>]` in `tix.toml`).
865///
866/// Carries the ordered column list plus per-configuration metadata: the done
867/// column, columns hidden from the rendered board, and whether the computed
868/// "Mismatched" pseudo-column is shown (C-002 / DM-01M2MH4D84NHG0JVWEPR6ZH4VR).
869///
870/// `Serialize` is written by hand (below) so every field equal to the value the
871/// loader would supply is omitted on save, keeping a legacy `tix.toml`
872/// byte-identical; `Deserialize` stays derived.
873#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
874#[serde(default, deny_unknown_fields)]
875pub struct BoardConfiguration {
876    /// Ordered, case-sensitive, non-empty, unique column names.
877    pub columns: Vec<String>,
878    /// The column treated as "done" (commit prompting). Defaults to `"done"`
879    /// when that name is present in `columns`, else the last configured
880    /// column (contracts/tix-toml.md field rules).
881    ///
882    /// **Its meaning is unchanged by this mission.** It remains the commit-prompt
883    /// trigger and the legacy completion signal. WP02 reads the *category* for the
884    /// completion decision; this field is not reinterpreted here, and a
885    /// `category_map` does not change what it does.
886    pub done_column: Option<String>,
887    /// Column name → category key (contract §2). Omitted when empty, so a board
888    /// without a mapping gains no key on save.
889    ///
890    /// A board with **no** map is a legacy board: its `done_column` resolves to
891    /// `completed` and every other column to the safe default. See
892    /// [`BoardConfiguration::category_for`], which is the single place that
893    /// fallback lives — duplicating it in the service is how a board ends up with
894    /// two notions of its own default.
895    pub category_map: IndexMap<String, String>,
896    /// Columns hidden from the rendered board. Must be a subset of `columns`;
897    /// their cards stay in board state and remain reachable via filters/search.
898    pub hidden_columns: Vec<String>,
899    /// Whether the computed "Mismatched" pseudo-column is rendered. Default
900    /// `false`. Never a configured column.
901    pub show_mismatch: bool,
902    /// Whether long card titles wrap onto multiple lines. Default `true`.
903    pub wrap_titles: bool,
904    /// The maximum number of lines a wrapped card may occupy. Default `3`;
905    /// validated `>= 1` in [`validate_board`]. Ignored when `wrap_titles`
906    /// is `false`.
907    pub title_lines: usize,
908    /// Sparse manual card order persisted by drag-and-drop reordering. When
909    /// non-empty, this order takes precedence over [`order_by`] for the cards
910    /// whose positions differ from the automatic ordering.
911    pub card_order: Vec<String>,
912    /// Ordering of cards within each rendered column. Default
913    /// `created_at_desc` (most recently created first).
914    pub order_by: OrderBy,
915    /// Per-column work-in-progress limits and the estimate units they count in
916    /// (mission `configurable-wip-limits-01M3C6WX`). Absent by default and omitted
917    /// when empty, so a board that configures no limits gains no key on save — and
918    /// **no status semantics change**: the columns, their categories and the done
919    /// column mean exactly what they meant before (C-002).
920    pub wip: crate::wip::WipConfig,
921}
922
923impl Default for BoardConfiguration {
924    fn default() -> Self {
925        Self {
926            columns: ["todo", "in progress", "in review", "done"]
927                .into_iter()
928                .map(String::from)
929                .collect(),
930            done_column: Some(DEFAULT_DONE_COLUMN.to_string()),
931            category_map: IndexMap::new(),
932            hidden_columns: Vec::new(),
933            show_mismatch: false,
934            wrap_titles: true,
935            title_lines: DEFAULT_TITLE_LINES,
936            card_order: Vec::new(),
937            order_by: OrderBy::default(),
938            wip: crate::wip::WipConfig::default(),
939        }
940    }
941}
942
943impl Serialize for BoardConfiguration {
944    /// Serialize a board as a `[kanban.<name>]` table, omitting **every field
945    /// that already equals the value the loader would supply** (#1415).
946    ///
947    /// `columns` is always written — a board without columns is not a board —
948    /// and `category_map`/`card_order` were already omitted when empty. The
949    /// display and ordering keys (`hidden_columns`, `show_mismatch`,
950    /// `wrap_titles`, `title_lines`, `order_by`) are omitted at their defaults,
951    /// and `done_column` when it already equals [`DEFAULT_DONE_COLUMN`] — the
952    /// value the loader fills in when the key is absent, since the container is
953    /// `#[serde(default)]`. `Deserialize` stays derived, so the round trip is a
954    /// fixed point: omitting a default writes nothing and loading a missing key
955    /// restores the same value.
956    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
957    where
958        S: Serializer,
959    {
960        let done = self
961            .done_column
962            .as_deref()
963            .filter(|done| *done != DEFAULT_DONE_COLUMN);
964        let order_by = (self.order_by != OrderBy::default()).then_some(self.order_by);
965
966        let mut len = 1; // `columns` is always written.
967        len += usize::from(done.is_some());
968        len += usize::from(!self.category_map.is_empty());
969        len += usize::from(!self.hidden_columns.is_empty());
970        len += usize::from(self.show_mismatch);
971        len += usize::from(!self.wrap_titles);
972        len += usize::from(self.title_lines != DEFAULT_TITLE_LINES);
973        len += usize::from(!self.card_order.is_empty());
974        len += usize::from(order_by.is_some());
975        len += usize::from(!self.wip.is_empty());
976
977        let mut board = serializer.serialize_struct("BoardConfiguration", len)?;
978        board.serialize_field("columns", &self.columns)?;
979        if let Some(done) = done {
980            board.serialize_field("done_column", done)?;
981        }
982        if !self.category_map.is_empty() {
983            board.serialize_field("category_map", &self.category_map)?;
984        }
985        if !self.hidden_columns.is_empty() {
986            board.serialize_field("hidden_columns", &self.hidden_columns)?;
987        }
988        if self.show_mismatch {
989            board.serialize_field("show_mismatch", &self.show_mismatch)?;
990        }
991        if !self.wrap_titles {
992            board.serialize_field("wrap_titles", &self.wrap_titles)?;
993        }
994        if self.title_lines != DEFAULT_TITLE_LINES {
995            board.serialize_field("title_lines", &self.title_lines)?;
996        }
997        if !self.card_order.is_empty() {
998            board.serialize_field("card_order", &self.card_order)?;
999        }
1000        if let Some(order_by) = order_by {
1001            board.serialize_field("order_by", &order_by)?;
1002        }
1003        if !self.wip.is_empty() {
1004            board.serialize_field("wip", &self.wip)?;
1005        }
1006        board.end()
1007    }
1008}
1009
1010/// The board a **new repository** starts with (`tix init`), rather than the bare
1011/// [`BoardConfiguration::default`] a config-less or partial file falls back to.
1012///
1013/// It carries the two columns the triage inbox needs: `triage` (where unreviewed work
1014/// lands — `[triage].entry_status`) and `wontfix` (where a decline goes; it has to
1015/// exist, because `declined_status` must resolve to the `canceled` category).
1016///
1017/// **Only those two are mapped**, and that is deliberate. The map is what makes a
1018/// board `explicit_policy`, and an explicit policy brings *adjacency*: a category may
1019/// move to itself or forward through the declared order, never backwards. Mapping the
1020/// working columns would have to call them `unstarted` or `active`, and then
1021/// `in progress -> todo` — a move people make every day when a review finds a problem
1022/// — would be refused outright, with no `--reason` to override it. Leaving them
1023/// unmapped resolves them all to the same safe default, so movement among them stays
1024/// free while `done`/`wontfix` are terminal, which is the discipline a new repository
1025/// can *opt into* later (`tix lint` names the unmapped columns and how to map them).
1026pub fn starter_board() -> BoardConfiguration {
1027    BoardConfiguration {
1028        columns: [
1029            "triage",
1030            "todo",
1031            "in progress",
1032            "in review",
1033            "done",
1034            "wontfix",
1035        ]
1036        .into_iter()
1037        .map(String::from)
1038        .collect(),
1039        category_map: IndexMap::from([
1040            ("done".to_string(), "completed".to_string()),
1041            ("wontfix".to_string(), "canceled".to_string()),
1042        ]),
1043        ..BoardConfiguration::default()
1044    }
1045}
1046
1047impl BoardConfiguration {
1048    /// The category `column` resolves to, given the global configuration.
1049    ///
1050    /// The single place the fallback lives, and the seam WP02's service builds
1051    /// on. Three cases, in order:
1052    ///
1053    /// 1. An explicit `category_map` entry wins.
1054    /// 2. Otherwise, on a board with **no** map at all, `done_column` is
1055    ///    `completed` and every other column is the safe default.
1056    /// 3. Otherwise (a map exists but omits this column), the safe default.
1057    ///
1058    /// Case 2 is a reconciliation, and the contract's two rules collide without
1059    /// it: *"a board without a `category_map` inherits `[categories].default` for
1060    /// every column"* (§2) plus *"no column maps to `completed` is a configuration
1061    /// error"* (§3) would reject every legacy `tix.toml`, while §2 also requires
1062    /// that one to load unchanged (C-007). `done_column` is the resolution: it is
1063    /// always a configured column, it already means "this column completes work",
1064    /// and using it keeps a legacy board's completion behaviour exactly what it
1065    /// was. A board that *does* declare a map gets no such help — a map that
1066    /// forgot `completed` is a real mistake and is reported as one.
1067    pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
1068        if let Some(mapped) = self.category_map.get(column) {
1069            return mapped;
1070        }
1071        if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
1072            return COMPLETE_CATEGORY;
1073        }
1074        &categories.default
1075    }
1076
1077    /// The resolved done column: the explicit `done_column`, else the one
1078    /// [`BoardConfiguration::inferred_done_column`] derives from `columns`.
1079    pub fn effective_done_column(&self) -> Option<&str> {
1080        self.done_column
1081            .as_deref()
1082            .or_else(|| self.inferred_done_column())
1083    }
1084
1085    /// The `done_column` inferred from `columns` alone, for a board built in
1086    /// code rather than read from a file: `"done"` when that name is present,
1087    /// else the last configured column. `None` only for empty `columns` (which
1088    /// validation rejects).
1089    ///
1090    /// Split out of [`BoardConfiguration::effective_done_column`] so the
1091    /// inference lives in one place.
1092    fn inferred_done_column(&self) -> Option<&str> {
1093        if self.columns.iter().any(|c| c == DEFAULT_DONE_COLUMN) {
1094            Some(DEFAULT_DONE_COLUMN)
1095        } else {
1096            self.columns.last().map(String::as_str)
1097        }
1098    }
1099}
1100
1101/// The `[kanban]` section: named configurations in declaration order.
1102///
1103/// `#[serde(transparent)]` maps the TOML table `[kanban.<name>]` straight
1104/// onto the [`IndexMap`]. The `toml` parser sorts table keys, so
1105/// [`Config::load_with_warning`] re-orders the map from the source
1106/// document's `[kanban.<name>]` header order via
1107/// [`restore_declaration_order`] (D-02: declaration order = tab order,
1108/// validated by the round-trip test). The map is never empty after load —
1109/// [`Config::load_with_warning`] synthesizes the shipped `default`
1110/// configuration when `tix.toml` omits the section.
1111#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1112#[serde(default, transparent)]
1113pub struct KanbanSection {
1114    /// Named configurations, keyed by name, in declaration order.
1115    pub configurations: IndexMap<String, BoardConfiguration>,
1116}
1117
1118impl KanbanSection {
1119    /// Whether no board is configured. An empty section writes no `[kanban]`
1120    /// table, which is how a board-file project keeps its `tix.toml` free of
1121    /// board records.
1122    pub fn is_empty(&self) -> bool {
1123        self.configurations.is_empty()
1124    }
1125}
1126
1127/// The project configuration file (`tix.toml`).
1128///
1129/// Not `Eq`, unlike each of the sections it holds: [`Config::views`] preserves
1130/// unknown view metadata as `toml::Value`, whose float variant includes `nan`, so
1131/// the reflexive equality `Eq` promises cannot be guaranteed for a config that
1132/// carries one. `PartialEq` is what every comparison in the tree needs — `save`
1133/// then `load` round-trip tests included.
1134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1135#[serde(default, deny_unknown_fields)]
1136pub struct Config {
1137    /// Named board configurations (`[kanban]`). Always ≥1 after load.
1138    #[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
1139    pub kanban: KanbanSection,
1140    /// Directory holding issue files, relative to the repo root.
1141    pub issues_dir: PathBuf,
1142    /// Directory holding milestone markdown files, relative to the repo
1143    /// root. `None` → the default `docs/milestones`.
1144    #[serde(default, skip_serializing_if = "Option::is_none")]
1145    pub milestones_dir: Option<PathBuf>,
1146    /// Named teams (`[teams.<label>]`), in declaration order.
1147    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1148    pub teams: IndexMap<String, Team>,
1149    /// Named cycles (`[cycles.<name>]`), in declaration order.
1150    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1151    pub cycles: IndexMap<String, PlanningCycle>,
1152    /// Named new-issue templates (`[templates.<name>]`), in declaration order.
1153    ///
1154    /// Optional and absent by default: a project with no templates writes no
1155    /// section, and the F4 New Issue mode simply offers none.
1156    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1157    pub templates: IndexMap<String, IssueTemplate>,
1158    /// Saved view definitions (`[views.<view-id>]`), in declaration order.
1159    ///
1160    /// Mission `saved-issue-views-01M3C926` (WP01). Omitted when empty, so a
1161    /// repository that has never saved a view gains no key on save. The records
1162    /// are [`crate::views::registry::SavedView`]s: a stable id, a mutable display
1163    /// name, explicit scope metadata, the query plan, and preserved extension
1164    /// fields. Every write goes through
1165    /// [`crate::views::registry::ViewRegistry::commit`].
1166    #[serde(
1167        default,
1168        skip_serializing_if = "crate::views::registry::ViewRegistry::is_empty"
1169    )]
1170    pub views: crate::views::registry::ViewRegistry,
1171    /// Health policy configuration (`[health]`). Omitted when absent.
1172    #[serde(default, skip_serializing_if = "HealthConfig::is_empty")]
1173    pub health: HealthConfig,
1174    /// Stable-ID issue/form templates (`[issue_templates.<id>]`), in
1175    /// declaration order.
1176    ///
1177    /// As distinct from [`Self::templates`] as an ID is from a name: this is the
1178    /// registry behind `tix template` and `tix create --template`, keyed by an
1179    /// immutable ID with an explicit repository/board/team scope, typed defaults
1180    /// and prompts, typed references, and preserved unknown metadata
1181    /// ([`crate::templates::registry`]). The legacy name-keyed `templates` above
1182    /// only pre-fills the TUI New Issue form. Omitted when empty, so a project
1183    /// with no stable-ID templates gains no section and a legacy file
1184    /// round-trips byte-identically.
1185    #[serde(default, skip_serializing_if = "TemplateRegistry::is_empty")]
1186    pub issue_templates: TemplateRegistry,
1187    /// Planning presets and the auto-cycle counter (`[planning]`).
1188    #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
1189    pub planning: PlanningConfig,
1190    /// `[categories]`: the global vocabulary and safe default. Omitted when
1191    /// untouched, so a legacy file gains no key on save.
1192    #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
1193    pub categories: CategoryConfig,
1194    /// `[transitions]`: the workflow policy. Omitted when untouched.
1195    #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
1196    pub transitions: TransitionConfig,
1197    /// `[triage]`: the normal columns that make up the triage inbox.
1198    ///
1199    /// Mission `triage-inbox-review-flow-01M3C600`. The inbox is opt-in: `None` means
1200    /// no section, so no inbox, and every `tix triage` command refuses by naming the
1201    /// missing section. **`tix init` writes one**, with the board columns it needs, so
1202    /// a new repository starts with the inbox on; a legacy `tix.toml` gains no key on
1203    /// save. See [`crate::triage::config`].
1204    #[serde(default, skip_serializing_if = "Option::is_none")]
1205    pub triage: Option<crate::triage::TriageConfig>,
1206    /// Optional `$EDITOR` override for full-file editing.
1207    pub editor: Option<String>,
1208    /// Read-only viewer for the TUI's `v` key (tix #1507).
1209    ///
1210    /// `None` and `Some("auto")` both mean the built-in probe chain — `mdterm`,
1211    /// then `batcat`, `most`, `less`, `more` — and every other value names the
1212    /// program to run directly. Never written when unset, so a legacy file gains
1213    /// no key on save.
1214    #[serde(skip_serializing_if = "Option::is_none")]
1215    pub viewer: Option<String>,
1216    /// Configured AI agent command (opencode, claude, etc.) launched with
1217    /// the issue body as its specification. Mirrors the `editor` resolution
1218    /// chain (explicit → `$TIX_AGENT` → `$AGENT` env → PATH fallback).
1219    pub agent: Option<String>,
1220    /// Persisted runtime state (`[state]`). Tolerated on load for legacy
1221    /// files, never written back to disk (issue #52).
1222    #[serde(skip_serializing)]
1223    pub state: StateConfig,
1224    /// External tracker targets (`[export]`). Omitted when empty, so a legacy
1225    /// file gains no section on save (#1415).
1226    #[serde(default, skip_serializing_if = "ExportConfig::is_empty")]
1227    pub export: ExportConfig,
1228    /// Sparse TUI keybinding overrides (`[keybindings]`).
1229    #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
1230    pub keybindings: KeyBindings,
1231    /// Top-level frontmatter keys this project uses on purpose (`[custom_attributes]`).
1232    ///
1233    /// #1361. `tix` preserves a frontmatter key it does not recognise and warns
1234    /// about it, because an unrecognised key is usually a typo (`titel:`) and a
1235    /// typo that round-trips silently is worse than one that is dropped. A project
1236    /// that *does* use a custom key declares it here once and stops being warned.
1237    ///
1238    /// **Top-level names only**, not dotted paths — the thing being declared is a
1239    /// top-level key, and accepting a path syntax here would imply nested
1240    /// declarations are checked, which they are not.
1241    #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
1242    pub custom_attributes: CustomAttributes,
1243    /// Whether `tix lint` also lints the foreign repositories reachable from
1244    /// this store's `related_issues` path references and merges their warnings.
1245    ///
1246    /// Default `true`. Skipped when `true` so a `tix.toml` that does not set it
1247    /// round-trips byte-identically; only an explicit `false` is written.
1248    #[serde(default = "default_true", skip_serializing_if = "is_true")]
1249    pub lint_foreign_repositories: bool,
1250    /// Whether the TUI opens its "did you know?" tips modal on startup (tix #1632).
1251    ///
1252    /// Default `true`: a session that reaches the TUI shows one tip, then gets out
1253    /// of the way. `tix init` writes the key explicitly, so the setting is
1254    /// discoverable where the rest of the configuration lives, and the modal's `d`
1255    /// key writes `false` back. Skipped when `true` for the same reason
1256    /// [`Self::lint_foreign_repositories`] is, so a file that does not mention it
1257    /// round-trips byte-identically.
1258    #[serde(default = "default_true", skip_serializing_if = "is_true")]
1259    pub tips_on_startup: bool,
1260    /// `[mentions]`: durable `@handle` / `@email` mention tracking.
1261    ///
1262    /// OpenSpec change `issue-mentions`. Absent means the defaults — enabled, under
1263    /// `docs/mentions` — so no legacy file gains a key on save.
1264    #[serde(default, skip_serializing_if = "MentionsConfig::is_default")]
1265    pub mentions: MentionsConfig,
1266    /// `[layout]`: configurable on-disk issue layout.
1267    #[serde(default, skip_serializing_if = "LayoutConfig::is_default")]
1268    pub layout: LayoutConfig,
1269    /// Allowed issue `type:` values (tix #1530 renamed the key from
1270    /// `custom_types`; the old name is still accepted on read).
1271    ///
1272    /// `tix init` writes the default set (see [`DEFAULT_TYPES`]); `tix lint`
1273    /// accepts an issue's `type:` only from this list. `None` falls back to
1274    /// [`DEFAULT_TYPES`], so a repository that never configured it still gets a
1275    /// meaningful check.
1276    #[serde(
1277        default,
1278        skip_serializing_if = "Option::is_none",
1279        alias = "custom_types"
1280    )]
1281    pub types: Option<Vec<String>>,
1282    /// Extra known issue `labels` for this project, beyond the builtins.
1283    ///
1284    /// [`BUILTIN_LABELS`] are always resolved (see [`Self::labels`]), so this key
1285    /// is an **optional extension**: a project that wants to name its own
1286    /// additional labels lists them here. `tix init` does **not** write the key —
1287    /// a builtin needs no key, and emitting a brand-new key would make a fresh
1288    /// `tix.toml` unreadable to a released binary (`deny_unknown_fields`).
1289    /// Omitted when unset, so a legacy file gains no key on save.
1290    ///
1291    /// Unlike [`Self::types`], `tix lint` does **not** reject an undeclared
1292    /// label: labels are free-form across the ecosystem (tracker mirrors, import
1293    /// provenance), and a hard allow-list would flag every one of them.
1294    #[serde(default, skip_serializing_if = "Option::is_none")]
1295    pub labels: Option<Vec<String>>,
1296    /// Per-type accent colors (`[type_colors]`, tix #1530).
1297    ///
1298    /// Maps a type name to a color name. A type with no entry uses the shipped
1299    /// [`DEFAULT_TYPE_COLORS`]. Omitted when empty, so an untouched file gains
1300    /// no table on save.
1301    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1302    pub type_colors: IndexMap<String, String>,
1303    /// Directory holding tag definition files (`tags_dir`, tix #1530).
1304    ///
1305    /// Each `*.md` there names a tag and its `color:`; a tag colors the issue
1306    /// label of the same name in the TUI and the CLI. `None` → the default
1307    /// `docs/tags`. Absent by default, so a file gains no key on save.
1308    #[serde(default, skip_serializing_if = "Option::is_none")]
1309    pub tags_dir: Option<PathBuf>,
1310    /// Child repositories this project coordinates (`[repos]`, tix #1529).
1311    ///
1312    /// A **parent-only** map: a project-management repository names the
1313    /// repositories it manages, one level deep, and each value is that child's
1314    /// project root (the directory holding its `tix.toml`), relative to this
1315    /// file. Absent by default, so a file gains no section on save. The TUI's
1316    /// Repos mode lists these and runs `tix` inside the chosen one.
1317    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1318    pub repos: IndexMap<String, PathBuf>,
1319    /// The parent repository, when this project is itself a managed child
1320    /// (tix #1529).
1321    ///
1322    /// Set here, the TUI's Repos mode offers a limb that runs `tix` in the
1323    /// parent. The parent also sets `TIX_PARENT_REPO` when it spawns a child, so
1324    /// the jump back works even without this key.
1325    #[serde(default, skip_serializing_if = "Option::is_none")]
1326    pub parent_repo: Option<PathBuf>,
1327    /// Project records (`[projects.<project-id>]`), in declaration order.
1328    ///
1329    /// Mission `first-class-projects-01M3BYBW` (WP01). The map key is a stable
1330    /// generated [`ProjectId`] and never a display name (C-002), so renaming a
1331    /// project leaves every issue association and derived figure intact. Omitted
1332    /// entirely when empty, so a project-free repository gains no section on save
1333    /// and a legacy `tix.toml` round-trips byte-identically (C-001). Issue-side
1334    /// associations are the `projects` list in issue frontmatter; this registry is
1335    /// the only place a project's own record lives.
1336    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1337    pub projects: IndexMap<String, ProjectConfiguration>,
1338    /// Epic records (`[epics.<epic-id>]`), in declaration order.
1339    ///
1340    /// Mission `epics-work-items-01M3BYW4` (WP01). The registry key is a stable
1341    /// generated [`EpicId`] and never a display name (C-003), so renaming an epic
1342    /// leaves every member issue's reference and every derived figure intact.
1343    /// Omitted entirely when empty, so an epic-free repository gains no section on
1344    /// save and a legacy `tix.toml` round-trips byte-identically (C-004). Member
1345    /// issues point at an epic through the `epics` list in their frontmatter; this
1346    /// registry is the only place an epic's own record lives. Reference fields
1347    /// (`parent`, `projects`, `milestone`, `cycle`) are **shape**-checked only —
1348    /// resolution and diagnosis belong to the epic service and `tix lint` (C-005).
1349    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1350    pub epics: IndexMap<String, EpicConfiguration>,
1351    /// Initiative records (`[initiatives.<initiative-id>]`), in declaration order.
1352    ///
1353    /// Mission `roadmap-initiatives-01M3C8SX` (WP01). The registry key is a stable
1354    /// generated [`InitiativeId`] and never a display name (C-002), so renaming an
1355    /// initiative leaves every association and derived figure intact. Omitted
1356    /// entirely when empty, so an initiative-free repository gains no section on save
1357    /// and a legacy `tix.toml` round-trips byte-identically (C-001). All writes go
1358    /// through the atomic boundary in [`crate::roadmap::registry`], which validates
1359    /// the whole change before [`Config::save`] touches the file; reference fields
1360    /// (`projects`, `epics`, `milestones`) are **shape**-checked only — resolution and
1361    /// diagnosis belong to the initiative service (WP03) and `tix lint` (C-005).
1362    ///
1363    /// Unlike its two siblings this record preserves unknown metadata rather than
1364    /// refusing it; see [`InitiativeConfiguration`].
1365    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1366    pub initiatives: IndexMap<String, InitiativeConfiguration>,
1367    /// Cycle-schedule definitions (`[cycle_schedules.<schedule-id>]`), in
1368    /// declaration order.
1369    ///
1370    /// Mission `repeating-cycle-schedules-01M3C7W1` (WP01). A **schedule** is not a
1371    /// **cycle**: the legacy, name-keyed `[cycles.<name>]` registry above stays
1372    /// exactly as it is, and issue `cycles` references keep pointing at it (C-002).
1373    /// A schedule is the stable-ID *definition* — cadence, duration, start date,
1374    /// timezone, look-ahead, state — from which cycles are explicitly generated
1375    /// (WP04).
1376    ///
1377    /// The registry key is a stable generated [`crate::cycle_schedule::config::CycleScheduleId`]
1378    /// and never the display name (C-003), so a rename leaves every generated cycle,
1379    /// cursor and closed snapshot intact. Omitted entirely when empty, so a
1380    /// schedule-free repository gains no section on save and a legacy `tix.toml`
1381    /// round-trips byte-identically (C-001). Every field is validated by
1382    /// [`crate::cycle_schedule::config::validate_schedules`] before persistence, with
1383    /// a diagnostic that names the field (C-005).
1384    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1385    pub cycle_schedules:
1386        IndexMap<String, crate::cycle_schedule::config::CycleScheduleConfiguration>,
1387    /// Capacity ceilings (`[capacity.<entry-id>]`), in declaration order.
1388    ///
1389    /// Mission `cycle-capacity-planning-01M3C85C` (WP01). Each entry names exactly one
1390    /// scope — a cycle, a team, or an assignee — and the ceiling it is allowed to commit
1391    /// to, in an estimate unit the WIP registry already declares (C-003, C-004). The
1392    /// entry's key is only a handle: the scope is a field of the record, so "which
1393    /// dimension is this?" is always stated rather than inferred from a table's shape,
1394    /// and [`crate::capacity::config::validate`] refuses an entry that names none or
1395    /// more than one.
1396    ///
1397    /// Omitted entirely when empty, so a capacity-free repository gains no section on
1398    /// save and a legacy `tix.toml` round-trips byte-identically (C-001), and unknown
1399    /// metadata inside an entry is preserved verbatim for a newer `tix`. A closed
1400    /// cycle's capacity is historical evidence: this section holds no snapshot state,
1401    /// and [`crate::capacity::config::ensure_cycle_editable`] is the seam that refuses a
1402    /// configuration edit aimed at a closed cycle (C-008).
1403    #[serde(default, skip_serializing_if = "CapacityConfig::is_empty")]
1404    pub capacity: CapacityConfig,
1405}
1406
1407/// The `[custom_attributes]` section: top-level frontmatter keys this project
1408/// carries on purpose (#1361).
1409///
1410/// A **section** rather than a bare top-level array so there is somewhere to put
1411/// the next option, and so the declaration reads as a named decision rather than as
1412/// one more anonymous list in a file that is mostly named sections.
1413///
1414/// ```toml
1415/// [custom_attributes]
1416/// keys = ["build_id", "reviewed_by"]
1417/// ```
1418#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1419#[serde(default, deny_unknown_fields)]
1420pub struct CustomAttributes {
1421    /// Top-level frontmatter key names. Names only — no dotted paths; the thing
1422    /// being declared is a top-level key, and a path syntax here would imply nested
1423    /// declarations are checked when they are not.
1424    pub keys: Vec<String>,
1425}
1426
1427impl CustomAttributes {
1428    /// Whether anything is declared, for `skip_serializing_if`.
1429    pub fn is_empty(&self) -> bool {
1430        self.keys.is_empty()
1431    }
1432}
1433
1434/// Serde default for a boolean that defaults to `true`.
1435///
1436/// Shared with [`crate::capacity::config::CapacityEntry`], whose `enabled` follows the
1437/// same rule the WIP limits set: writing the default is not writing anything.
1438pub(crate) fn default_true() -> bool {
1439    true
1440}
1441/// `skip_serializing_if` for a boolean whose default is `true`: only an explicit
1442/// `false` is written to disk.
1443#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&bool) -> bool`
1444pub(crate) fn is_true(value: &bool) -> bool {
1445    *value
1446}
1447
1448/// The default issue types: what `tix init` writes into `types`, and what
1449/// `tix lint` accepts when a repository carries no `types`.
1450///
1451/// `default` is the value an issue without a `type:` falls back to (in `[layout]`
1452/// and as an explicit option). `bug`, `open` and `duplicate` are the requested
1453/// baseline, with the usual companions.
1454pub const DEFAULT_TYPES: &[&str] = &[
1455    "default",
1456    "bug",
1457    "feature",
1458    "task",
1459    "improvement",
1460    "documentation",
1461    "question",
1462    "duplicate",
1463    "chore",
1464    "open",
1465];
1466
1467/// Labels tix itself ships and always recognises — no configuration required.
1468///
1469/// `decision-pending` is the label behind the `DECISION:` convention: an open
1470/// question a human must settle before the issue can move. It is a **builtin**,
1471/// not a config default, so every repository resolves it whether or not its
1472/// `tix.toml` names it — and `tix init` does not write a `labels` key for it (a
1473/// new key would be rejected by an older binary's `deny_unknown_fields`).
1474pub const BUILTIN_LABELS: &[&str] = &["decision-pending"];
1475
1476/// A shipped accent color per default type (tix #1530).
1477///
1478/// The color names are the ones [`crate::ui::color::parse`] understands. A type
1479/// absent here — and absent from `[type_colors]` — renders uncolored. `open` and
1480/// `default` are deliberately neutral (gray/white) so a type chip does not
1481/// compete with the priority and blocked markers beside it.
1482pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
1483    ("default", "gray"),
1484    ("bug", "red"),
1485    ("feature", "green"),
1486    ("task", "blue"),
1487    ("improvement", "cyan"),
1488    ("documentation", "yellow"),
1489    ("question", "magenta"),
1490    ("duplicate", "dark_gray"),
1491    ("chore", "dark_gray"),
1492    ("open", "white"),
1493];
1494
1495/// The `[mentions]` section: durable `@handle` / `@email` mention tracking.
1496///
1497/// Default **enabled**, unlike the opt-in `[triage]` section: the feature is a scan that runs
1498/// on an issue write that already happened, and its footprint is bounded on the other side —
1499/// a mention file is created lazily, only once a real mention is found, and a bare `user@host`
1500/// or a `@word` inside code is not a mention. Setting `enabled = false` is the off switch, and
1501/// it stops both the scan and every write.
1502///
1503/// Omitted from the file when it is the default, so a `tix.toml` that has never configured
1504/// mentions round-trips byte-identically and gains no key on save.
1505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1506#[serde(default, deny_unknown_fields)]
1507pub struct MentionsConfig {
1508    /// Whether issue writes scan for mentions and record them.
1509    pub enabled: bool,
1510    /// Directory holding the per-target mention files, relative to the project root unless
1511    /// absolute. `None` is not a state: the default is [`DEFAULT_MENTIONS_DIR`].
1512    pub dir: PathBuf,
1513}
1514
1515/// The directory mention files live in, relative to the project root.
1516pub const DEFAULT_MENTIONS_DIR: &str = "docs/mentions";
1517
1518impl Default for MentionsConfig {
1519    fn default() -> Self {
1520        MentionsConfig {
1521            enabled: true,
1522            dir: PathBuf::from(DEFAULT_MENTIONS_DIR),
1523        }
1524    }
1525}
1526
1527impl MentionsConfig {
1528    /// Whether this is the default, for `skip_serializing_if`.
1529    #[must_use]
1530    pub fn is_default(&self) -> bool {
1531        *self == Self::default()
1532    }
1533
1534    /// The absolute directory the store reads and writes.
1535    ///
1536    /// A relative `dir` resolves against the **project root** — the directory holding
1537    /// `tix.toml` — not the invocation directory, the same rule `issues_dir` follows
1538    /// ([`crate::storage::Store::open`]).
1539    #[must_use]
1540    pub fn resolve_dir(&self, issues_dir: &Path) -> PathBuf {
1541        if self.dir.is_absolute() {
1542            return self.dir.clone();
1543        }
1544        match Config::project_root(issues_dir) {
1545            Some(root) => root.join(&self.dir),
1546            None => self.dir.clone(),
1547        }
1548    }
1549}
1550
1551/// The `[layout]` section: configurable on-disk issue layout.
1552///
1553/// Tokens are validated at config load ([`crate::layout::validate`]). The
1554/// defaults reproduce the documented layout — `{assignee}/{type}/{status}` for
1555/// the directory, `{date}-{slug}.md` for the filename, moving files when a
1556/// templated value changes.
1557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1558#[serde(default, deny_unknown_fields)]
1559pub struct LayoutConfig {
1560    /// Directory template, relative to `issues_dir`.
1561    pub directory: String,
1562    /// Filename template; must end in `.md`.
1563    pub filename: String,
1564    /// Relocate an issue when a templated value changes (status/assignee/type).
1565    pub move_on_change: bool,
1566}
1567
1568impl Default for LayoutConfig {
1569    fn default() -> Self {
1570        LayoutConfig {
1571            directory: "{assignee}/{type}/{status}".to_string(),
1572            // Date first, then the id: a lexicographic listing of the directory
1573            // is then creation order, while the id keeps the name globally
1574            // unique across branches (and disambiguates same-day, same-title
1575            // issues). `number` is deliberately absent — it can collide across
1576            // branches and is not an identity.
1577            filename: "{date}-{id}-{slug}.md".to_string(),
1578            move_on_change: true,
1579        }
1580    }
1581}
1582
1583impl LayoutConfig {
1584    /// Whether this is the default, for `skip_serializing_if`.
1585    #[must_use]
1586    pub fn is_default(&self) -> bool {
1587        *self == Self::default()
1588    }
1589}
1590
1591impl Default for Config {
1592    fn default() -> Self {
1593        Config {
1594            kanban: KanbanSection {
1595                configurations: IndexMap::from([(
1596                    "default".to_string(),
1597                    BoardConfiguration::default(),
1598                )]),
1599            },
1600            issues_dir: PathBuf::from("docs/issues"),
1601            milestones_dir: None,
1602            teams: IndexMap::new(),
1603            cycles: IndexMap::new(),
1604            templates: IndexMap::new(),
1605            views: crate::views::registry::ViewRegistry::new(),
1606            health: HealthConfig::default(),
1607            issue_templates: TemplateRegistry::default(),
1608            planning: PlanningConfig::default(),
1609            categories: CategoryConfig::default(),
1610            transitions: TransitionConfig::default(),
1611            // The bare default has no inbox, and that stays true: the mechanism is
1612            // opt-in, so a config with no `[triage]` section has no inbox and every
1613            // `tix triage` command refuses, naming the section. What changed is the
1614            // *starting point*: `tix init` writes the section (and the board columns it
1615            // needs), so a new repository begins with an inbox rather than with a
1616            // refusal. See [`crate::commands::init`].
1617            triage: None,
1618            editor: None,
1619            viewer: None,
1620            state: StateConfig::default(),
1621            export: ExportConfig::default(),
1622            agent: None,
1623            keybindings: KeyBindings::default(),
1624            custom_attributes: CustomAttributes::default(),
1625            lint_foreign_repositories: true,
1626            tips_on_startup: true,
1627            mentions: MentionsConfig::default(),
1628            layout: LayoutConfig::default(),
1629            types: None,
1630            labels: None,
1631            type_colors: IndexMap::new(),
1632            tags_dir: None,
1633            repos: IndexMap::new(),
1634            parent_repo: None,
1635            projects: IndexMap::new(),
1636            epics: IndexMap::new(),
1637            initiatives: IndexMap::new(),
1638            cycle_schedules: IndexMap::new(),
1639            capacity: CapacityConfig::new(),
1640        }
1641    }
1642}
1643
1644impl Config {
1645    /// The configuration a **new repository** starts with — what `tix init` writes.
1646    ///
1647    /// Deliberately not [`Default`]: that is the fallback for a missing or partial
1648    /// `tix.toml` (and the base hundreds of tests build fixtures on), and it stays
1649    /// minimal. The *starting point* is a product decision: a new repository gets the
1650    /// board [`starter_board`] lays out and a working `[triage]` section, so
1651    /// `tix triage list` works on a fresh checkout instead of refusing and asking for
1652    /// configuration first.
1653    ///
1654    /// The three keys match the board: unreviewed work sits in `triage`, an accept
1655    /// lands in `todo`, a decline in `wontfix`. `deferred_status` is left unset
1656    /// because it defaults to `entry_status`, and a default that is never written is a
1657    /// default that cannot drift from the meaning it stands for.
1658    #[must_use]
1659    pub fn starter() -> Config {
1660        let mut config = Config::default();
1661        config
1662            .kanban
1663            .configurations
1664            .insert("default".to_string(), starter_board());
1665        config.triage = Some(crate::triage::TriageConfig {
1666            entry_status: Some("triage".to_string()),
1667            accepted_status: Some("todo".to_string()),
1668            declined_status: Some("wontfix".to_string()),
1669            deferred_status: None,
1670        });
1671        // `tix init` writes the default issue types explicitly, so a new project
1672        // sees and can edit them; a repository without the key falls back to the
1673        // same list at lint time.
1674        config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
1675        // `labels` is deliberately NOT written: `decision-pending` is a builtin
1676        // (`BUILTIN_LABELS`), so no key is needed, and emitting a brand-new key
1677        // would make a fresh `tix.toml` unreadable to a released binary whose
1678        // `Config` denies unknown fields.
1679        // Ship the tag directory explicitly (tix #1530), so a new project has
1680        // somewhere to drop `docs/tags/*.md` without having to know the key.
1681        config.tags_dir = Some(PathBuf::from("docs/tags"));
1682        config
1683    }
1684
1685    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
1686    /// the `Config` error variant with a human message. Errors name the
1687    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
1688    pub(crate) fn validate(&self) -> Result<()> {
1689        if self.issues_dir.as_os_str().is_empty() {
1690            return Err(TixError::Config("`issues_dir` must not be blank".into()));
1691        }
1692        if self.milestones_dir().as_os_str().is_empty() {
1693            return Err(TixError::Config(
1694                "`milestones_dir` must not be blank".into(),
1695            ));
1696        }
1697        if self.tags_dir().as_os_str().is_empty() {
1698            return Err(TixError::Config("`tags_dir` must not be blank".into()));
1699        }
1700        validate_repos(&self.repos)?;
1701        // The health policy version is a closed set, and a repository naming one
1702        // this build does not implement is refused here rather than at the first
1703        // rollup: a value derived under a formula nobody can name is worse than a
1704        // file that will not load.
1705        self.health.policy()?;
1706        if self
1707            .parent_repo
1708            .as_deref()
1709            .is_some_and(|p| p.as_os_str().is_empty())
1710        {
1711            return Err(TixError::Config("`parent_repo` must not be blank".into()));
1712        }
1713        // A blank `viewer` is a mistake with a clear spelling: the way to ask for
1714        // the probe chain is `auto` (or omitting the key). Rejected here rather
1715        // than silently treated as auto, so a typo cannot quietly change the
1716        // meaning of a saved file.
1717        if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
1718            return Err(TixError::Config(
1719                "`viewer` must not be blank; use `auto` for the default chain".into(),
1720            ));
1721        }
1722        validate_planning(&self.planning)?;
1723        crate::layout::validate(&self.layout)?;
1724        validate_categories(&self.categories)?;
1725        self.effective_keybindings()?;
1726        if self.kanban.configurations.is_empty() {
1727            return Err(TixError::Config(
1728                "`[kanban]` must define at least one configuration".into(),
1729            ));
1730        }
1731        for (name, board) in &self.kanban.configurations {
1732            validate_board(name, board)?;
1733            validate_category_map(name, board, &self.categories)?;
1734        }
1735        for (label, team) in &self.teams {
1736            validate_team(label, team)?;
1737        }
1738        for (name, cycle) in &self.cycles {
1739            validate_cycle(name, cycle)?;
1740        }
1741        for (name, template) in &self.templates {
1742            validate_template(name, template)?;
1743        }
1744        // Project records: a valid stable id as the key, a non-blank unique display
1745        // name, present-but-blank optional text refused, and the shared date rules.
1746        // Project *resolution* (does a view/issue reference an existing project?) is
1747        // not `Config` validation, for the same reason view scopes are not: a
1748        // reference to a deleted project must stay loadable and diagnosable (C-005).
1749        validate_projects(&self.projects)?;
1750        // Epic records: the same structural rules as projects (a valid stable id as
1751        // the key, a non-blank unique display name, present-but-blank optional text
1752        // refused, the shared date rules), plus a positive `weight` and the
1753        // epic-specific reference *shapes*. Epic *resolution* — does a project,
1754        // milestone, cycle, or parent epic exist? — is deliberately not `Config`
1755        // validation, for the same reason project resolution is not: a dangling
1756        // reference caused by a deletion or a hand edit must stay loadable and
1757        // diagnosable (C-005).
1758        validate_epics(&self.epics)?;
1759        // Initiative records: the same structural rules as projects and epics (a
1760        // valid stable id as the key, a non-blank unique display name, the shared
1761        // calendar-date rule), plus the initiative-specific reference *shapes* —
1762        // project/epic ids for `projects`/`epics` and non-blank names for
1763        // `milestones`. Initiative *resolution* — does a referenced project, epic, or
1764        // milestone exist? — is deliberately not `Config` validation, for the same
1765        // reason project/epic resolution is not: a dangling reference caused by a
1766        // deletion or a hand edit must stay loadable and diagnosable (C-005).
1767        validate_initiatives(&self.initiatives)?;
1768        crate::cycle_schedule::config::validate_schedules(&self.cycle_schedules)?;
1769        // `[capacity]`'s rootless half — the entry handle, exactly one scope, the
1770        // ceiling, and the *declared unit* — is validated here, because every
1771        // validate-then-save path (`tix wip unit --remove`, the schedule registry, the MCP
1772        // write tools) validates with this method before it writes: without the unit check
1773        // there, removing a unit a ceiling names would persist a file the next load
1774        // refuses. The half that needs the cycle **records** — resolving a cycle reference
1775        // to a generated cycle's identity — is validated by the loader
1776        // (`Config::load_with_warning`) and by `crate::capacity::config::set_capacity`,
1777        // the only two gates with a project root; see `crate::capacity::config::validate`.
1778        crate::capacity::config::validate(&self.capacity, self)?;
1779        // View records are validated for *structure* here: a blank name, a scope
1780        // that contradicts itself, an id that disagrees with its table key. Project
1781        // *resolution* is deliberately not part of `Config` validation — it needs
1782        // the project registry, and a view pointing at a project that was deleted
1783        // must stay loadable (and diagnosable) rather than making `tix.toml`
1784        // unreadable. The write path refuses it instead
1785        // (`ViewRegistry::commit`, C-008).
1786        self.views
1787            .check_structure()
1788            .map_err(|refusal| TixError::Config(refusal.render()))?;
1789        // Stable-ID templates: scope binding, prompt shape, references and
1790        // preserved metadata. Field-aware errors name `issue_templates.<id>...`.
1791        self.issue_templates.validate(self)?;
1792        // Last, deliberately. `[triage]` names columns from the active board
1793        // *and* from that board's `category_map`, so it can only be judged once
1794        // both are; running it earlier would hand `workflow::resolution` an
1795        // unvalidated vocabulary, which is the `unreachable!` it documents
1796        // (C-002).
1797        crate::triage::config::validate(self)?;
1798        Ok(())
1799    }
1800
1801    /// Resolve sparse user overrides over the built-in TUI registry.
1802    pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
1803        KeyBindings::resolve(&self.keybindings)
1804    }
1805
1806    /// Allocate a [`ProjectId`] that is not already in the registry.
1807    ///
1808    /// Redraws against the stored ids, so a stored id is never handed out a second
1809    /// time. The rest of the never-reused guarantee is carried by the id shape
1810    /// itself (see [`StableId::generate`]); there is no counter persisted in
1811    /// `tix.toml`, so a deletion cannot reset an allocator — the id a deleted
1812    /// project carried stays spent because no draw can repeat one this process
1813    /// already produced.
1814    #[must_use]
1815    pub fn allocate_project_id(&self) -> ProjectId {
1816        self.allocate_project_id_with(ProjectId::generate)
1817    }
1818
1819    /// [`Self::allocate_project_id`] with the draw injected, so the redraw loop
1820    /// itself is testable: a real generator collides so rarely that a test driving
1821    /// it can only ever observe the first iteration.
1822    fn allocate_project_id_with(&self, draw: impl FnMut() -> ProjectId) -> ProjectId {
1823        allocate_stable_id(&self.projects, draw)
1824    }
1825
1826    /// Allocate an [`EpicId`] that is not already in the registry.
1827    ///
1828    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
1829    /// ids, so a stored id is never handed out a second time, and the rest of the
1830    /// never-reused guarantee is carried by the id shape itself
1831    /// ([`StableId::generate`]) — a deleted epic's id stays spent, keeping a member
1832    /// issue's dangling reference diagnosable rather than silently rebound (C-005).
1833    #[must_use]
1834    pub fn allocate_epic_id(&self) -> EpicId {
1835        self.allocate_epic_id_with(EpicId::generate)
1836    }
1837
1838    /// [`Self::allocate_epic_id`] with the draw injected, so the redraw loop itself
1839    /// is testable.
1840    fn allocate_epic_id_with(&self, draw: impl FnMut() -> EpicId) -> EpicId {
1841        allocate_stable_id(&self.epics, draw)
1842    }
1843
1844    /// Allocate an [`InitiativeId`] that is not already in the registry.
1845    ///
1846    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
1847    /// ids, so a stored id is never handed out a second time, and the rest of the
1848    /// never-reused guarantee is carried by the id shape itself
1849    /// ([`StableId::generate`]) — a deleted initiative's id stays spent, keeping the
1850    /// record's identity distinct from any later one.
1851    #[must_use]
1852    pub fn allocate_initiative_id(&self) -> InitiativeId {
1853        self.allocate_initiative_id_with(InitiativeId::generate)
1854    }
1855
1856    /// [`Self::allocate_initiative_id`] with the draw injected, so the redraw loop
1857    /// itself is testable.
1858    fn allocate_initiative_id_with(&self, draw: impl FnMut() -> InitiativeId) -> InitiativeId {
1859        allocate_stable_id(&self.initiatives, draw)
1860    }
1861
1862    /// Allocate a fresh [`crate::cycle_schedule::config::CycleScheduleId`].
1863    ///
1864    /// Mirrors [`Self::allocate_initiative_id`]: the draw is redrawn against the
1865    /// stored ids, so a stored id is never handed out a second time, and the rest of
1866    /// the never-reused guarantee is carried by the id shape itself
1867    /// ([`StableId::generate`]) — a deleted schedule's id stays spent, keeping the
1868    /// record's identity distinct from any later one (C-003).
1869    #[must_use]
1870    pub fn allocate_cycle_schedule_id(&self) -> crate::cycle_schedule::config::CycleScheduleId {
1871        self.allocate_cycle_schedule_id_with(
1872            crate::cycle_schedule::config::CycleScheduleId::generate,
1873        )
1874    }
1875
1876    /// [`Self::allocate_cycle_schedule_id`] with the draw injected, so the redraw loop
1877    /// itself is testable: a real generator collides with a stored id so rarely that
1878    /// the loop is otherwise unverifiable.
1879    fn allocate_cycle_schedule_id_with(
1880        &self,
1881        draw: impl FnMut() -> crate::cycle_schedule::config::CycleScheduleId,
1882    ) -> crate::cycle_schedule::config::CycleScheduleId {
1883        allocate_stable_id(&self.cycle_schedules, draw)
1884    }
1885
1886    /// Restore the canonical declaration order of named configurations,
1887    /// which the TOML parser loses (it sorts table keys). The order comes
1888    /// from `[kanban.<name>]` section headers in the source document.
1889    fn restore_declaration_order(&mut self, contents: &str) {
1890        crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
1891        crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
1892        crate::config::restore_section_order(&mut self.teams, contents, "teams");
1893        crate::config::restore_section_order(&mut self.templates, contents, "templates");
1894        self.views.restore_declaration_order(contents);
1895        crate::config::restore_section_order(
1896            &mut self.issue_templates.templates,
1897            contents,
1898            "issue_templates",
1899        );
1900        crate::config::restore_section_order(&mut self.projects, contents, "projects");
1901        crate::config::restore_section_order(&mut self.epics, contents, "epics");
1902        crate::config::restore_section_order(&mut self.initiatives, contents, "initiatives");
1903        crate::config::restore_section_order(
1904            &mut self.cycle_schedules,
1905            contents,
1906            "cycle_schedules",
1907        );
1908        self.capacity.restore_declaration_order(contents);
1909    }
1910
1911    /// Declared custom top-level frontmatter keys, lowercased for comparison.
1912    ///
1913    /// Lowercased because YAML keys are case-sensitive but nobody has ever
1914    /// intended `Titel` and `titel` to be different keys, and a suppression list
1915    /// that silently fails to match is worse than no list at all.
1916    pub fn custom_attributes(&self) -> Vec<String> {
1917        self.custom_attributes
1918            .keys
1919            .iter()
1920            .map(|k| k.trim().to_ascii_lowercase())
1921            .collect()
1922    }
1923
1924    /// The allowed issue types: the configured `types`, or [`DEFAULT_TYPES`]
1925    /// when unset. Compared case-insensitively by callers.
1926    #[must_use]
1927    pub fn types(&self) -> Vec<String> {
1928        self.types
1929            .clone()
1930            .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
1931    }
1932
1933    /// The pre-#1530 name for [`Self::types`], kept so existing call sites and
1934    /// tests read the same list under the old spelling.
1935    #[must_use]
1936    pub fn custom_types(&self) -> Vec<String> {
1937        self.types()
1938    }
1939
1940    /// The recognised issue labels: [`BUILTIN_LABELS`], plus any extra labels the
1941    /// project declares under `labels` in `tix.toml`.
1942    ///
1943    /// Builtins come first and are always present, so `decision-pending` resolves
1944    /// in a repository that never configured the key — which is why `tix init`
1945    /// does not write one.
1946    #[must_use]
1947    pub fn labels(&self) -> Vec<String> {
1948        let mut out: Vec<String> = BUILTIN_LABELS.iter().map(|s| (*s).to_string()).collect();
1949        if let Some(extra) = &self.labels {
1950            for label in extra {
1951                if !out.contains(label) {
1952                    out.push(label.clone());
1953                }
1954            }
1955        }
1956        out
1957    }
1958
1959    /// The color name for issue `type`, from `[type_colors]` when set for it, else
1960    /// the shipped [`DEFAULT_TYPE_COLORS`] entry. `None` for a type with no color
1961    /// anywhere (an unknown type). Compared case-insensitively (tix #1530).
1962    #[must_use]
1963    pub fn type_color(&self, type_name: &str) -> Option<String> {
1964        let needle = type_name.trim().to_ascii_lowercase();
1965        self.type_colors
1966            .iter()
1967            .find(|(name, _)| name.to_ascii_lowercase() == needle)
1968            .map(|(_, color)| color.clone())
1969            .or_else(|| {
1970                DEFAULT_TYPE_COLORS
1971                    .iter()
1972                    .find(|(name, _)| *name == needle)
1973                    .map(|(_, color)| (*color).to_string())
1974            })
1975    }
1976
1977    /// The attribute list shown and edited in the Config view: the declared
1978    /// `[custom_attributes].keys`, or the built-in attributes tix models when
1979    /// none are declared. Mirrors [`Self::custom_types`] (`configured-or-defaults`).
1980    #[must_use]
1981    pub fn attributes(&self) -> Vec<String> {
1982        if self.custom_attributes.keys.is_empty() {
1983            crate::model::KNOWN_FRONTMATTER_KEYS
1984                .iter()
1985                .filter(|key| **key != "links")
1986                .map(|key| (*key).to_string())
1987                .collect()
1988        } else {
1989            self.custom_attributes.keys.clone()
1990        }
1991    }
1992
1993    /// The resolved milestones directory: the configured value, or the
1994    /// contract default `docs/milestones` when unset.
1995    pub fn milestones_dir(&self) -> &Path {
1996        self.milestones_dir
1997            .as_deref()
1998            .unwrap_or(Path::new("docs/milestones"))
1999    }
2000
2001    /// The resolved tags directory: the configured `tags_dir`, or the contract
2002    /// default `docs/tags` when unset (tix #1530).
2003    pub fn tags_dir(&self) -> &Path {
2004        self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
2005    }
2006
2007    /// The child repositories this project coordinates (`[repos]`, tix #1529).
2008    pub fn repos(&self) -> &IndexMap<String, PathBuf> {
2009        &self.repos
2010    }
2011
2012    /// The parent repository, when this project is a managed child (tix #1529).
2013    pub fn parent_repo(&self) -> Option<&Path> {
2014        self.parent_repo.as_deref()
2015    }
2016
2017    /// Absolutize the paths that are resolved against the project root:
2018    /// an explicitly configured `tags_dir`, every `[repos]` entry, and
2019    /// `parent_repo` (tix #1529/#1530).
2020    ///
2021    /// Only a **relative** configured value is rewritten, so an unset key stays
2022    /// `None` (and is not materialized on save) and an absolute one is left
2023    /// alone. `issues_dir`/`milestones_dir` are resolved elsewhere; this covers
2024    /// the newer keys so the TUI can spawn `tix` in a child from any working
2025    /// directory.
2026    pub fn resolve_repo_paths(&mut self, base: &Path) {
2027        if let Some(path) = self.tags_dir.as_ref() {
2028            if path.is_relative() {
2029                self.tags_dir = Some(base.join(path));
2030            }
2031        }
2032        for path in self.repos.values_mut() {
2033            if path.is_relative() {
2034                *path = base.join(&*path);
2035            }
2036        }
2037        if let Some(path) = self.parent_repo.clone() {
2038            if path.is_relative() {
2039                self.parent_repo = Some(base.join(path));
2040            }
2041        }
2042    }
2043
2044    /// The configuration used by the CLI and other single-config surfaces:
2045    /// the one named `default` if present, else the first declared.
2046    pub fn default_config(&self) -> &BoardConfiguration {
2047        self.kanban
2048            .configurations
2049            .get("default")
2050            .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
2051            .expect("validate() guarantees at least one configuration")
2052    }
2053
2054    /// Named configurations in declaration order (tab order = declaration
2055    /// order, D-02).
2056    pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
2057        self.kanban
2058            .configurations
2059            .iter()
2060            .map(|(name, board)| (name.as_str(), board))
2061    }
2062
2063    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
2064    /// every cleartext token read from the file.
2065    ///
2066    /// Parse and validation failures surface as [`TixError::Config`] with
2067    /// the file's path and (where the TOML parser reports it) line.
2068    pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
2069        let contents = std::fs::read_to_string(path)
2070            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2071        let mut config: Config = toml::from_str(&contents)
2072            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2073        config.restore_declaration_order(&contents);
2074        // File stores win over the legacy tables whenever their directory
2075        // exists. Absent directories mean an existing project: its
2076        // `[kanban.*]`/`[cycles.*]` tables load exactly as before.
2077        let root = path
2078            .parent()
2079            .filter(|p| !p.as_os_str().is_empty())
2080            .unwrap_or_else(|| Path::new("."));
2081        let board_store = BoardStore::new(root);
2082        if board_store.exists() {
2083            let boards = board_store
2084                .list()
2085                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2086            if !boards.is_empty() {
2087                // The board **files** own a board's shape — its columns, its
2088                // categories, its display — and `tix.toml` owns its **limits**:
2089                // C-001 puts the work-in-progress configuration there, and a
2090                // scaffolded repository (`tix init`) keeps its board in
2091                // `docs/boards/<name>.md`. Replacing the section wholesale, which is
2092                // what this did before limits existed, silently dropped them — so a
2093                // `tix wip set` on such a project wrote a limit that nothing ever
2094                // read (found by WP06's MCP test, whose `tix init` repository showed
2095                // no limit at all).
2096                let declared = std::mem::take(&mut config.kanban.configurations);
2097                config.kanban = KanbanSection {
2098                    configurations: boards
2099                        .into_iter()
2100                        .map(|(name, mut board)| {
2101                            if let Some(from_toml) = declared.get(&name) {
2102                                board.wip = from_toml.wip.clone();
2103                            }
2104                            (name, board)
2105                        })
2106                        .collect(),
2107                };
2108            }
2109        }
2110        let cycle_store = CycleStore::new(root);
2111        if cycle_store.exists() {
2112            let cycles = cycle_store
2113                .list()
2114                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2115            config.cycles = cycles
2116                .into_iter()
2117                .map(|record| (record.name.clone(), record.into()))
2118                .collect();
2119        }
2120        if config.kanban.configurations.is_empty() {
2121            config.kanban = KanbanSection {
2122                configurations: IndexMap::from([(
2123                    "default".to_string(),
2124                    BoardConfiguration::default(),
2125                )]),
2126            };
2127        }
2128        config.validate().map_err(|e| at_file(path, e))?;
2129        // The records' half of the capacity section's validation: a cycle reference
2130        // resolves against `docs/cycles/` (or the legacy `[cycles.<name>]` tables when there
2131        // is no store), and the records are what tell a generated cycle's name from a
2132        // hand-written one. `capacity::config::set_capacity` is the other gate, over the
2133        // same records, for every programmatic change (WP01).
2134        crate::capacity::config::validate_cycle_references(&config.capacity, &config, root)
2135            .map_err(|e| at_file(path, e))?;
2136        for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
2137            .into_iter()
2138            .flatten()
2139        {
2140            if remote.token.is_some() {
2141                sink(
2142                    "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
2143                );
2144            }
2145        }
2146        Ok(config)
2147    }
2148
2149    /// Load the config for a project root: `tix.toml` at the root if
2150    /// present, otherwise the defaults. An absent file is *not* an error —
2151    /// the CLI layer decides whether to nag about `tix init`.
2152    pub fn load(project_root: &Path) -> Result<Config> {
2153        let path = project_root.join("tix.toml");
2154        if !path.is_file() {
2155            return Ok(Config::default());
2156        }
2157        Self::load_with_warning(&path, |w| eprintln!("{w}"))
2158    }
2159
2160    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
2161    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
2162    pub fn project_root(cwd: &Path) -> Option<PathBuf> {
2163        let mut dir = Some(cwd.to_path_buf());
2164        while let Some(current) = dir {
2165            if current.join("tix.toml").is_file() {
2166                return Some(current);
2167            }
2168            dir = current.parent().map(Path::to_path_buf);
2169        }
2170        None
2171    }
2172
2173    /// A copy of this config with every token stripped, for writing.
2174    fn sanitized_for_disk(&self) -> Config {
2175        let mut copy = self.clone();
2176        if let Some(gitlab) = copy.export.gitlab.as_mut() {
2177            gitlab.token = None;
2178        }
2179        if let Some(linear) = copy.export.linear.as_mut() {
2180            linear.token = None;
2181        }
2182        copy
2183    }
2184
2185    /// Write `tix.toml` atomically (temp file + rename in the same
2186    /// directory, with an `fsync` before the rename — NFR-006).
2187    ///
2188    /// Tokens are never written to disk, so a round-trip `load` → `save`
2189    /// drops any cleartext token (expected: prefer env vars).
2190    pub fn save(&self, path: &Path) -> Result<()> {
2191        let dir = match path.parent() {
2192            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
2193            _ => PathBuf::from("."),
2194        };
2195        std::fs::create_dir_all(&dir)?;
2196
2197        // A file store that exists is authoritative for its records: write
2198        // them there and leave them out of `tix.toml`. Every other setting
2199        // still round-trips through the TOML file.
2200        let board_store = BoardStore::new(&dir);
2201        let board_store_active = board_store.exists();
2202        if board_store_active {
2203            for (name, board) in &self.kanban.configurations {
2204                let filename = format!("{}.md", crate::slug::slugify(name));
2205                let body = RecordStore::new(board_store.dir()).body_of(&filename);
2206                board_store.save(name, board, body.as_deref())?;
2207            }
2208        }
2209        let cycle_store = CycleStore::new(&dir);
2210        let cycle_store_active = cycle_store.exists();
2211        if cycle_store_active {
2212            for (name, cycle) in &self.cycles {
2213                if is_implicit_default(name, cycle) {
2214                    continue;
2215                }
2216                let filename = format!("{}.md", crate::slug::slugify(name));
2217                let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
2218                cycle_store.save(name, cycle, body.as_deref())?;
2219            }
2220        }
2221
2222        let mut disk = self.sanitized_for_disk();
2223        if board_store_active {
2224            disk.kanban = KanbanSection::default();
2225        }
2226        if cycle_store_active {
2227            disk.cycles.clear();
2228        }
2229        let contents = toml::to_string_pretty(&disk)
2230            .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
2231
2232        let file_name = path
2233            .file_name()
2234            .and_then(|n| n.to_str())
2235            .unwrap_or("tix.toml");
2236        let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
2237
2238        let write_result = (|| -> std::io::Result<()> {
2239            let mut f = std::fs::File::create(&tmp)?;
2240            use std::io::Write;
2241            f.write_all(contents.as_bytes())?;
2242            f.sync_all()?;
2243            Ok(())
2244        })();
2245        if let Err(e) = write_result {
2246            let _ = std::fs::remove_file(&tmp);
2247            return Err(TixError::Io(e));
2248        }
2249
2250        if let Err(e) = std::fs::rename(&tmp, path) {
2251            let _ = std::fs::remove_file(&tmp);
2252            return Err(TixError::Io(e));
2253        }
2254        Ok(())
2255    }
2256}
2257
2258/// Prefix a configuration error with the file it came from.
2259///
2260/// A load failure has to name its source: a program that coordinates repositories
2261/// (tix #1529) reads more than one `tix.toml`, and a message that does not say which one
2262/// was refused is a message nobody can act on. Shared by the two validation gates a load
2263/// runs, so both name the file the same way.
2264fn at_file(path: &Path, error: TixError) -> TixError {
2265    match error {
2266        TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
2267        other => other,
2268    }
2269}
2270
2271/// Redraw a fresh id until it is not already a key of `registry`.
2272///
2273/// Shared by [`Config::allocate_project_id`] and [`Config::allocate_epic_id`]. It is
2274/// the exact half of the never-reused guarantee: a *stored* id is never handed out a
2275/// second time. A deletion cannot reset it, because the allocator holds no state a
2276/// removal could rewind — the remaining half is carried by the id shape itself
2277/// ([`StableId::generate`]).
2278pub(crate) fn allocate_stable_id<I: StableId, V>(
2279    registry: &IndexMap<String, V>,
2280    mut draw: impl FnMut() -> I,
2281) -> I {
2282    loop {
2283        let candidate = draw();
2284        if !registry.contains_key(candidate.as_str()) {
2285            return candidate;
2286        }
2287    }
2288}
2289
2290/// Reorder a named-config `IndexMap` to the declaration order of its
2291/// `[<section>.<name>]` section headers in the source document (D-02:
2292/// declaration order = tab order). The `toml` crate sorts table keys on
2293/// deserialize, so the parser's order is not trustworthy; this returns to the
2294/// document's byte order. Applies to `[kanban.*]`, `[cycles.*]`, `[teams.*]`,
2295/// `[projects.*]`, `[epics.*]`, `[initiatives.*]`, `[cycle_schedules.*]` and
2296/// `[views.*]` (the last through
2297/// its own wrapper). Names are unquoted when the header used a quoted key (e.g.
2298/// `[cycles."Cycle 1"]`).
2299pub(crate) fn restore_section_order<V: Clone>(
2300    map: &mut IndexMap<String, V>,
2301    contents: &str,
2302    section: &str,
2303) {
2304    let mut order: Vec<String> = Vec::new();
2305    for line in contents.lines() {
2306        let line = line.trim();
2307        if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
2308            && let Some((head, key)) = rest.split_once('.')
2309            && head.trim() == section
2310            && !key.trim().contains('.')
2311            && let Some(name) = unquote_key(key.trim())
2312            && !order.iter().any(|n| n == name)
2313        {
2314            order.push(name.to_string());
2315        }
2316    }
2317    if order.is_empty() {
2318        return;
2319    }
2320    let mut ordered = IndexMap::new();
2321    for name in &order {
2322        if let Some(value) = map.shift_remove(name) {
2323            ordered.insert(name.clone(), value);
2324        }
2325    }
2326    ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
2327    *map = ordered;
2328}
2329
2330/// Strip a single pair of surrounding single or double quotes from a TOML
2331/// table-header key, when present.
2332fn unquote_key(key: &str) -> Option<&str> {
2333    if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
2334        Some(inner)
2335    } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
2336        Some(inner)
2337    } else {
2338        Some(key)
2339    }
2340}
2341
2342/// Validation for the `[planning]` section (NFR-003): the counter must be
2343/// positive once the section is present.
2344fn validate_planning(planning: &PlanningConfig) -> Result<()> {
2345    if planning.cycle_number == 0 {
2346        return Err(TixError::Config(
2347            "`planning.cycle_number` must be at least 1".into(),
2348        ));
2349    }
2350    Ok(())
2351}
2352
2353/// `[repos]`: a name and a path, neither blank (tix #1529).
2354///
2355/// The depth rule is structural rather than a check here: a parent lists only
2356/// its own direct children, and a child reaches the parent through
2357/// `parent_repo`, so a two-level chain is two processes, never a nested walk.
2358fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
2359    for (name, path) in repos {
2360        if name.trim().is_empty() {
2361            return Err(TixError::Config(
2362                "`[repos]` entry names must not be blank".into(),
2363            ));
2364        }
2365        if path.as_os_str().is_empty() {
2366            return Err(TixError::Config(format!(
2367                "`repos.{name}` must name the repository path"
2368            )));
2369        }
2370    }
2371    Ok(())
2372}
2373
2374/// Per-team validation (NFR-003): non-blank label, non-empty members, unique
2375/// member identities.
2376fn validate_team(label: &str, team: &Team) -> Result<()> {
2377    if label.trim().is_empty() {
2378        return Err(TixError::Config("`teams.<label>` must not be blank".into()));
2379    }
2380    if team.members.is_empty() {
2381        return Err(TixError::Config(format!(
2382            "`teams.{label}.members` must not be empty"
2383        )));
2384    }
2385    let mut seen: HashSet<&str> = HashSet::new();
2386    for member in &team.members {
2387        if !seen.insert(member.as_str()) {
2388            return Err(TixError::Config(format!(
2389                "`teams.{label}.members` must be unique; found duplicate `{member}`"
2390            )));
2391        }
2392    }
2393    Ok(())
2394}
2395
2396/// The key of a `[<section>.<id>]` registry must be a valid stable id.
2397///
2398/// Shared by `[projects.<project-id>]`, `[epics.<epic-id>]`,
2399/// `[initiatives.<initiative-id>]` and `[cycle_schedules.<schedule-id>]`: the rule
2400/// that keeps a rename from breaking a reference — and the key safe as a TOML table
2401/// header — lives on [`StableId`], so this only names the section the error belongs
2402/// to.
2403pub(crate) fn validate_registry_key<I: StableId>(section: &str, id: &str) -> Result<()> {
2404    I::new(id).map_err(|err| TixError::Config(format!("`{section}`: {err}")))?;
2405    Ok(())
2406}
2407
2408/// The display-name rules shared by the named registries.
2409///
2410/// A name is required (an unnamed record cannot be selected), must not be padded (a
2411/// padded name selects differently from how it looks, exactly as a padded id does),
2412/// and must be unique within its registry, compared **case-folded**: the point is
2413/// that a user typing a name selects exactly one record, and a user does not tell
2414/// `Platform` from `platform`. The stored spelling is kept verbatim. `noun` is the
2415/// word the error uses for the kind of record (`project`, `epic`).
2416pub(crate) fn validate_display_name(
2417    section: &str,
2418    id: &str,
2419    name: &str,
2420    noun: &str,
2421    seen: &mut HashSet<String>,
2422) -> Result<()> {
2423    if name.trim().is_empty() {
2424        return Err(TixError::Config(format!(
2425            "`{section}.{id}.name` must not be blank"
2426        )));
2427    }
2428    if name.trim() != name {
2429        return Err(TixError::Config(format!(
2430            "`{section}.{id}.name` has leading or trailing whitespace"
2431        )));
2432    }
2433    if !seen.insert(name.to_lowercase()) {
2434        return Err(TixError::Config(format!(
2435            "`{section}.{id}.name` (`{name}`) must be unique; another {noun} already uses it"
2436        )));
2437    }
2438    Ok(())
2439}
2440
2441/// Optional text that is present but blank is a typo, and silence about it is the one
2442/// thing a config loader should not offer. Shared by the named registries, including
2443/// the name-keyed reference fields (`lead`, `owner`, `milestone`, `cycle`).
2444fn validate_optional_text(section: &str, id: &str, fields: &[(&str, Option<&str>)]) -> Result<()> {
2445    for (field, value) in fields {
2446        if value.is_some_and(|v| v.trim().is_empty()) {
2447            return Err(TixError::Config(format!(
2448                "`{section}.{id}.{field}` must not be blank when present"
2449            )));
2450        }
2451    }
2452    Ok(())
2453}
2454
2455/// The shared date rule for a registry record: `YYYY-MM-DD`, with
2456/// `due_date >= start_date`, via [`crate::model::validate_issue_dates`], wrapped so
2457/// the message names the record the way the rest of this module names its keys.
2458fn validate_record_dates(
2459    section: &str,
2460    id: &str,
2461    start_date: Option<&str>,
2462    due_date: Option<&str>,
2463) -> Result<()> {
2464    crate::model::validate_issue_dates(start_date, due_date)
2465        .map_err(|err| TixError::Config(format!("`{section}.{id}`: {err}")))
2466}
2467
2468/// Per-project validation for `[projects.<project-id>]` (FR-001, C-001, C-002).
2469///
2470/// Every rule here is the shared one: the key must be a valid stable [`ProjectId`]
2471/// (that is what keeps a rename from breaking an association, and what keeps the key
2472/// safe as a TOML table header); the display `name` is required, unpadded and unique
2473/// within the registry, since a user types the name and two projects answering to one
2474/// name would be ambiguous; optional text fields are refused when present but blank;
2475/// dates go through the shared rule. See the helpers for the reasoning.
2476fn validate_projects(projects: &IndexMap<String, ProjectConfiguration>) -> Result<()> {
2477    let mut seen_names: HashSet<String> = HashSet::new();
2478    for (id, project) in projects {
2479        validate_registry_key::<ProjectId>("projects", id)?;
2480        validate_display_name("projects", id, &project.name, "project", &mut seen_names)?;
2481        validate_optional_text(
2482            "projects",
2483            id,
2484            &[
2485                ("description", project.description.as_deref()),
2486                ("status", project.status.as_deref()),
2487                ("lead", project.lead.as_deref()),
2488            ],
2489        )?;
2490        validate_record_dates(
2491            "projects",
2492            id,
2493            project.start_date.as_deref(),
2494            project.due_date.as_deref(),
2495        )?;
2496    }
2497    Ok(())
2498}
2499
2500/// Per-epic validation for `[epics.<epic-id>]` (FR-001, FR-003, C-002, C-003, C-004).
2501///
2502/// The structural half is exactly the project registry's, through the shared helpers:
2503/// a valid stable [`EpicId`] key, a non-blank unique display name, present-but-blank
2504/// optional text refused, and the shared date rule — plus, new here, a **positive**
2505/// `weight`. A non-positive weight is a typo with a clear intent, and a relative size
2506/// has no meaning at or below zero.
2507///
2508/// The reference fields are validated for **shape** only. A `parent` must be a
2509/// well-formed [`EpicId`] and must not name the record's own key; every `projects`
2510/// entry must be a well-formed [`ProjectId`]; `milestone` and `cycle` must be
2511/// non-blank names — the same name-keyed references the existing registries use.
2512/// Whether those targets **exist** is deliberately not decided here: cross-record
2513/// resolution belongs to the epic service, and a parent that names an epic which was
2514/// deleted (or a project that never existed) must stay loadable and diagnosable
2515/// (C-005). A cycle across several parent records is likewise not checkable from a
2516/// single record; it belongs to the service and to `tix lint`.
2517fn validate_epics(epics: &IndexMap<String, EpicConfiguration>) -> Result<()> {
2518    let mut seen_names: HashSet<String> = HashSet::new();
2519    for (id, epic) in epics {
2520        validate_registry_key::<EpicId>("epics", id)?;
2521        validate_display_name("epics", id, &epic.name, "epic", &mut seen_names)?;
2522        validate_optional_text(
2523            "epics",
2524            id,
2525            &[
2526                ("description", epic.description.as_deref()),
2527                ("status", epic.status.as_deref()),
2528                ("owner", epic.owner.as_deref()),
2529                ("milestone", epic.milestone.as_deref()),
2530                ("cycle", epic.cycle.as_deref()),
2531            ],
2532        )?;
2533        validate_record_dates(
2534            "epics",
2535            id,
2536            epic.start_date.as_deref(),
2537            epic.due_date.as_deref(),
2538        )?;
2539        if let Some(weight) = epic.weight
2540            && weight <= 0
2541        {
2542            return Err(TixError::Config(format!(
2543                "`epics.{id}.weight` must be a positive integer; got {weight}"
2544            )));
2545        }
2546        if let Some(parent) = epic.parent.as_deref() {
2547            EpicId::new(parent)
2548                .map_err(|err| TixError::Config(format!("`epics.{id}.parent`: {err}")))?;
2549            if parent == id {
2550                return Err(TixError::Config(format!(
2551                    "`epics.{id}.parent` must not name the epic itself"
2552                )));
2553            }
2554        }
2555        for reference in &epic.projects {
2556            ProjectId::new(reference)
2557                .map_err(|err| TixError::Config(format!("`epics.{id}.projects`: {err}")))?;
2558        }
2559    }
2560    Ok(())
2561}
2562
2563/// Every field name [`InitiativeConfiguration`] declares.
2564///
2565/// This is the set serde itself treats as **known** for the record — the fields that
2566/// survive through the typed struct rather than being swept into the flattened
2567/// `extensions` map. It must stay in step with `InitiativeConfiguration`: add a field
2568/// there and add it here, or a later shadowing check would let the new name through.
2569/// It is listed in one place, and checked in [`validate_initiatives`], because that is
2570/// the single validation every write goes through.
2571const INITIATIVE_KNOWN_FIELDS: &[&str] = &[
2572    "name",
2573    "outcome",
2574    "owner",
2575    "status",
2576    "priority",
2577    "labels",
2578    "target_date",
2579    "projects",
2580    "epics",
2581    "milestones",
2582];
2583
2584/// Per-initiative validation for `[initiatives.<initiative-id>]` (FR-001, C-001, C-002).
2585///
2586/// The structural half is the project and epic registries' own, through the shared
2587/// helpers: a valid stable [`InitiativeId`] key, a non-blank unique (case-folded)
2588/// display name, present-but-blank optional text refused, and the shared calendar
2589/// rule for `target_date` ([`crate::model::validate_date`] — the same rule issues
2590/// use, so the format has one owner). Each `labels` entry must be non-blank.
2591///
2592/// The reference fields are validated for **shape** only. Every `projects` entry must
2593/// be a well-formed [`ProjectId`] and every `epics` entry a well-formed [`EpicId`],
2594/// because that is what a registry must hold to load; every `milestones` entry must
2595/// be a non-blank **name**, the name-keyed reference the existing milestone registry
2596/// uses. Whether those targets **exist** is deliberately not decided here:
2597/// cross-record resolution belongs to the initiative service (WP03) and `tix lint`,
2598/// and a dangling reference caused by a deletion must stay loadable and diagnosable
2599/// (C-005). Repeated entries are not refused here — the write path collapses them —
2600/// because identity is the concern, not redundancy.
2601///
2602/// A preserved extension key must not **shadow** a built-in field. A file can never
2603/// deliver one — serde matches the known field first, so the extension map is only
2604/// ever the leftovers — but the public write path can inject one through
2605/// [`crate::roadmap::registry::InitiativeExtensions`], and `toml` would then serialize
2606/// the same table key twice with no duplicate detection: the write would appear to
2607/// succeed while the next load failed on `duplicate key`, diverging the file from the
2608/// caller's in-memory config. Refusing it here is what keeps
2609/// "an accepted change persists the complete record" true.
2610fn validate_initiatives(initiatives: &IndexMap<String, InitiativeConfiguration>) -> Result<()> {
2611    let mut seen_names: HashSet<String> = HashSet::new();
2612    for (id, initiative) in initiatives {
2613        validate_registry_key::<InitiativeId>("initiatives", id)?;
2614        validate_display_name(
2615            "initiatives",
2616            id,
2617            &initiative.name,
2618            "initiative",
2619            &mut seen_names,
2620        )?;
2621        validate_optional_text(
2622            "initiatives",
2623            id,
2624            &[
2625                ("outcome", initiative.outcome.as_deref()),
2626                ("owner", initiative.owner.as_deref()),
2627                ("status", initiative.status.as_deref()),
2628            ],
2629        )?;
2630        for key in initiative.extensions.keys() {
2631            if INITIATIVE_KNOWN_FIELDS.contains(&key) {
2632                return Err(TixError::Config(format!(
2633                    "`initiatives.{id}.{key}` collides with a built-in field; rename the extension key"
2634                )));
2635            }
2636        }
2637        if let Some(date) = initiative.target_date.as_deref() {
2638            if !crate::model::validate_date(date) {
2639                return Err(TixError::Config(format!(
2640                    "`initiatives.{id}.target_date` (`{date}`) must be a `YYYY-MM-DD` calendar date"
2641                )));
2642            }
2643        }
2644        for label in &initiative.labels {
2645            if label.trim().is_empty() {
2646                return Err(TixError::Config(format!(
2647                    "`initiatives.{id}.labels` must not contain a blank value"
2648                )));
2649            }
2650        }
2651        for reference in &initiative.projects {
2652            ProjectId::new(reference)
2653                .map_err(|err| TixError::Config(format!("`initiatives.{id}.projects`: {err}")))?;
2654        }
2655        for reference in &initiative.epics {
2656            EpicId::new(reference)
2657                .map_err(|err| TixError::Config(format!("`initiatives.{id}.epics`: {err}")))?;
2658        }
2659        for reference in &initiative.milestones {
2660            if reference.trim().is_empty() {
2661                return Err(TixError::Config(format!(
2662                    "`initiatives.{id}.milestones` must not contain a blank value"
2663                )));
2664            }
2665        }
2666    }
2667    Ok(())
2668}
2669
2670/// Per-cycle validation (NFR-003): non-blank name, parseable dates, and
2671/// `due_date >= start_date` when both are present.
2672fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
2673    if name.trim().is_empty() {
2674        return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
2675    }
2676    if let Some(start) = &cycle.start_date {
2677        if !crate::model::validate_date(start) {
2678            return Err(TixError::Config(format!(
2679                "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2680            )));
2681        }
2682    }
2683    if let Some(due) = &cycle.due_date {
2684        if !crate::model::validate_date(due) {
2685            return Err(TixError::Config(format!(
2686                "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2687            )));
2688        }
2689        if let Some(start) = &cycle.start_date
2690            && due.as_str() < start.as_str()
2691        {
2692            return Err(TixError::Config(format!(
2693                "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2694            )));
2695        }
2696    }
2697    Ok(())
2698}
2699
2700/// Per-template validation for `[templates.<name>]` (`tui-new-issue-mode`).
2701///
2702/// The rules exist because of how a template reaches the form: `labels`,
2703/// `cycles` and `milestones` are joined with `,` into their single text rows and
2704/// split again at submit, so a value that itself contains a comma would silently
2705/// become two values the user never typed. A blank label is refused for the same
2706/// reason the form filters blanks — a template that "sets" an empty label sets
2707/// nothing. Dates follow the cycle rules verbatim.
2708fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
2709    if name.trim().is_empty() {
2710        return Err(TixError::Config(
2711            "`templates` must not contain a blank name".into(),
2712        ));
2713    }
2714    for (field, values) in [
2715        ("labels", &template.labels),
2716        ("cycles", &template.cycles),
2717        ("milestones", &template.milestones),
2718    ] {
2719        for value in values {
2720            if value.trim().is_empty() {
2721                return Err(TixError::Config(format!(
2722                    "`templates.{name}.{field}` must not contain a blank value"
2723                )));
2724            }
2725            if value.contains(',') {
2726                return Err(TixError::Config(format!(
2727                    "`templates.{name}.{field}` value `{value}` must not contain a comma \
2728                     — the form joins these with `,`"
2729                )));
2730            }
2731        }
2732    }
2733    if let Some(start) = &template.start_date
2734        && !crate::model::validate_date(start)
2735    {
2736        return Err(TixError::Config(format!(
2737            "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2738        )));
2739    }
2740    if let Some(due) = &template.due_date {
2741        if !crate::model::validate_date(due) {
2742            return Err(TixError::Config(format!(
2743                "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2744            )));
2745        }
2746        if let Some(start) = &template.start_date
2747            && due.as_str() < start.as_str()
2748        {
2749            return Err(TixError::Config(format!(
2750                "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2751            )));
2752        }
2753    }
2754    Ok(())
2755}
2756
2757/// Per-configuration validation (NFR-003): non-empty unique non-blank
2758/// columns, `done_column` ⊆ columns, `hidden_columns` ⊆ columns.
2759/// The `[categories]` section's own rules (contract §2, §4).
2760///
2761/// Both are refusals of a *default*, which is the escalation the contract names:
2762/// a default naming a category outside the vocabulary is a typo, and a default
2763/// that resolves to `completed` would silently mark every unmapped column done.
2764fn validate_categories(categories: &CategoryConfig) -> Result<()> {
2765    if categories.order.is_empty() {
2766        return Err(TixError::Config(
2767            "`categories.order` must not be empty — it is the global vocabulary".into(),
2768        ));
2769    }
2770    for key in &categories.order {
2771        if !CATEGORY_KEYS.contains(&key.as_str()) {
2772            return Err(TixError::Config(format!(
2773                "unknown_category: `categories.order` names `{key}`, which is not in the \
2774                 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
2775                 board maps columns into it and does not define it.",
2776                CATEGORY_KEYS.join(", ")
2777            )));
2778        }
2779    }
2780    if !categories.contains(&categories.default) {
2781        return Err(TixError::Config(format!(
2782            "unknown_category: `categories.default` is `{}`, which is not in \
2783             `categories.order`",
2784            categories.default
2785        )));
2786    }
2787    if categories.default == COMPLETE_CATEGORY {
2788        return Err(TixError::Config(format!(
2789            "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
2790             without an explicit mapping would resolve to complete, which changes \
2791             completion meaning by accident. Map the completing column explicitly \
2792             instead."
2793        )));
2794    }
2795    Ok(())
2796}
2797
2798/// Per-board mapping validation (contract §3).
2799///
2800/// A board either has a completion category or is an explicit configuration
2801/// error; there is no silent "nothing completes". A *missing* mapping is never an
2802/// error — it resolves by the safe default — because erroring would reject every
2803/// existing custom configuration (NFR-003, C-007).
2804fn validate_category_map(
2805    name: &str,
2806    board: &BoardConfiguration,
2807    categories: &CategoryConfig,
2808) -> Result<()> {
2809    for (column, key) in &board.category_map {
2810        if !board.columns.iter().any(|c| c == column) {
2811            return Err(TixError::Config(format!(
2812                "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
2813                 `{column}`, which is not one of the board's columns ({})",
2814                board.columns.join(", ")
2815            )));
2816        }
2817        if !categories.contains(key) {
2818            return Err(TixError::Config(format!(
2819                "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
2820                 `{key}`, which is not in the vocabulary ({})",
2821                categories.order.join(", ")
2822            )));
2823        }
2824    }
2825    // Only a board that *declares* a map is held to naming a completion column. A
2826    // board with no map gets `done_column` -> `completed` in `category_for`, so
2827    // counting completions here would reject every legacy config.
2828    if board.category_map.is_empty() {
2829        return Ok(());
2830    }
2831    let completers: Vec<&String> = board
2832        .category_map
2833        .iter()
2834        .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
2835        .map(|(column, _)| column)
2836        .collect();
2837    match completers.as_slice() {
2838        [] => Err(TixError::Config(format!(
2839            "missing_completion_category: `kanban.{name}.category_map` maps no column \
2840             to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
2841             is ever complete — map exactly one column to it.",
2842            board.category_map.len()
2843        ))),
2844        [_only] => Ok(()),
2845        many => Err(TixError::Config(format!(
2846            "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
2847             ({}); exactly one column may complete work",
2848            many.len(),
2849            many.iter()
2850                .map(|c| c.as_str())
2851                .collect::<Vec<_>>()
2852                .join(", ")
2853        ))),
2854    }
2855}
2856
2857fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
2858    if board.columns.is_empty() {
2859        return Err(TixError::Config(format!(
2860            "`kanban.{name}.columns` must not be empty"
2861        )));
2862    }
2863    if board.columns.iter().any(|c| c.trim().is_empty()) {
2864        return Err(TixError::Config(format!(
2865            "`kanban.{name}.columns` must not contain blank names"
2866        )));
2867    }
2868    let mut seen: HashSet<&str> = HashSet::new();
2869    for column in &board.columns {
2870        if !seen.insert(column.as_str()) {
2871            return Err(TixError::Config(format!(
2872                "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
2873            )));
2874        }
2875    }
2876    if let Some(done) = &board.done_column
2877        && !board.columns.contains(done)
2878    {
2879        return Err(TixError::Config(format!(
2880            "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
2881        )));
2882    }
2883    for hidden in &board.hidden_columns {
2884        if !board.columns.contains(hidden) {
2885            return Err(TixError::Config(format!(
2886                "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
2887            )));
2888        }
2889    }
2890    if board.title_lines == 0 {
2891        return Err(TixError::Config(format!(
2892            "`kanban.{name}.title_lines` must be at least 1"
2893        )));
2894    }
2895    // The WIP schema validates itself against this board's own columns, so a limit
2896    // can only ever be attached to a column the board declares (T001/T003).
2897    board.wip.validate(name, &board.columns)?;
2898    Ok(())
2899}
2900
2901#[cfg(test)]
2902mod tests {
2903    use super::*;
2904    use std::path::PathBuf;
2905    use tempfile::TempDir;
2906
2907    use crate::templates::{
2908        PromptKind, ReferenceKind, Template, TemplateDefaults, TemplatePrompt, TemplateReference,
2909        TemplateScope,
2910    };
2911
2912    /// The canonical legacy `tix.toml`: `toml`'s pretty form, with no
2913    /// board field that already equals the loader's value and no empty
2914    /// `[export]`. A save must reproduce it byte-for-byte (#1415).
2915    const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
2916
2917[kanban.default]
2918columns = [
2919    "todo",
2920    "in progress",
2921    "in review",
2922    "done",
2923]
2924"#;
2925
2926    fn write_toml(dir: &Path, contents: &str) -> PathBuf {
2927        let path = dir.join("tix.toml");
2928        std::fs::write(&path, contents).unwrap();
2929        path
2930    }
2931
2932    #[test]
2933    fn starter_config_carries_a_working_inbox() {
2934        let config = Config::starter();
2935        config
2936            .validate()
2937            .expect("the config `tix init` writes must load");
2938
2939        let board = config.default_config();
2940        for column in [
2941            "triage",
2942            "todo",
2943            "in progress",
2944            "in review",
2945            "done",
2946            "wontfix",
2947        ] {
2948            assert!(
2949                board.columns.iter().any(|c| c == column),
2950                "the starter board is missing `{column}`: {:?}",
2951                board.columns
2952            );
2953        }
2954
2955        let triage = config
2956            .triage
2957            .as_ref()
2958            .expect("the starter config has an inbox");
2959        assert_eq!(triage.entry_status.as_deref(), Some("triage"));
2960        assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
2961        assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
2962        // Left unset: the default *is* the entry column, and a default that is never
2963        // written cannot drift from the meaning it stands for.
2964        assert_eq!(triage.effective_deferred_status(), Some("triage"));
2965
2966        // The one mapping triage requires, plus the completion column.
2967        assert_eq!(
2968            board.category_map.get("wontfix").map(String::as_str),
2969            Some("canceled")
2970        );
2971        assert_eq!(
2972            board.category_map.get("done").map(String::as_str),
2973            Some("completed")
2974        );
2975        // The working columns stay **unmapped** on purpose: declaring a map makes the
2976        // board an explicit policy, and adjacency then refuses a *backwards* move with
2977        // no override to offer — `in progress -> todo` is a move people make daily.
2978        // Left out, they all resolve to the same safe default, so movement among them
2979        // stays free while `done`/`wontfix` remain terminal facts.
2980        for column in ["triage", "todo", "in progress", "in review"] {
2981            assert!(
2982                !board.category_map.contains_key(column),
2983                "`{column}` must stay unmapped, or adjacency will refuse work moving back"
2984            );
2985        }
2986    }
2987
2988    #[test]
2989    fn repos_and_parent_repo_load_round_trip_and_resolve() {
2990        let tmp = TempDir::new().unwrap();
2991        // `parent_repo` must precede the `[repos]` table, or TOML would file it
2992        // inside that table.
2993        let raw = "issues_dir = \"docs/issues\"\n\
2994                   parent_repo = \"../parent\"\n\n\
2995                   [repos]\n\
2996                   tix = \"../tix\"\n\
2997                   website = \"child/website\"\n";
2998        let path = write_toml(tmp.path(), raw);
2999        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3000        assert_eq!(cfg.repos.len(), 2);
3001        assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
3002        assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
3003        assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
3004
3005        // A save keeps both, and they re-load identically.
3006        cfg.save(&path).unwrap();
3007        let reread = Config::load_with_warning(&path, |_| {}).unwrap();
3008        assert_eq!(reread.repos, cfg.repos, "repos round-trip");
3009        assert_eq!(
3010            reread.parent_repo(),
3011            cfg.parent_repo(),
3012            "parent round-trips"
3013        );
3014
3015        // Relative paths resolve against a base, absolute ones are left alone.
3016        let mut resolved = cfg.clone();
3017        resolved.resolve_repo_paths(Path::new("/root"));
3018        assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
3019        assert_eq!(
3020            resolved.repos["website"],
3021            PathBuf::from("/root/child/website")
3022        );
3023        assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
3024        let mut absolute = Config::default();
3025        absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
3026        absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
3027        absolute.resolve_repo_paths(Path::new("/root"));
3028        assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
3029        assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
3030    }
3031
3032    #[test]
3033    fn types_rename_and_type_colors() {
3034        let tmp = TempDir::new().unwrap();
3035        // The pre-#1530 key still loads (serde alias) but serializes as `types`.
3036        let path = write_toml(
3037            tmp.path(),
3038            "issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
3039        );
3040        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3041        assert_eq!(cfg.types(), vec!["bug", "epic"]);
3042        cfg.save(&path).unwrap();
3043        let after = std::fs::read_to_string(&path).unwrap();
3044        assert!(after.contains("types = ["), "{after}");
3045        assert!(!after.contains("custom_types"), "{after}");
3046
3047        // Every shipped type has a default color; `[type_colors]` overrides it,
3048        // matched case-insensitively. An unknown type has none.
3049        let defaults = Config::default();
3050        assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
3051        assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
3052        assert_eq!(defaults.type_color("nonsense"), None);
3053        let mut overridden = Config::default();
3054        overridden.type_colors.insert("bug".into(), "cyan".into());
3055        assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
3056    }
3057
3058    #[test]
3059    fn a_blank_repo_path_is_rejected() {
3060        let tmp = TempDir::new().unwrap();
3061        let path = write_toml(
3062            tmp.path(),
3063            "issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
3064        );
3065        let err = Config::load_with_warning(&path, |_| {})
3066            .unwrap_err()
3067            .to_string();
3068        assert!(err.contains("repos.tix"), "got {err}");
3069    }
3070
3071    #[test]
3072    fn an_empty_repos_section_is_not_written() {
3073        let tmp = TempDir::new().unwrap();
3074        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3075        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3076        cfg.save(&path).unwrap();
3077        let after = std::fs::read_to_string(&path).unwrap();
3078        assert!(
3079            !after.contains("[repos]") && !after.contains("parent_repo"),
3080            "an untouched config gains no repos keys on save: {after}"
3081        );
3082    }
3083
3084    #[test]
3085    fn defaults_match_contract() {
3086        let c = Config::default();
3087        let board = c.default_config();
3088        assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
3089        assert_eq!(board.done_column.as_deref(), Some("done"));
3090        assert_eq!(board.hidden_columns, Vec::<String>::new());
3091        assert!(!board.show_mismatch);
3092        assert!(board.wrap_titles);
3093        assert_eq!(board.title_lines, 3);
3094        assert_eq!(board.card_order, [] as [std::string::String; 0]);
3095        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3096        assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
3097        assert_eq!(c.editor, None);
3098        assert_eq!(c.state.next_number, 0);
3099        assert_eq!(c.export.gitlab, None);
3100        assert_eq!(c.export.linear, None);
3101    }
3102
3103    /// `decision-pending` is a builtin: it resolves with no `labels` key at all,
3104    /// and `tix init` writes no key for it.
3105    #[test]
3106    fn decision_pending_is_a_builtin_label_that_needs_no_key() {
3107        let bare = Config::default();
3108        assert_eq!(bare.labels, None);
3109        assert_eq!(bare.labels(), vec!["decision-pending".to_string()]);
3110
3111        // The starting point does not declare it either, so `tix init` emits no
3112        // `labels` key.
3113        let starter = Config::starter();
3114        assert_eq!(starter.labels, None);
3115        assert_eq!(starter.labels(), vec!["decision-pending".to_string()]);
3116        for written in [
3117            toml::to_string_pretty(&bare).unwrap(),
3118            toml::to_string_pretty(&starter).unwrap(),
3119        ] {
3120            assert!(
3121                !written.contains("labels ="),
3122                "no labels key is written:\n{written}"
3123            );
3124        }
3125
3126        // A project may declare extras; they follow the builtins, deduplicated.
3127        let configured = Config {
3128            labels: Some(vec!["decision-pending".into(), "spec-kitty".into()]),
3129            ..Config::default()
3130        };
3131        assert_eq!(
3132            configured.labels(),
3133            vec!["decision-pending".to_string(), "spec-kitty".to_string()]
3134        );
3135    }
3136
3137    #[test]
3138    fn load_returns_defaults_when_file_absent() {
3139        let tmp = TempDir::new().unwrap();
3140        let cfg = Config::load(tmp.path()).unwrap();
3141        assert_eq!(cfg.state.next_number, 0);
3142        assert_eq!(cfg.default_config().columns.len(), 4);
3143    }
3144
3145    #[test]
3146    fn load_synthesizes_default_config_when_kanban_section_missing() {
3147        let tmp = TempDir::new().unwrap();
3148        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3149        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3150        assert_eq!(cfg.kanban.configurations.len(), 1);
3151        assert!(cfg.kanban.configurations.contains_key("default"));
3152        assert_eq!(cfg.default_config().columns.len(), 4);
3153    }
3154
3155    #[test]
3156    fn load_parses_state_and_keeps_editor_and_remote_targets() {
3157        let tmp = TempDir::new().unwrap();
3158        let path = write_toml(
3159            tmp.path(),
3160            r#"issues_dir = "tickets"
3161editor = "nvim"
3162
3163[kanban.default]
3164columns = ["todo", "done"]
3165done_column = "done"
3166
3167[state]
3168next_number = 17
3169
3170[export.gitlab]
3171project = "acme/tix"
3172"#,
3173        );
3174        let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
3175        assert_eq!(
3176            cfg.kanban.configurations.get("default").unwrap().columns,
3177            ["todo", "done"]
3178        );
3179        assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
3180        assert_eq!(cfg.editor.as_deref(), Some("nvim"));
3181        assert_eq!(cfg.state.next_number, 17);
3182        assert_eq!(
3183            cfg.export.gitlab.unwrap().project.as_deref(),
3184            Some("acme/tix")
3185        );
3186        assert_eq!(cfg.export.linear, None);
3187    }
3188
3189    #[test]
3190    fn viewer_is_parsed_and_blank_is_rejected() {
3191        let tmp = TempDir::new().unwrap();
3192        let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
3193        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3194        assert_eq!(cfg.viewer.as_deref(), Some("less"));
3195
3196        let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"  \"\n");
3197        let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
3198        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3199        assert!(err.to_string().contains("viewer"), "{err}");
3200    }
3201
3202    #[test]
3203    fn viewer_is_omitted_from_the_file_when_unset() {
3204        let dir = TempDir::new().unwrap();
3205        let path = dir.path().join("tix.toml");
3206        let mut config = Config::default();
3207        config.save(&path).unwrap();
3208        assert!(
3209            !std::fs::read_to_string(&path).unwrap().contains("viewer"),
3210            "an unset viewer writes no key"
3211        );
3212
3213        config.viewer = Some("less".to_string());
3214        config.save(&path).unwrap();
3215        assert!(
3216            std::fs::read_to_string(&path)
3217                .unwrap()
3218                .contains(r#"viewer = "less""#),
3219            "a set viewer round-trips"
3220        );
3221    }
3222
3223    #[test]
3224    fn empty_columns_rejected_as_config_error() {
3225        let tmp = TempDir::new().unwrap();
3226        let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
3227        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3228        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3229        assert!(err.to_string().contains("kanban.scratch.columns"));
3230    }
3231
3232    #[test]
3233    fn duplicate_columns_rejected_as_config_error() {
3234        let tmp = TempDir::new().unwrap();
3235        let path = write_toml(
3236            tmp.path(),
3237            "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
3238        );
3239        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3240        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3241        assert!(err.to_string().contains("duplicate"));
3242    }
3243
3244    #[test]
3245    fn blank_column_name_rejected() {
3246        let tmp = TempDir::new().unwrap();
3247        let path = write_toml(
3248            tmp.path(),
3249            "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
3250        );
3251        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3252        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3253    }
3254
3255    #[test]
3256    fn done_column_must_be_in_columns() {
3257        let tmp = TempDir::new().unwrap();
3258        let path = write_toml(
3259            tmp.path(),
3260            "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
3261        );
3262        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3263        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3264        assert!(err.to_string().contains("kanban.scratch.done_column"));
3265    }
3266
3267    #[test]
3268    fn hidden_columns_must_be_subset_of_columns() {
3269        let tmp = TempDir::new().unwrap();
3270        let path = write_toml(
3271            tmp.path(),
3272            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
3273        );
3274        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3275        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3276        assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
3277    }
3278
3279    #[test]
3280    fn hidden_done_column_loads_fine() {
3281        let tmp = TempDir::new().unwrap();
3282        let path = write_toml(
3283            tmp.path(),
3284            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
3285        );
3286        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3287        let board = cfg.kanban.configurations.get("scratch").unwrap();
3288        assert_eq!(board.done_column.as_deref(), Some("done"));
3289        assert_eq!(board.hidden_columns, ["done"]);
3290    }
3291
3292    #[test]
3293    fn legacy_top_level_keys_rejected() {
3294        let tmp = TempDir::new().unwrap();
3295        let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
3296        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3297        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3298        assert!(err.to_string().contains("tix.toml"));
3299        assert!(err.to_string().contains("columns"));
3300    }
3301
3302    #[test]
3303    fn token_warning_fires_once_per_remote_with_token() {
3304        let tmp = TempDir::new().unwrap();
3305        let path = write_toml(
3306            tmp.path(),
3307            r#"[export.gitlab]
3308project = "a/b"
3309token = "gl-secret"
3310
3311[export.linear]
3312team = "Eng"
3313token = "lin-secret"
3314"#,
3315        );
3316        let mut warnings: Vec<String> = Vec::new();
3317        let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
3318        assert_eq!(warnings.len(), 2);
3319        assert!(warnings[0].contains("GITLAB_TOKEN"));
3320        assert!(warnings[1].contains("LINEAR_TOKEN"));
3321        // token values must never leak into the warning
3322        for w in &warnings {
3323            assert!(!w.contains("secret"));
3324        }
3325        assert_eq!(
3326            cfg.export.gitlab.unwrap().token.as_deref(),
3327            Some("gl-secret")
3328        );
3329    }
3330
3331    #[test]
3332    fn no_token_means_no_warning() {
3333        let tmp = TempDir::new().unwrap();
3334        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3335        let mut fired = false;
3336        Config::load_with_warning(&path, |_| fired = true).unwrap();
3337        assert!(!fired);
3338    }
3339
3340    #[test]
3341    fn save_never_writes_the_state_section() {
3342        let tmp = TempDir::new().unwrap();
3343        let path = tmp.path().join("tix.toml");
3344        // Simulate a legacy load: `[state].next_number` is kept in memory
3345        // but must never be written back out (issue #52).
3346        let mut cfg = Config::default();
3347        cfg.state.next_number = 42;
3348        cfg.save(&path).unwrap();
3349
3350        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3351        assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
3352        assert_eq!(
3353            reloaded.default_config().columns,
3354            cfg.default_config().columns
3355        );
3356
3357        let on_disk = std::fs::read_to_string(&path).unwrap();
3358        assert!(
3359            !on_disk.contains("next_number"),
3360            "next_number leaked into tix.toml:\n{on_disk}"
3361        );
3362        assert!(
3363            !on_disk.contains("[state]"),
3364            "state section written:\n{on_disk}"
3365        );
3366    }
3367
3368    #[test]
3369    fn multi_config_roundtrip_preserves_declaration_order() {
3370        let tmp = TempDir::new().unwrap();
3371        let path = write_toml(
3372            tmp.path(),
3373            r#"issues_dir = "docs/issues"
3374
3375[kanban.release]
3376columns = ["planned", "in progress", "done"]
3377done_column = "done"
3378show_mismatch = true
3379
3380[kanban.personal]
3381columns = ["todo", "done"]
3382done_column = "done"
3383hidden_columns = ["done"]
3384"#,
3385        );
3386        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3387        let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
3388        assert_eq!(names, ["release", "personal"]);
3389
3390        // default_config falls back to the first declared when no "default".
3391        assert_eq!(
3392            cfg.default_config().columns,
3393            ["planned", "in progress", "done"]
3394        );
3395
3396        cfg.save(&path).unwrap();
3397        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3398        assert_eq!(
3399            cfg, reloaded,
3400            "order and values survive save/load round trip"
3401        );
3402        let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
3403        assert_eq!(names2, ["release", "personal"]);
3404    }
3405
3406    #[test]
3407    fn save_never_serializes_tokens_back_out() {
3408        let tmp = TempDir::new().unwrap();
3409        let path = tmp.path().join("tix.toml");
3410        let mut cfg = Config::default();
3411        cfg.export.gitlab = Some(RemoteConfig {
3412            token: Some("secret".into()),
3413            project: Some("a/b".into()),
3414            ..RemoteConfig::default()
3415        });
3416        cfg.save(&path).unwrap();
3417
3418        let on_disk = std::fs::read_to_string(&path).unwrap();
3419        assert!(
3420            !on_disk.contains("secret"),
3421            "token leaked to disk:\n{on_disk}"
3422        );
3423        assert!(
3424            on_disk.contains("a/b"),
3425            "non-secret fields survive:\n{on_disk}"
3426        );
3427
3428        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3429        assert_eq!(reloaded.export.gitlab.unwrap().token, None);
3430    }
3431
3432    #[test]
3433    fn save_is_atomic_and_leaves_no_temp_file() {
3434        let tmp = TempDir::new().unwrap();
3435        let path = tmp.path().join("tix.toml");
3436        Config::default().save(&path).unwrap();
3437        let entries: Vec<_> = std::fs::read_dir(tmp.path())
3438            .unwrap()
3439            .filter_map(|e| e.ok())
3440            .collect();
3441        assert_eq!(entries.len(), 1);
3442        assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
3443    }
3444
3445    #[test]
3446    fn default_loadwrites_are_idempotent() {
3447        let tmp = TempDir::new().unwrap();
3448        let path = tmp.path().join("tix.toml");
3449        let cfg = Config::default();
3450        cfg.save(&path).unwrap();
3451        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3452        assert_eq!(cfg, reloaded);
3453    }
3454
3455    #[test]
3456    fn effective_done_column_falls_back_to_last() {
3457        let board = BoardConfiguration {
3458            columns: vec!["todo".into(), "later".into()],
3459            done_column: None,
3460            ..Default::default()
3461        };
3462        assert_eq!(board.effective_done_column(), Some("later"));
3463
3464        let with_done = BoardConfiguration {
3465            columns: vec!["todo".into(), "done".into(), "later".into()],
3466            done_column: None,
3467            ..Default::default()
3468        };
3469        assert_eq!(with_done.effective_done_column(), Some("done"));
3470    }
3471
3472    #[test]
3473    fn project_root_walks_up_the_tree() {
3474        let tmp = TempDir::new().unwrap();
3475        let nested = tmp.path().join("a").join("b");
3476        std::fs::create_dir_all(&nested).unwrap();
3477        std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
3478
3479        assert_eq!(
3480            Config::project_root(&nested),
3481            Some(tmp.path().to_path_buf())
3482        );
3483        assert_eq!(
3484            Config::project_root(tmp.path()),
3485            Some(tmp.path().to_path_buf())
3486        );
3487    }
3488
3489    #[test]
3490    fn project_root_is_none_when_no_toml_exists() {
3491        let tmp = TempDir::new().unwrap();
3492        assert_eq!(Config::project_root(tmp.path()), None);
3493    }
3494
3495    #[test]
3496    fn order_by_parses_all_names_and_defaults_to_desc() {
3497        let tmp = TempDir::new().unwrap();
3498        for name in ["created_at_desc", "created_at_asc", "none"] {
3499            let path = write_toml(
3500                tmp.path(),
3501                &format!(
3502                    "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
3503                ),
3504            );
3505            let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3506            let board = cfg.kanban.configurations.get("x").unwrap();
3507            assert_eq!(board.order_by.as_str(), name);
3508        }
3509        assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
3510        assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
3511        assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
3512        assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
3513    }
3514
3515    #[test]
3516    fn zero_title_lines_rejected_as_config_error() {
3517        let tmp = TempDir::new().unwrap();
3518        let path = write_toml(
3519            tmp.path(),
3520            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
3521        );
3522        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3523        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3524        assert!(
3525            err.to_string().contains("kanban.scratch.title_lines"),
3526            "error names the offending field: {err}"
3527        );
3528    }
3529
3530    #[test]
3531    fn unknown_order_by_rejected_as_config_error() {
3532        let tmp = TempDir::new().unwrap();
3533        let path = write_toml(
3534            tmp.path(),
3535            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
3536        );
3537        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3538        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3539    }
3540
3541    #[test]
3542    fn board_without_display_keys_loads_default_display_settings() {
3543        let tmp = TempDir::new().unwrap();
3544        let path = write_toml(
3545            tmp.path(),
3546            "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
3547        );
3548        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3549        let board = cfg.default_config();
3550        assert!(board.wrap_titles);
3551        assert_eq!(board.title_lines, 3);
3552        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3553    }
3554
3555    #[test]
3556    fn display_settings_roundtrip_through_save() {
3557        let tmp = TempDir::new().unwrap();
3558        let path = tmp.path().join("tix.toml");
3559        let mut cfg = Config::default();
3560        {
3561            let board = cfg.kanban.configurations.get_mut("default").unwrap();
3562            board.wrap_titles = false;
3563            board.title_lines = 5;
3564            board.order_by = OrderBy::None;
3565        }
3566        cfg.save(&path).unwrap();
3567        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3568        assert_eq!(
3569            reloaded, cfg,
3570            "display settings survive save/load round trip"
3571        );
3572        let on_disk = std::fs::read_to_string(&path).unwrap();
3573        for key in ["wrap_titles", "title_lines", "order_by"] {
3574            assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
3575        }
3576    }
3577
3578    #[test]
3579    fn manual_card_order_roundtrips_through_save() {
3580        let tmp = TempDir::new().unwrap();
3581        let path = tmp.path().join("tix.toml");
3582        let mut cfg = Config::default();
3583        {
3584            let board = cfg.kanban.configurations.get_mut("default").unwrap();
3585            board.card_order = vec!["a.md".into(), "b.md".into()];
3586        }
3587        cfg.save(&path).unwrap();
3588        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3589        assert_eq!(
3590            reloaded
3591                .kanban
3592                .configurations
3593                .get("default")
3594                .unwrap()
3595                .card_order,
3596            vec!["a.md".to_string(), "b.md".to_string()]
3597        );
3598        let on_disk = std::fs::read_to_string(&path).unwrap();
3599        assert!(
3600            on_disk.contains("card_order"),
3601            "missing card_order in:\n{on_disk}"
3602        );
3603    }
3604
3605    #[test]
3606    fn planning_defaults_match_contract() {
3607        let c = Config::default();
3608        assert_eq!(c.planning.cycle_number, 1);
3609        assert_eq!(c.planning.default_cycle, None);
3610        assert_eq!(c.planning.default_milestone, None);
3611        assert_eq!(c.planning.default_team, None);
3612        assert!(c.teams.is_empty());
3613        assert!(c.cycles.is_empty());
3614        assert_eq!(c.milestones_dir, None);
3615        assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
3616    }
3617
3618    #[test]
3619    fn planning_sections_roundtrip_and_preserve_declaration_order() {
3620        let tmp = TempDir::new().unwrap();
3621        let path = write_toml(
3622            tmp.path(),
3623            r#"issues_dir = "docs/issues"
3624milestones_dir = "docs/milestones"
3625
3626[planning]
3627cycle_number = 1
3628default_cycle = "Cycle 1"
3629default_milestone = "Backlog"
3630default_team = "default"
3631
3632[cycles."Cycle 1"]
3633
3634[cycles."Cycle 2"]
3635start_date = "2026-09-21"
3636due_date = "2026-09-30"
3637
3638[teams.default]
3639members = ["Herb Mason <herb@example.com>", "@alice"]
3640
3641[teams.platform]
3642members = ["bob"]
3643"#,
3644        );
3645        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3646        assert_eq!(cfg.planning.cycle_number, 1);
3647        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
3648        assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
3649        assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
3650        assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
3651
3652        // Names are map keys; declaration order is restored from headers.
3653        let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
3654        assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
3655        let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
3656        assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
3657        assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
3658        assert_eq!(cycle2.description, None);
3659
3660        let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
3661        assert_eq!(teams, ["default", "platform"]);
3662        assert_eq!(
3663            cfg.teams.get("default").unwrap().members,
3664            ["Herb Mason <herb@example.com>", "@alice"]
3665        );
3666
3667        // Save then reload: order + values survive the round trip (NFR-002).
3668        cfg.save(&path).unwrap();
3669        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3670        assert_eq!(cfg, reloaded);
3671        assert_eq!(
3672            reloaded
3673                .cycles
3674                .keys()
3675                .map(String::as_str)
3676                .collect::<Vec<_>>(),
3677            ["Cycle 1", "Cycle 2"]
3678        );
3679        assert_eq!(
3680            reloaded
3681                .teams
3682                .keys()
3683                .map(String::as_str)
3684                .collect::<Vec<_>>(),
3685            ["default", "platform"]
3686        );
3687    }
3688
3689    // ---------------------------------------------------------------------
3690    // WP01 / T001-T005 — the category vocabulary and mapping
3691    // ---------------------------------------------------------------------
3692
3693    const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
3694
3695[categories]
3696order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
3697default = "unstarted"
3698
3699[transitions]
3700terminal_locked = true
3701
3702[kanban.default]
3703columns = ["triage", "wip", "review", "shipped"]
3704done_column = "shipped"
3705category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
3706
3707[export]
3708"#;
3709
3710    #[test]
3711    fn category_sections_parse_and_default_sensibly() {
3712        let tmp = TempDir::new().unwrap();
3713        let path = write_toml(tmp.path(), FULL_CATEGORIES);
3714        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3715        assert_eq!(cfg.categories.order, CATEGORY_KEYS);
3716        assert_eq!(cfg.categories.default, "unstarted");
3717        assert!(cfg.transitions.terminal_locked);
3718
3719        let board = cfg.default_config();
3720        assert_eq!(
3721            board.category_map.get("shipped").map(String::as_str),
3722            Some("completed")
3723        );
3724        assert_eq!(
3725            board.category_map.get("triage").map(String::as_str),
3726            Some("backlog")
3727        );
3728    }
3729
3730    #[test]
3731    fn category_sections_default_when_absent() {
3732        let tmp = TempDir::new().unwrap();
3733        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3734        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3735        // A config omitting `[categories]` still works, off the built-in
3736        // vocabulary — T001's "sensible defaults".
3737        assert_eq!(cfg.categories, CategoryConfig::default());
3738        assert_eq!(cfg.transitions, TransitionConfig::default());
3739        assert!(cfg.categories.is_default(), "and is not written back");
3740        assert!(cfg.transitions.is_default());
3741    }
3742
3743    #[test]
3744    fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
3745        let tmp = TempDir::new().unwrap();
3746        let bad_categories = write_toml(
3747            tmp.path(),
3748            "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
3749        );
3750        let err = Config::load_with_warning(&bad_categories, |_| {})
3751            .expect_err("an unknown key in [categories] must be refused");
3752        assert!(err.to_string().contains("unknown field"), "got: {err}");
3753
3754        let tmp2 = TempDir::new().unwrap();
3755        let bad_transitions = write_toml(
3756            tmp2.path(),
3757            "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
3758        );
3759        let err = Config::load_with_warning(&bad_transitions, |_| {})
3760            .expect_err("an unknown key in [transitions] must be refused");
3761        assert!(err.to_string().contains("unknown field"), "got: {err}");
3762    }
3763
3764    #[test]
3765    fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
3766        let tmp = TempDir::new().unwrap();
3767        let path = write_toml(
3768            tmp.path(),
3769            "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
3770        );
3771        let err = Config::load_with_warning(&path, |_| {})
3772            .expect_err("the vocabulary is global and fixed; a new key is not a default");
3773        assert!(err.to_string().contains("unknown_category"), "got: {err}");
3774    }
3775
3776    /// The escalation the contract names: a default that resolves to `completed`
3777    /// would mark every unmapped column done.
3778    #[test]
3779    fn a_default_of_completed_is_refused() {
3780        let tmp = TempDir::new().unwrap();
3781        let path = write_toml(
3782            tmp.path(),
3783            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
3784        );
3785        let err = Config::load_with_warning(&path, |_| {})
3786            .expect_err("a completion-changing default must be an escalation");
3787        let msg = err.to_string();
3788        assert!(msg.contains("may not be `completed`"), "got: {msg}");
3789    }
3790
3791    #[test]
3792    fn a_default_outside_the_vocabulary_is_refused() {
3793        let tmp = TempDir::new().unwrap();
3794        let path = write_toml(
3795            tmp.path(),
3796            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
3797        );
3798        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3799        assert!(err.to_string().contains("unknown_category"), "got: {err}");
3800    }
3801
3802    /// The contract's central invariant: a board either has a completion category
3803    /// or is an explicit configuration error. There is no silent "nothing
3804    /// completes".
3805    #[test]
3806    fn a_mapped_board_with_no_completion_column_is_refused() {
3807        let tmp = TempDir::new().unwrap();
3808        let path = write_toml(
3809            tmp.path(),
3810            r#"issues_dir = "docs/issues"
3811
3812[kanban.default]
3813columns = ["triage", "wip", "shipped"]
3814done_column = "shipped"
3815category_map = { triage = "backlog", wip = "active" }
3816"#,
3817        );
3818        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3819        assert!(
3820            err.to_string().contains("missing_completion_category"),
3821            "got: {err}"
3822        );
3823    }
3824
3825    #[test]
3826    fn a_board_with_two_completion_columns_is_refused() {
3827        let tmp = TempDir::new().unwrap();
3828        let path = write_toml(
3829            tmp.path(),
3830            r#"issues_dir = "docs/issues"
3831
3832[kanban.default]
3833columns = ["shipped", "cancelled"]
3834done_column = "shipped"
3835category_map = { shipped = "completed", cancelled = "completed" }
3836"#,
3837        );
3838        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3839        let msg = err.to_string();
3840        assert!(
3841            msg.contains("exactly one column may complete"),
3842            "got: {msg}"
3843        );
3844    }
3845
3846    #[test]
3847    fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
3848        let tmp = TempDir::new().unwrap();
3849        let path = write_toml(
3850            tmp.path(),
3851            r#"issues_dir = "docs/issues"
3852
3853[kanban.default]
3854columns = ["triage", "shipped"]
3855done_column = "shipped"
3856category_map = { triage = "backlog", shipped = "completed", nope = "active" }
3857"#,
3858        );
3859        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3860        assert!(
3861            err.to_string().contains("mapping_for_unknown_column"),
3862            "got: {err}"
3863        );
3864    }
3865
3866    #[test]
3867    fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
3868        let tmp = TempDir::new().unwrap();
3869        let path = write_toml(
3870            tmp.path(),
3871            r#"issues_dir = "docs/issues"
3872
3873[kanban.default]
3874columns = ["triage", "shipped"]
3875done_column = "shipped"
3876category_map = { triage = "wontfix", shipped = "completed" }
3877"#,
3878        );
3879        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3880        assert!(err.to_string().contains("unknown_category"), "got: {err}");
3881    }
3882
3883    /// T003 step 5, and the boundary that keeps NFR-003 satisfiable: a configured
3884    /// column with **no** mapping is valid. It resolves by the safe default, and
3885    /// erroring here would reject every existing custom configuration.
3886    #[test]
3887    fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
3888        let tmp = TempDir::new().unwrap();
3889        let path = write_toml(
3890            tmp.path(),
3891            r#"issues_dir = "docs/issues"
3892
3893[kanban.default]
3894columns = ["triage", "wip", "shipped"]
3895done_column = "shipped"
3896category_map = { shipped = "completed" }
3897"#,
3898        );
3899        let cfg =
3900            Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
3901        let board = cfg.default_config();
3902        assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
3903        assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
3904        assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
3905    }
3906
3907    /// The legacy reconciliation, and the reason `missing_completion_category`
3908    /// cannot fire for a board with no map. Without it, a board with no
3909    /// `category_map` would map nothing to `completed` and be rejected — which
3910    /// would reject every legacy `tix.toml` the contract requires to load
3911    /// unchanged.
3912    #[test]
3913    fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
3914        let tmp = TempDir::new().unwrap();
3915        let path = write_toml(
3916            tmp.path(),
3917            r#"issues_dir = "docs/issues"
3918
3919[kanban.default]
3920columns = ["todo", "in progress", "in review", "done"]
3921done_column = "done"
3922"#,
3923        );
3924        let cfg = Config::load_with_warning(&path, |_| {})
3925            .expect("a legacy board must load, and it does");
3926        let board = cfg.default_config();
3927        assert!(board.category_map.is_empty());
3928        assert_eq!(board.category_for("done", &cfg.categories), "completed");
3929        assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
3930        assert_eq!(
3931            board.category_for("in review", &cfg.categories),
3932            "unstarted",
3933            "and nothing else is silently complete"
3934        );
3935    }
3936
3937    /// NFR-003 / C-007: the strongest guard. A defaulted configuration must not
3938    /// materialize — otherwise a default that writes new keys is exactly the
3939    /// "silent change" the requirement forbids.
3940    #[test]
3941    fn a_defaulted_config_gains_no_new_keys_on_save() {
3942        let tmp = TempDir::new().unwrap();
3943        let legacy = LEGACY_PRETTY_BOARD;
3944        let path = write_toml(tmp.path(), legacy);
3945        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3946        cfg.save(&path).unwrap();
3947        let after = std::fs::read_to_string(&path).unwrap();
3948        assert_eq!(
3949            after, legacy,
3950            "no `[categories]`, no `[transitions]`, no `category_map` may appear"
3951        );
3952    }
3953
3954    /// An explicitly-defaulted board is **normalized**: every field equal to the
3955    /// loader's value is dropped on save, so the file converges on the canonical
3956    /// legacy form (#1415) rather than carrying redundant keys forever.
3957    #[test]
3958    fn explicit_default_board_keys_are_normalized_away_by_save() {
3959        let tmp = TempDir::new().unwrap();
3960        let explicit = r#"issues_dir = "docs/issues"
3961
3962[kanban.default]
3963columns = [
3964    "todo",
3965    "in progress",
3966    "in review",
3967    "done",
3968]
3969done_column = "done"
3970hidden_columns = []
3971show_mismatch = false
3972wrap_titles = true
3973title_lines = 3
3974order_by = "created_at_desc"
3975
3976[export]
3977"#;
3978        let path = write_toml(tmp.path(), explicit);
3979        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3980        cfg.save(&path).unwrap();
3981        let after = std::fs::read_to_string(&path).unwrap();
3982        assert_eq!(
3983            after, LEGACY_PRETTY_BOARD,
3984            "a redundant explicit default collapses to the canonical form"
3985        );
3986        for key in [
3987            "done_column",
3988            "hidden_columns",
3989            "show_mismatch",
3990            "wrap_titles",
3991            "title_lines",
3992            "order_by",
3993            "[export]",
3994        ] {
3995            assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
3996        }
3997        assert_eq!(
3998            cfg,
3999            Config::load_with_warning(&path, |_| {}).unwrap(),
4000            "and the collapsed form reloads to the same config"
4001        );
4002    }
4003
4004    /// A board mapping with **no** `[categories]` section loads on the built-in
4005    /// vocabulary, and saving it back does not materialize the section.
4006    #[test]
4007    fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
4008        let tmp = TempDir::new().unwrap();
4009        let path = write_toml(
4010            tmp.path(),
4011            r#"issues_dir = "docs/issues"
4012
4013[kanban.default]
4014columns = ["triage", "shipped"]
4015done_column = "shipped"
4016category_map = { triage = "backlog", shipped = "completed" }
4017
4018[export]
4019"#,
4020        );
4021        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4022        assert_eq!(cfg.categories, CategoryConfig::default());
4023        cfg.save(&path).unwrap();
4024        let after = std::fs::read_to_string(&path).unwrap();
4025        assert!(
4026            !after.contains("[categories]"),
4027            "the section must stay unmaterialized: {after}"
4028        );
4029        assert!(
4030            after.contains("category_map"),
4031            "while the board's own mapping survives: {after}"
4032        );
4033    }
4034
4035    /// What round-trips is the **values** and the `order` vector, not the raw
4036    /// bytes: `category_map` serializes as a `[kanban.<name>.category_map]`
4037    /// sub-table with its keys emitted in whatever order the TOML writer chooses,
4038    /// so the authored inline form
4039    /// `category_map = { triage = "backlog", ... }` is *not* what comes back.
4040    ///
4041    /// That is a contract/code mismatch, and the WP's rule is to fix the
4042    /// contract in the same change rather than let the two disagree — so the
4043    /// contract's example is corrected alongside this.
4044    ///
4045    /// The order that *does* matter, `[categories].order`, is a `Vec` and
4046    /// round-trips exactly. The mapping's key order has no semantic effect: it
4047    /// maps a column to a category, and the columns' presentation order comes
4048    /// from `columns`.
4049    #[test]
4050    fn a_full_category_config_round_trips_with_its_values_and_order() {
4051        let tmp = TempDir::new().unwrap();
4052        // A **non-default** vocabulary, so `[categories]` is written at all.
4053        // `FULL_CATEGORIES` spells out the built-in vocabulary verbatim, which
4054        // makes `CategoryConfig::is_default()` true and the section is then
4055        // *correctly* omitted on save — which is the property the other test
4056        // asserts. Using it here would have tested nothing about the round trip.
4057        let authored = r#"issues_dir = "docs/issues"
4058
4059[categories]
4060order = ["backlog", "active", "completed", "canceled"]
4061default = "backlog"
4062
4063[transitions]
4064terminal_locked = false
4065
4066[kanban.default]
4067columns = ["triage", "wip", "shipped"]
4068done_column = "shipped"
4069category_map = { triage = "backlog", wip = "active", shipped = "completed" }
4070
4071[export]
4072"#;
4073        let path = write_toml(tmp.path(), authored);
4074        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4075        cfg.save(&path).unwrap();
4076        let after = std::fs::read_to_string(&path).unwrap();
4077        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4078
4079        assert_eq!(
4080            reloaded.categories, cfg.categories,
4081            "the vocabulary and its default survive"
4082        );
4083        assert_eq!(
4084            reloaded.categories.order,
4085            vec!["backlog", "active", "completed", "canceled"],
4086            "and `order` is a Vec, so the declared order is preserved exactly"
4087        );
4088
4089        assert_eq!(
4090            reloaded.default_config().category_map,
4091            cfg.default_config().category_map,
4092            "the mapping's contents survive, in whatever order they are written"
4093        );
4094        // Both new sections are written, since neither is at its default.
4095        // Both new sections are written — and only because neither is at its
4096        // default. The first version of this fixture set `terminal_locked = true`,
4097        // which **is** the default, so `[transitions]` was correctly omitted and
4098        // the round trip had nothing to prove. Omitted-when-defaulted is the
4099        // NFR-003 trade this WP makes deliberately, so a fixture that pins the
4100        // default is a fixture that tests nothing.
4101        assert!(after.contains("[categories]"), "after: {after}");
4102        assert!(after.contains("[transitions]"), "after: {after}");
4103        assert!(
4104            !reloaded.transitions.terminal_locked,
4105            "and the value survives"
4106        );
4107
4108        // **Save is idempotent.** The top-level section order is the serializer's
4109        // (struct field) order, not the authored order — the first version of this
4110        // assertion expected the authored order and failed on a correctly-saved
4111        // file. What actually has to hold is that a second save changes nothing,
4112        // which is the property a diff and a merge depend on. The authored order
4113        // that *is* load-bearing is preserved by the two assertions above.
4114        let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
4115        reloaded_again.save(&path).unwrap();
4116        assert_eq!(
4117            std::fs::read_to_string(&path).unwrap(),
4118            after,
4119            "a second save must be a no-op"
4120        );
4121    }
4122
4123    /// Two boards with different column names, one mapped and one not — the
4124    /// intake's first acceptance scenario.
4125    #[test]
4126    fn two_boards_with_different_columns_map_to_the_same_categories() {
4127        let tmp = TempDir::new().unwrap();
4128        let path = write_toml(
4129            tmp.path(),
4130            r#"issues_dir = "docs/issues"
4131
4132[kanban.alpha]
4133columns = ["triage", "wip", "review", "shipped"]
4134done_column = "shipped"
4135category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
4136
4137[kanban.beta]
4138columns = ["new", "doing", "merged"]
4139done_column = "merged"
4140
4141[export]
4142"#,
4143        );
4144        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4145        let alpha = &cfg.kanban.configurations["alpha"];
4146        let beta = &cfg.kanban.configurations["beta"];
4147        // Different column names, same categories: `shipped` and `merged` both
4148        // complete, and both boards' working columns are `active`/`unstarted`.
4149        assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
4150        assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
4151        assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
4152        assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
4153        assert!(beta.category_map.is_empty());
4154    }
4155
4156    /// The vocabulary's load-bearing distinction, asserted directly: `canceled` is
4157    /// terminal and **not** complete. Treating it as complete would change
4158    /// completion meaning, which the contract makes an escalation.
4159    #[test]
4160    fn canceled_is_terminal_but_not_complete() {
4161        assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
4162        assert!(TERMINAL_CATEGORIES.contains(&"completed"));
4163        assert_eq!(
4164            COMPLETE_CATEGORY, "completed",
4165            "completion is defined by this category alone"
4166        );
4167        assert_ne!(COMPLETE_CATEGORY, "canceled");
4168        // And a board may map a column to `canceled` — the category exists and is
4169        // mappable, it simply is not the completing one.
4170        let tmp = TempDir::new().unwrap();
4171        let path = write_toml(
4172            tmp.path(),
4173            r#"issues_dir = "docs/issues"
4174
4175[kanban.default]
4176columns = ["triage", "dropped", "shipped"]
4177done_column = "shipped"
4178category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
4179"#,
4180        );
4181        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4182        let board = cfg.default_config();
4183        assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
4184        assert_ne!(
4185            board.category_for("dropped", &cfg.categories),
4186            COMPLETE_CATEGORY
4187        );
4188    }
4189
4190    /// `blocked` as a category is a workflow column position, not the derived
4191    /// blocked state owned by issue 169's mission. Asserted as distinctness, since
4192    /// conflating them is a named rejected alternative.
4193    #[test]
4194    fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
4195        assert!(CATEGORY_KEYS.contains(&"blocked"));
4196        let tmp = TempDir::new().unwrap();
4197        let path = write_toml(
4198            tmp.path(),
4199            r#"issues_dir = "docs/issues"
4200
4201[kanban.default]
4202columns = ["wip", "on hold", "shipped"]
4203done_column = "shipped"
4204category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
4205"#,
4206        );
4207        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4208        let board = cfg.default_config();
4209        assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
4210        // The derived blocked state lives in the relationship graph, not here.
4211        assert!(
4212            !cfg.default_config()
4213                .category_map
4214                .values()
4215                .any(|v| v.contains("state")),
4216            "a category is a position, never a derived state"
4217        );
4218    }
4219
4220    #[test]
4221    fn legacy_file_roundtrips_byte_identical() {
4222        let tmp = TempDir::new().unwrap();
4223        // A pre-mission tix.toml in the canonical toml pretty form. The save
4224        // path must not add, drop, or reorder any pre-existing key (NFR-003):
4225        // maximum already-pretty formatting proves a true byte-stable save.
4226        let legacy = LEGACY_PRETTY_BOARD;
4227        let path = write_toml(tmp.path(), legacy);
4228        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4229        assert_eq!(cfg.planning, PlanningConfig::default());
4230        assert!(cfg.teams.is_empty());
4231        assert!(cfg.cycles.is_empty());
4232        assert_eq!(cfg.milestones_dir, None);
4233        // Behavior the omitted keys would have carried is unchanged: the
4234        // `done_column` is still the loader's `"done"` and ordering keeps its
4235        // default (#1415).
4236        assert_eq!(
4237            cfg.default_config().columns,
4238            ["todo", "in progress", "in review", "done"]
4239        );
4240        assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
4241        assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
4242        assert_eq!(
4243            cfg.default_config().hidden_columns,
4244            [] as [std::string::String; 0]
4245        );
4246        assert!(!cfg.default_config().show_mismatch);
4247        assert!(cfg.default_config().wrap_titles);
4248        assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
4249        assert!(!cfg.default_config().category_map.contains_key("done"));
4250
4251        cfg.save(&path).unwrap();
4252        let on_disk = std::fs::read_to_string(&path).unwrap();
4253        assert_eq!(
4254            on_disk, legacy,
4255            "legacy tix.toml must save byte-identical (NFR-003)"
4256        );
4257        assert!(
4258            !on_disk.contains("[export]"),
4259            "an empty `[export]` table must not be materialized:\n{on_disk}"
4260        );
4261        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4262        assert_eq!(cfg, reloaded);
4263    }
4264
4265    #[test]
4266    fn legacy_file_in_any_layout_never_gains_planning_keys() {
4267        let tmp = TempDir::new().unwrap();
4268        // Hand-written single-line arrays (typical of real repos) parse fine;
4269        // the pretty re-serializer may reformat them, but must never add a
4270        // planning/teams/cycles/milestones key.
4271        let legacy = r#"issues_dir = "docs/issues"
4272[kanban.default]
4273columns = ["todo", "in progress", "in review", "done"]
4274done_column = "done"
4275"#;
4276        let path = write_toml(tmp.path(), legacy);
4277        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4278        cfg.save(&path).unwrap();
4279        let on_disk = std::fs::read_to_string(&path).unwrap();
4280        assert!(!on_disk.contains("milestones_dir"));
4281        assert!(!on_disk.contains("[planning]"));
4282        assert!(!on_disk.contains("[teams]"));
4283        assert!(!on_disk.contains("[cycles"));
4284    }
4285
4286    #[test]
4287    fn planning_default_section_with_partial_keys_loads() {
4288        let tmp = TempDir::new().unwrap();
4289        let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
4290        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4291        assert_eq!(
4292            cfg.planning.cycle_number, 1,
4293            "counter defaults when omitted"
4294        );
4295        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
4296        assert_eq!(cfg.planning.default_milestone, None);
4297        assert_eq!(cfg.planning.default_team, None);
4298    }
4299
4300    #[test]
4301    fn planning_rejects_unknown_keys() {
4302        let tmp = TempDir::new().unwrap();
4303        let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
4304        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4305        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4306        assert!(err.to_string().contains("counter"));
4307    }
4308
4309    #[test]
4310    fn planning_rejects_zero_cycle_number() {
4311        let tmp = TempDir::new().unwrap();
4312        let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
4313        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4314        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4315        assert!(err.to_string().contains("cycle_number"));
4316    }
4317
4318    #[test]
4319    fn cycle_dates_are_validated_and_compared() {
4320        let tmp = TempDir::new().unwrap();
4321        // parsing failure
4322        let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
4323        let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
4324        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4325        assert!(err.to_string().contains("start_date"));
4326
4327        // ordering failure
4328        let ordered = write_toml(
4329            tmp.path(),
4330            "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
4331        );
4332        let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
4333        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4334        assert!(err.to_string().contains("due_date"));
4335        assert!(err.to_string().contains("start_date"));
4336    }
4337
4338    #[test]
4339    fn cycles_reject_unknown_keys() {
4340        let tmp = TempDir::new().unwrap();
4341        let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
4342        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4343        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4344        assert!(err.to_string().contains("bogus"));
4345    }
4346
4347    #[test]
4348    fn cycle_without_dates_loads_fine() {
4349        let tmp = TempDir::new().unwrap();
4350        let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
4351        let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
4352        assert_eq!(
4353            cfg.cycles.get("Alpha").unwrap().description.as_deref(),
4354            Some("a")
4355        );
4356    }
4357
4358    #[test]
4359    fn teams_validate_members_nonempty_and_unique() {
4360        let tmp = TempDir::new().unwrap();
4361        let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
4362        let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
4363        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4364        assert!(err.to_string().contains("members"));
4365
4366        let dup = write_toml(
4367            tmp.path(),
4368            "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
4369        );
4370        let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
4371        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4372        assert!(err.to_string().contains("unique"));
4373    }
4374
4375    #[test]
4376    fn empty_teams_table_is_disabled_not_error() {
4377        let tmp = TempDir::new().unwrap();
4378        let path = write_toml(tmp.path(), "[teams]\n");
4379        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4380        assert!(cfg.teams.is_empty());
4381        // An empty [teams] is never written back out.
4382        cfg.save(&path).unwrap();
4383        let on_disk = std::fs::read_to_string(&path).unwrap();
4384        assert!(!on_disk.contains("[teams]"));
4385    }
4386
4387    #[test]
4388    fn blank_milestones_dir_rejected() {
4389        let tmp = TempDir::new().unwrap();
4390        let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
4391        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4392        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4393        assert!(err.to_string().contains("milestones_dir"));
4394    }
4395
4396    #[test]
4397    fn sparse_keybindings_roundtrip_without_defaults() {
4398        let tmp = TempDir::new().unwrap();
4399        let path = write_toml(
4400            tmp.path(),
4401            "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
4402        );
4403        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4404        config.save(&path).unwrap();
4405        let contents = std::fs::read_to_string(&path).unwrap();
4406        assert!(contents.contains("[keybindings.board]"));
4407        assert!(!contents.contains("move_left"));
4408        assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
4409    }
4410
4411    #[test]
4412    fn omitted_keybindings_retain_effective_defaults() {
4413        let tmp = TempDir::new().unwrap();
4414        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
4415        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4416        let bindings = config.effective_keybindings().unwrap();
4417        assert_eq!(
4418            bindings.label(
4419                crate::keybindings::InputContext::Board,
4420                crate::keybindings::TuiAction::NewIssue
4421            ),
4422            "n"
4423        );
4424    }
4425
4426    /// `[keybindings.edit_conflict]` is a real table (tix #1510): an override lands
4427    /// on the conflict prompt's decisions and nothing else moves. Without this the
4428    /// `edit_conflict` rows in `docs/key-bindings.md` would be documentation for a
4429    /// setting that cannot be written.
4430    #[test]
4431    fn edit_conflict_keybindings_override_the_prompt() {
4432        use crate::keybindings::{InputContext, TuiAction};
4433        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4434
4435        let tmp = TempDir::new().unwrap();
4436        let path = write_toml(
4437            tmp.path(),
4438            "[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
4439        );
4440        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4441        let bindings = config.effective_keybindings().unwrap();
4442
4443        assert_eq!(
4444            bindings.label(InputContext::EditConflict, TuiAction::Confirm),
4445            "Ctrl+k"
4446        );
4447        assert_eq!(
4448            bindings.action(
4449                InputContext::EditConflict,
4450                KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
4451            ),
4452            Some(TuiAction::Confirm)
4453        );
4454        assert_eq!(
4455            bindings.action(
4456                InputContext::EditConflict,
4457                KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
4458            ),
4459            None,
4460            "an override replaces the action's whole alias list"
4461        );
4462        // The actions that were not overridden keep their defaults.
4463        assert_eq!(
4464            bindings.action(
4465                InputContext::EditConflict,
4466                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
4467            ),
4468            Some(TuiAction::Reedit)
4469        );
4470        assert_eq!(
4471            bindings.action(
4472                InputContext::EditConflict,
4473                KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
4474            ),
4475            Some(TuiAction::Ignore)
4476        );
4477    }
4478
4479    #[test]
4480    fn keybindings_reject_unknown_fields_and_empty_actions() {
4481        let tmp = TempDir::new().unwrap();
4482        let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
4483        assert!(
4484            Config::load_with_warning(&unknown, |_| {})
4485                .unwrap_err()
4486                .to_string()
4487                .contains("bogus")
4488        );
4489        let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
4490        assert!(
4491            Config::load_with_warning(&empty, |_| {})
4492                .unwrap_err()
4493                .to_string()
4494                .contains("new_issue")
4495        );
4496    }
4497
4498    /// The mode switches are configurable under `[keybindings.shell]`: an
4499    /// override moves the key *and* the label the rail renders, an unknown action
4500    /// name is refused, and an unparsable chord names the key it came from.
4501    #[test]
4502    fn shell_mode_switches_are_configurable() {
4503        use crate::keybindings::{InputContext, TuiAction};
4504        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4505
4506        let tmp = TempDir::new().unwrap();
4507        let path = write_toml(
4508            tmp.path(),
4509            "[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
4510        );
4511        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4512        let bindings = config.effective_keybindings().unwrap();
4513
4514        // The label is what the rail renders (`nav::render` reads it back).
4515        assert_eq!(
4516            bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
4517            "g"
4518        );
4519        assert_eq!(
4520            bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
4521            "F3 / b"
4522        );
4523        // The key resolves, and the default F5 no longer does.
4524        assert_eq!(
4525            bindings.action(
4526                InputContext::Shell,
4527                KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
4528            ),
4529            Some(TuiAction::SwitchToMilestones)
4530        );
4531        assert_eq!(
4532            bindings.action(
4533                InputContext::Shell,
4534                KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
4535            ),
4536            None,
4537            "an override replaces the action's whole alias list"
4538        );
4539        assert_eq!(
4540            bindings.action(
4541                InputContext::Shell,
4542                KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
4543            ),
4544            Some(TuiAction::SwitchToBoard),
4545            "a second alias keeps working"
4546        );
4547
4548        // An unknown action name is a load error naming the key.
4549        let unknown = write_toml(
4550            tmp.path(),
4551            "[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
4552        );
4553        let error = Config::load_with_warning(&unknown, |_| {})
4554            .unwrap_err()
4555            .to_string();
4556        assert!(
4557            error.contains("switch_to_nowhere"),
4558            "the error names the offending key: {error:?}"
4559        );
4560
4561        // An unparsable chord is a load error naming the action and the chord.
4562        let bad_chord = write_toml(
4563            tmp.path(),
4564            "[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
4565        );
4566        let error = Config::load_with_warning(&bad_chord, |_| {})
4567            .unwrap_err()
4568            .to_string();
4569        assert!(
4570            error.contains("switch_to_milestones") && error.contains("nosuchkey"),
4571            "the error names the key and the chord: {error:?}"
4572        );
4573    }
4574
4575    // --- new-issue templates (tui-new-issue-mode, task 6.2) ---
4576
4577    #[test]
4578    fn templates_round_trip_through_save_and_load() {
4579        let tmp = TempDir::new().unwrap();
4580        let path = tmp.path().join("tix.toml");
4581        let mut config = Config::starter();
4582        config.templates.insert(
4583            "bug".into(),
4584            IssueTemplate {
4585                title: Some("Bug: ".into()),
4586                body: Some("## Steps\n".into()),
4587                labels: vec!["bug".into(), "urgent".into()],
4588                status: Some("todo".into()),
4589                cycles: vec!["Default".into()],
4590                start_date: Some("2026-01-01".into()),
4591                due_date: Some("2026-01-02".into()),
4592                ..IssueTemplate::default()
4593            },
4594        );
4595        config.validate().expect("the fixture is a valid config");
4596        config.save(&path).unwrap();
4597
4598        let raw = std::fs::read_to_string(&path).unwrap();
4599        assert!(raw.contains("[templates.bug]"), "{raw}");
4600        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4601        assert_eq!(reloaded.templates, config.templates);
4602        let bug = &reloaded.templates["bug"];
4603        assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
4604        assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
4605        assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
4606    }
4607
4608    #[test]
4609    fn a_config_without_templates_writes_no_section() {
4610        let tmp = TempDir::new().unwrap();
4611        let path = tmp.path().join("tix.toml");
4612        Config::default().save(&path).unwrap();
4613        let raw = std::fs::read_to_string(&path).unwrap();
4614        assert!(
4615            !raw.contains("templates"),
4616            "an absent section must not appear on disk:\n{raw}"
4617        );
4618        assert!(
4619            Config::load_with_warning(&path, |_| {})
4620                .unwrap()
4621                .templates
4622                .is_empty()
4623        );
4624    }
4625
4626    #[test]
4627    fn a_template_value_with_a_comma_is_rejected() {
4628        let tmp = TempDir::new().unwrap();
4629        let path = write_toml(
4630            tmp.path(),
4631            "issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
4632        );
4633        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4634        assert!(
4635            err.to_string().contains("must not contain a comma"),
4636            "got {err}"
4637        );
4638    }
4639
4640    #[test]
4641    fn a_template_with_a_backwards_date_range_is_rejected() {
4642        let tmp = TempDir::new().unwrap();
4643        let path = write_toml(
4644            tmp.path(),
4645            "issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
4646             start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
4647        );
4648        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4649        assert!(err.to_string().contains(">= start_date"), "got {err}");
4650    }
4651
4652    #[test]
4653    fn an_unknown_template_key_is_rejected() {
4654        let tmp = TempDir::new().unwrap();
4655        let path = write_toml(
4656            tmp.path(),
4657            "issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
4658        );
4659        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4660        assert!(err.to_string().contains("unknown field"), "got {err}");
4661    }
4662
4663    // --- stable-ID issue templates (issue-form-templates, WP01) ---
4664
4665    #[test]
4666    fn issue_templates_round_trip_through_save_and_load() {
4667        let tmp = TempDir::new().unwrap();
4668        let path = tmp.path().join("tix.toml");
4669        let mut config = Config::starter();
4670        config
4671            .issue_templates
4672            .insert(
4673                "bug",
4674                Template {
4675                    name: Some("Bug report".to_string()),
4676                    defaults: TemplateDefaults {
4677                        labels: vec!["bug".to_string()],
4678                        priority: Some("high".to_string()),
4679                        ..TemplateDefaults::default()
4680                    },
4681                    prompts: vec![TemplatePrompt {
4682                        key: "priority".to_string(),
4683                        label: Some("Priority".to_string()),
4684                        kind: PromptKind::Select,
4685                        required: true,
4686                        default: None,
4687                        options_ref: Some("priorities".to_string()),
4688                    }],
4689                    references: vec![TemplateReference {
4690                        kind: ReferenceKind::Label,
4691                        value: "bug".to_string(),
4692                        required: true,
4693                    }],
4694                    ..Template::default()
4695                },
4696            )
4697            .unwrap();
4698        config.validate().expect("the fixture is a valid config");
4699        config.save(&path).unwrap();
4700
4701        let raw = std::fs::read_to_string(&path).unwrap();
4702        assert!(raw.contains("[issue_templates.bug]"), "{raw}");
4703        assert!(raw.contains("[[issue_templates.bug.prompts]]"), "{raw}");
4704
4705        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4706        assert_eq!(reloaded.issue_templates, config.issue_templates);
4707        let bug = reloaded.issue_templates.get("bug").unwrap();
4708        assert_eq!(bug.name.as_deref(), Some("Bug report"));
4709        assert_eq!(bug.defaults.labels, ["bug".to_string()]);
4710        assert_eq!(bug.prompts[0].kind, PromptKind::Select);
4711        assert!(bug.prompts[0].required);
4712        assert_eq!(bug.references[0].kind, ReferenceKind::Label);
4713    }
4714
4715    #[test]
4716    fn a_config_without_issue_templates_writes_no_section() {
4717        let tmp = TempDir::new().unwrap();
4718        let path = tmp.path().join("tix.toml");
4719        Config::default().save(&path).unwrap();
4720        let raw = std::fs::read_to_string(&path).unwrap();
4721        assert!(
4722            !raw.contains("issue_templates"),
4723            "an absent section must not appear on disk:\n{raw}"
4724        );
4725        assert!(
4726            Config::load_with_warning(&path, |_| {})
4727                .unwrap()
4728                .issue_templates
4729                .is_empty()
4730        );
4731    }
4732
4733    #[test]
4734    fn a_legacy_config_gains_no_issue_templates_section() {
4735        let tmp = TempDir::new().unwrap();
4736        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
4737        let before = std::fs::read_to_string(&path).unwrap();
4738        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4739        assert!(
4740            config.issue_templates.is_empty(),
4741            "a legacy config has no stable-ID registry"
4742        );
4743        config.save(&path).unwrap();
4744        assert_eq!(
4745            std::fs::read_to_string(&path).unwrap(),
4746            before,
4747            "a legacy config must round-trip byte-identically"
4748        );
4749    }
4750
4751    #[test]
4752    fn issue_template_declaration_order_is_restored() {
4753        let tmp = TempDir::new().unwrap();
4754        let path = write_toml(
4755            tmp.path(),
4756            "issues_dir = \"docs/issues\"\n\n\
4757             [issue_templates.zeta]\nname = \"Zeta\"\n\n\
4758             [issue_templates.alpha]\nname = \"Alpha\"\n",
4759        );
4760        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4761        assert_eq!(
4762            config.issue_templates.ids().collect::<Vec<_>>(),
4763            ["zeta", "alpha"],
4764            "declaration order is the display order"
4765        );
4766        config.save(&path).unwrap();
4767        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4768        assert_eq!(
4769            reloaded.issue_templates.ids().collect::<Vec<_>>(),
4770            ["zeta", "alpha"]
4771        );
4772    }
4773
4774    #[test]
4775    fn a_rejected_template_write_leaves_tix_toml_unchanged() {
4776        let tmp = TempDir::new().unwrap();
4777        let path = write_toml(
4778            tmp.path(),
4779            "issues_dir = \"docs/issues\"\n\n[issue_templates.bug]\nname = \"Bug report\"\n",
4780        );
4781        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4782        let before = std::fs::read(&path).unwrap();
4783
4784        // A scope naming a board that does not exist is a validation error, so
4785        // the caller never reaches `Config::save`.
4786        let mut rejected = config.clone();
4787        rejected.issue_templates.get_mut("bug").unwrap().scope =
4788            TemplateScope::board("no-such-board");
4789        let err = rejected.validate().unwrap_err().to_string();
4790        assert!(
4791            err.contains("issue_templates.bug.scope") && err.contains("no-such-board"),
4792            "{err}"
4793        );
4794        assert_eq!(
4795            std::fs::read(&path).unwrap(),
4796            before,
4797            "a rejected write must not touch tix.toml"
4798        );
4799
4800        // A duplicate identity is refused before any save, leaving both the
4801        // file and the loaded registry untouched.
4802        let mut duplicate = config.clone();
4803        let err = duplicate
4804            .issue_templates
4805            .insert("bug", Template::default())
4806            .unwrap_err();
4807        assert!(err.to_string().contains("already exists"), "{err}");
4808        assert_eq!(duplicate.issue_templates, config.issue_templates);
4809        assert_eq!(std::fs::read(&path).unwrap(), before);
4810    }
4811
4812    #[test]
4813    fn unknown_issue_template_metadata_round_trips_byte_stably() {
4814        let tmp = TempDir::new().unwrap();
4815        let path = write_toml(
4816            tmp.path(),
4817            "issues_dir = \"docs/issues\"\n\n\
4818             [issue_templates.bug]\nname = \"Bug report\"\n\
4819             reporter_hint = \"include the build id\"\n\n\
4820             [issue_templates.bug.future]\nlevel = 3\n",
4821        );
4822        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4823        let bug = config.issue_templates.get("bug").unwrap();
4824        assert_eq!(
4825            bug.extensions
4826                .get("reporter_hint")
4827                .and_then(toml::Value::as_str),
4828            Some("include the build id")
4829        );
4830        assert!(bug.extensions.get("future").is_some());
4831
4832        config.save(&path).unwrap();
4833        let first = std::fs::read(&path).unwrap();
4834        let text = String::from_utf8(first.clone()).unwrap();
4835        assert!(
4836            text.contains("reporter_hint") && text.contains("future"),
4837            "unknown metadata must survive a save:\n{text}"
4838        );
4839
4840        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4841        reloaded.save(&path).unwrap();
4842        assert_eq!(
4843            std::fs::read(&path).unwrap(),
4844            first,
4845            "a second round trip must be byte-identical"
4846        );
4847    }
4848
4849    /// A `tix.toml` that configures limits must survive a save byte-for-byte, and
4850    /// the resolved view of it must be the one the file states (T005).
4851    #[test]
4852    fn wip_config_round_trips_byte_stably_and_resolves() {
4853        let tmp = TempDir::new().unwrap();
4854        let configured = "issues_dir = \"docs/issues\"\n\n\
4855             [kanban.default]\ncolumns = [\n    \"todo\",\n    \"in progress\",\n    \"done\",\n]\n\n\
4856             [kanban.default.wip.units]\nd = 1\nw = 5\n\n\
4857             [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4858             max_estimate = 10\nunit = \"d\"\npolicy = \"block\"\n";
4859        let path = write_toml(tmp.path(), configured);
4860        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4861
4862        let board = config.default_config();
4863        let limit = board
4864            .wip
4865            .effective_limit("in progress")
4866            .expect("the configured limit is in force");
4867        assert_eq!(limit.max_items, Some(3));
4868        assert_eq!(limit.max_estimate, Some(10));
4869        assert_eq!(limit.unit.as_deref(), Some("d"));
4870        assert_eq!(limit.policy, Some(crate::wip::LimitPolicy::Block));
4871        assert_eq!(board.wip.factor("w"), Some(5));
4872        assert!(
4873            board.wip.effective_limit("done").is_none(),
4874            "a column without a limit has none"
4875        );
4876
4877        config.save(&path).unwrap();
4878        assert_eq!(
4879            std::fs::read_to_string(&path).unwrap(),
4880            configured,
4881            "a configured board must round trip byte-for-byte"
4882        );
4883    }
4884
4885    /// The limits live inside the board table, so a limit aimed at a column the
4886    /// board does not declare is refused at load, by name.
4887    #[test]
4888    fn wip_config_limit_on_an_unknown_column_is_refused() {
4889        let tmp = TempDir::new().unwrap();
4890        let path = write_toml(
4891            tmp.path(),
4892            "issues_dir = \"docs/issues\"\n\n\
4893             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4894             [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4895             policy = \"warn\"\n",
4896        );
4897        let error = Config::load_with_warning(&path, |_| {})
4898            .expect_err("an unknown column is refused")
4899            .to_string();
4900        assert!(
4901            error.contains("kanban.default.wip.limits.`in progress`"),
4902            "{error}"
4903        );
4904    }
4905
4906    /// Limits are absent unless declared: the legacy board keeps its exact bytes
4907    /// and gains no `wip` key (T005).
4908    #[test]
4909    fn wip_config_absent_limits_add_no_key() {
4910        let tmp = TempDir::new().unwrap();
4911        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
4912        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4913        assert!(config.default_config().wip.is_empty());
4914        config.save(&path).unwrap();
4915        assert_eq!(
4916            std::fs::read_to_string(&path).unwrap(),
4917            LEGACY_PRETTY_BOARD,
4918            "a board without limits must not gain a `wip` key"
4919        );
4920    }
4921
4922    // --- WP01: the `[projects.<project-id>]` registry (FR-001, C-001, C-002) ---
4923
4924    const TWO_PROJECTS: &str = "issues_dir = \"docs/issues\"\n\n\
4925        [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4926        [projects.\"p-3f9a2c\"]\n\
4927        name = \"Platform hardening\"\n\
4928        description = \"Reduce operational risk across the sync path.\"\n\
4929        status = \"active\"\n\
4930        lead = \"Alice <alice@example.com>\"\n\
4931        start_date = \"2026-09-01\"\n\
4932        due_date = \"2026-12-31\"\n\n\
4933        [projects.\"p-7b1e04\"]\n\
4934        name = \"Docs refresh\"\n";
4935
4936    #[test]
4937    fn project_registry_parses_every_contract_field() {
4938        let tmp = TempDir::new().unwrap();
4939        let path = write_toml(tmp.path(), TWO_PROJECTS);
4940        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4941
4942        let platform = &config.projects["p-3f9a2c"];
4943        assert_eq!(platform.name, "Platform hardening");
4944        assert_eq!(
4945            platform.description.as_deref(),
4946            Some("Reduce operational risk across the sync path.")
4947        );
4948        assert_eq!(platform.status.as_deref(), Some("active"));
4949        assert_eq!(platform.lead.as_deref(), Some("Alice <alice@example.com>"));
4950        assert_eq!(platform.start_date.as_deref(), Some("2026-09-01"));
4951        assert_eq!(platform.due_date.as_deref(), Some("2026-12-31"));
4952
4953        // The key is the id; a record that omits every optional field carries no
4954        // empty value.
4955        let docs = &config.projects["p-7b1e04"];
4956        assert_eq!(docs.name, "Docs refresh");
4957        assert!(docs.description.is_none() && docs.status.is_none() && docs.lead.is_none());
4958        assert!(docs.start_date.is_none() && docs.due_date.is_none());
4959    }
4960
4961    #[test]
4962    fn project_registry_rejects_an_unknown_field() {
4963        let tmp = TempDir::new().unwrap();
4964        let path = write_toml(
4965            tmp.path(),
4966            "issues_dir = \"docs/issues\"\n\n\
4967             [projects.\"p-1\"]\nname = \"One\"\nownr = \"typo\"\n",
4968        );
4969        let err = Config::load_with_warning(&path, |_| {})
4970            .unwrap_err()
4971            .to_string();
4972        assert!(err.contains("ownr"), "got {err}");
4973    }
4974
4975    #[test]
4976    fn project_declaration_order_is_restored() {
4977        let tmp = TempDir::new().unwrap();
4978        let path = write_toml(
4979            tmp.path(),
4980            "issues_dir = \"docs/issues\"\n\n\
4981             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4982             [projects.\"p-zeta\"]\nname = \"Zeta\"\n\n\
4983             [projects.\"p-alpha\"]\nname = \"Alpha\"\n",
4984        );
4985        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4986        assert_eq!(
4987            config.projects.keys().collect::<Vec<_>>(),
4988            ["p-zeta", "p-alpha"],
4989            "declaration order is the display order"
4990        );
4991        config.save(&path).unwrap();
4992        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4993        assert_eq!(
4994            reloaded.projects.keys().collect::<Vec<_>>(),
4995            ["p-zeta", "p-alpha"],
4996            "declaration order survives a save/reload through the ordered map"
4997        );
4998    }
4999
5000    #[test]
5001    fn a_legacy_tix_toml_gains_no_projects_section() {
5002        let tmp = TempDir::new().unwrap();
5003        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5004        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5005        assert!(config.projects.is_empty());
5006        config.save(&path).unwrap();
5007        assert_eq!(
5008            std::fs::read_to_string(&path).unwrap(),
5009            LEGACY_PRETTY_BOARD,
5010            "a repository with no projects must round-trip byte-identically"
5011        );
5012    }
5013
5014    #[test]
5015    fn an_empty_project_record_writes_no_optional_keys() {
5016        let tmp = TempDir::new().unwrap();
5017        let path = write_toml(
5018            tmp.path(),
5019            "issues_dir = \"docs/issues\"\n\n\
5020             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5021             [projects.\"p-1\"]\nname = \"One\"\n",
5022        );
5023        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5024        config.save(&path).unwrap();
5025        let after = std::fs::read_to_string(&path).unwrap();
5026        for key in ["description", "status", "lead", "start_date", "due_date"] {
5027            assert!(
5028                !after.contains(key),
5029                "an absent optional field must not be written: {key} in {after}"
5030            );
5031        }
5032        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5033        assert_eq!(reloaded, config);
5034    }
5035
5036    #[test]
5037    fn a_blank_project_name_is_rejected() {
5038        let tmp = TempDir::new().unwrap();
5039        let path = write_toml(
5040            tmp.path(),
5041            "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \"  \"\n",
5042        );
5043        let err = Config::load_with_warning(&path, |_| {})
5044            .unwrap_err()
5045            .to_string();
5046        assert!(err.contains("projects.p-1.name"), "got {err}");
5047    }
5048
5049    #[test]
5050    fn duplicate_project_names_are_rejected() {
5051        // `Same`/`Same` and `Same`/`same` are both refusals: the point of the rule
5052        // is that a name a user types selects exactly one project, and a user does
5053        // not tell the two spellings apart.
5054        for second in ["Same", "same"] {
5055            let tmp = TempDir::new().unwrap();
5056            let path = write_toml(
5057                tmp.path(),
5058                &format!(
5059                    "issues_dir = \"docs/issues\"\n\n\
5060                     [projects.\"p-1\"]\nname = \"Same\"\n\n\
5061                     [projects.\"p-2\"]\nname = \"{second}\"\n"
5062                ),
5063            );
5064            let err = Config::load_with_warning(&path, |_| {})
5065                .unwrap_err()
5066                .to_string();
5067            assert!(err.contains("must be unique"), "got {err}");
5068        }
5069    }
5070
5071    #[test]
5072    fn a_padded_project_name_is_rejected() {
5073        let tmp = TempDir::new().unwrap();
5074        let path = write_toml(
5075            tmp.path(),
5076            "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n",
5077        );
5078        let err = Config::load_with_warning(&path, |_| {})
5079            .unwrap_err()
5080            .to_string();
5081        assert!(err.contains("leading or trailing whitespace"), "got {err}");
5082    }
5083
5084    #[test]
5085    fn a_blank_project_id_is_rejected() {
5086        let tmp = TempDir::new().unwrap();
5087        let path = write_toml(
5088            tmp.path(),
5089            "issues_dir = \"docs/issues\"\n\n[projects.\"\"]\nname = \"No id\"\n",
5090        );
5091        let err = Config::load_with_warning(&path, |_| {})
5092            .unwrap_err()
5093            .to_string();
5094        assert!(err.contains("must not be blank"), "got {err}");
5095    }
5096
5097    #[test]
5098    fn a_project_id_with_a_structural_character_is_rejected() {
5099        let tmp = TempDir::new().unwrap();
5100        let path = write_toml(
5101            tmp.path(),
5102            "issues_dir = \"docs/issues\"\n\n[projects.\"p.1\"]\nname = \"Dotted\"\n",
5103        );
5104        let err = Config::load_with_warning(&path, |_| {})
5105            .unwrap_err()
5106            .to_string();
5107        assert!(err.contains("contains"), "got {err}");
5108    }
5109
5110    #[test]
5111    fn blank_optional_project_text_is_rejected() {
5112        for key in ["description", "status", "lead"] {
5113            let tmp = TempDir::new().unwrap();
5114            let path = write_toml(
5115                tmp.path(),
5116                &format!(
5117                    "issues_dir = \"docs/issues\"\n\n\
5118                     [projects.\"p-1\"]\nname = \"One\"\n{key} = \"\"\n"
5119                ),
5120            );
5121            let err = Config::load_with_warning(&path, |_| {})
5122                .unwrap_err()
5123                .to_string();
5124            assert!(err.contains(&format!("projects.p-1.{key}")), "got {err}");
5125        }
5126    }
5127
5128    #[test]
5129    fn a_bad_or_inverted_project_date_is_rejected() {
5130        let tmp = TempDir::new().unwrap();
5131        let bad = write_toml(
5132            tmp.path(),
5133            "issues_dir = \"docs/issues\"\n\n\
5134             [projects.\"p-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5135        );
5136        let err = Config::load_with_warning(&bad, |_| {})
5137            .unwrap_err()
5138            .to_string();
5139        assert!(err.contains("projects.p-1"), "got {err}");
5140
5141        let inverted = write_toml(
5142            tmp.path(),
5143            "issues_dir = \"docs/issues\"\n\n\
5144             [projects.\"p-1\"]\nname = \"One\"\n\
5145             start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5146        );
5147        let err = Config::load_with_warning(&inverted, |_| {})
5148            .unwrap_err()
5149            .to_string();
5150        assert!(err.contains("projects.p-1"), "got {err}");
5151    }
5152
5153    #[test]
5154    fn a_valid_project_date_pair_passes() {
5155        let tmp = TempDir::new().unwrap();
5156        let path = write_toml(tmp.path(), TWO_PROJECTS);
5157        Config::load_with_warning(&path, |_| {}).expect("a valid registry must load");
5158    }
5159
5160    /// The shape the allocator produces: `p-` plus 16 hex digits, distinct draws.
5161    #[test]
5162    fn allocated_project_ids_have_the_generated_shape_and_successive_draws_differ() {
5163        let mut config = Config::default();
5164        let first = config.allocate_project_id();
5165        config.projects.insert(
5166            first.to_string(),
5167            ProjectConfiguration {
5168                name: "One".into(),
5169                ..ProjectConfiguration::default()
5170            },
5171        );
5172        let second = config.allocate_project_id();
5173        assert_ne!(first, second, "two allocations must be distinct");
5174        assert_eq!(first.as_str().len(), 18, "`p-` plus 16 hex digits");
5175        assert!(first.as_str().starts_with("p-"));
5176
5177        // From the never-reused rule's second half: a deleted project's id is
5178        // spent. With the real generator this is observed as "a fresh draw is a
5179        // different id"; the redraw half is pinned exactly by
5180        // `allocation_redraws_past_a_stored_id`.
5181        config.projects.shift_remove(first.as_str());
5182        let third = config.allocate_project_id();
5183        assert_ne!(
5184            third, first,
5185            "a deleted project's id must not be handed out"
5186        );
5187        assert_ne!(third, second);
5188    }
5189
5190    /// The exact half of the never-reused rule: allocation redraws past an id that
5191    /// is already stored. The draw is injected, because a real generator collides
5192    /// so rarely that the redraw loop is otherwise untestable.
5193    #[test]
5194    fn allocation_redraws_past_a_stored_id() {
5195        let mut config = Config::default();
5196        config.projects.insert(
5197            "p-aaaa".into(),
5198            ProjectConfiguration {
5199                name: "Stored".into(),
5200                ..ProjectConfiguration::default()
5201            },
5202        );
5203        let scripted = ["p-aaaa", "p-bbbb"];
5204        let mut draws = 0;
5205        let allocated = config.allocate_project_id_with(|| {
5206            let drawn = scripted[draws];
5207            draws += 1;
5208            ProjectId::new(drawn).unwrap()
5209        });
5210        assert_eq!(allocated.as_str(), "p-bbbb");
5211        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5212    }
5213
5214    /// The same exact half, for the schedule registry: allocation redraws past an id
5215    /// that is already stored. The draw is injected for the same reason — a fresh
5216    /// `cs-` id cannot collide with a stored one, so the real generator cannot exercise
5217    /// the loop.
5218    #[test]
5219    fn schedule_allocation_redraws_past_a_stored_id() {
5220        let mut config = Config::default();
5221        config.cycle_schedules.insert(
5222            "cs-aaaa".into(),
5223            crate::cycle_schedule::config::CycleScheduleConfiguration {
5224                name: "Stored".into(),
5225                ..crate::cycle_schedule::config::CycleScheduleConfiguration::default()
5226            },
5227        );
5228        let scripted = ["cs-aaaa", "cs-bbbb"];
5229        let mut draws = 0;
5230        let allocated = config.allocate_cycle_schedule_id_with(|| {
5231            let drawn = scripted[draws];
5232            draws += 1;
5233            crate::cycle_schedule::config::CycleScheduleId::new(drawn).unwrap()
5234        });
5235        assert_eq!(allocated.as_str(), "cs-bbbb");
5236        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5237    }
5238
5239    #[test]
5240    fn a_hand_written_project_id_loads_and_is_never_allocated_again() {
5241        let tmp = TempDir::new().unwrap();
5242        let path = write_toml(
5243            tmp.path(),
5244            "issues_dir = \"docs/issues\"\n\n\
5245             [projects.\"p-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5246        );
5247        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5248        assert!(config.projects.contains_key("p-ffffffffffffffff"));
5249        // The stored id is never returned, however many times a draw scripted to
5250        // produce it is asked.
5251        let mut draws = 0;
5252        let allocated = config.allocate_project_id_with(|| {
5253            draws += 1;
5254            if draws < 3 {
5255                ProjectId::new("p-ffffffffffffffff").unwrap()
5256            } else {
5257                ProjectId::new("p-0000000000000000").unwrap()
5258            }
5259        });
5260        assert_eq!(allocated.as_str(), "p-0000000000000000");
5261        assert_eq!(draws, 3);
5262    }
5263
5264    #[test]
5265    fn a_rename_changes_the_label_and_never_the_key() {
5266        let tmp = TempDir::new().unwrap();
5267        let path = write_toml(tmp.path(), TWO_PROJECTS);
5268        let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5269        config.projects["p-3f9a2c"].name = "Platform hardening (renamed)".into();
5270        config.save(&path).unwrap();
5271
5272        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5273        assert!(
5274            reloaded.projects.contains_key("p-3f9a2c"),
5275            "the id is the key and must survive a rename"
5276        );
5277        assert_eq!(
5278            reloaded.projects["p-3f9a2c"].name,
5279            "Platform hardening (renamed)"
5280        );
5281    }
5282
5283    #[test]
5284    fn project_id_validation_accepts_handles_and_rejects_junk() {
5285        assert!(ProjectId::new("p-platform").is_ok());
5286        assert!(ProjectId::new("p-3f9a2c").is_ok());
5287        assert!(ProjectId::new("").is_err());
5288        assert!(ProjectId::new(" p-1").is_err(), "surrounding whitespace");
5289        assert!(ProjectId::new("p 1").is_err(), "inner whitespace");
5290        assert!(ProjectId::new("p.1").is_err(), "structural `.`");
5291        assert!(ProjectId::new(&"x".repeat(ProjectId::MAX_LEN + 1)).is_err());
5292    }
5293
5294    // --- WP01: the `[epics.<epic-id>]` registry (FR-001, FR-003, C-002, C-003, C-004) ---
5295
5296    const TWO_EPICS: &str = "issues_dir = \"docs/issues\"\n\n\
5297        [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5298        [epics.\"e-7c4a91\"]\n\
5299        name = \"Harden the sync path\"\n\
5300        description = \"Reduce operational risk across every tracker adapter.\"\n\
5301        status = \"active\"\n\
5302        owner = \"Alice <alice@example.com>\"\n\
5303        start_date = \"2026-09-01\"\n\
5304        due_date = \"2026-12-31\"\n\
5305        weight = 5\n\
5306        parent = \"e-2b8f30\"\n\
5307        projects = [\"p-3f9a2c\"]\n\
5308        milestone = \"2026-Q4\"\n\
5309        cycle = \"October 2026\"\n\n\
5310        [epics.\"e-2b8f30\"]\n\
5311        name = \"Q4 reliability\"\n";
5312
5313    #[test]
5314    fn epic_registry_parses_every_contract_field() {
5315        let tmp = TempDir::new().unwrap();
5316        let path = write_toml(tmp.path(), TWO_EPICS);
5317        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5318
5319        let sync = &config.epics["e-7c4a91"];
5320        assert_eq!(sync.name, "Harden the sync path");
5321        assert_eq!(
5322            sync.description.as_deref(),
5323            Some("Reduce operational risk across every tracker adapter.")
5324        );
5325        assert_eq!(sync.status.as_deref(), Some("active"));
5326        assert_eq!(sync.owner.as_deref(), Some("Alice <alice@example.com>"));
5327        assert_eq!(sync.start_date.as_deref(), Some("2026-09-01"));
5328        assert_eq!(sync.due_date.as_deref(), Some("2026-12-31"));
5329        assert_eq!(sync.weight, Some(5));
5330        assert_eq!(sync.parent.as_deref(), Some("e-2b8f30"));
5331        assert_eq!(sync.projects, ["p-3f9a2c"]);
5332        assert_eq!(sync.milestone.as_deref(), Some("2026-Q4"));
5333        assert_eq!(sync.cycle.as_deref(), Some("October 2026"));
5334
5335        // The key is the id; a record that omits every optional field carries no
5336        // value — and no empty `projects` list.
5337        let q4 = &config.epics["e-2b8f30"];
5338        assert_eq!(q4.name, "Q4 reliability");
5339        assert!(q4.description.is_none() && q4.status.is_none() && q4.owner.is_none());
5340        assert!(q4.start_date.is_none() && q4.due_date.is_none());
5341        assert!(q4.weight.is_none() && q4.parent.is_none() && q4.milestone.is_none());
5342        assert!(q4.cycle.is_none());
5343        assert_eq!(q4.projects, [] as [std::string::String; 0]);
5344    }
5345
5346    #[test]
5347    fn epic_registry_rejects_an_unknown_field() {
5348        let tmp = TempDir::new().unwrap();
5349        let path = write_toml(
5350            tmp.path(),
5351            "issues_dir = \"docs/issues\"\n\n\
5352             [epics.\"e-1\"]\nname = \"One\"\nownr = \"typo\"\n",
5353        );
5354        let err = Config::load_with_warning(&path, |_| {})
5355            .unwrap_err()
5356            .to_string();
5357        assert!(err.contains("ownr"), "got {err}");
5358    }
5359
5360    /// `weight` is epic metadata and there is **no** size field on the record:
5361    /// `Issue.estimate` stays the free-form string it is (research R3).
5362    #[test]
5363    fn an_epic_record_takes_weight_but_never_an_estimate_field() {
5364        let tmp = TempDir::new().unwrap();
5365        let path = write_toml(
5366            tmp.path(),
5367            "issues_dir = \"docs/issues\"\n\n\
5368             [epics.\"e-1\"]\nname = \"One\"\nestimate = \"2\"\n",
5369        );
5370        let err = Config::load_with_warning(&path, |_| {})
5371            .unwrap_err()
5372            .to_string();
5373        assert!(err.contains("estimate"), "got {err}");
5374    }
5375
5376    #[test]
5377    fn epic_declaration_order_is_restored() {
5378        let tmp = TempDir::new().unwrap();
5379        let path = write_toml(
5380            tmp.path(),
5381            "issues_dir = \"docs/issues\"\n\n\
5382             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5383             [epics.\"e-zeta\"]\nname = \"Zeta\"\n\n\
5384             [epics.\"e-alpha\"]\nname = \"Alpha\"\n",
5385        );
5386        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5387        assert_eq!(
5388            config.epics.keys().collect::<Vec<_>>(),
5389            ["e-zeta", "e-alpha"],
5390            "declaration order is the display order"
5391        );
5392        config.save(&path).unwrap();
5393        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5394        assert_eq!(
5395            reloaded.epics.keys().collect::<Vec<_>>(),
5396            ["e-zeta", "e-alpha"],
5397            "declaration order survives a save/reload through the ordered map"
5398        );
5399    }
5400
5401    #[test]
5402    fn a_legacy_tix_toml_gains_no_epics_section() {
5403        let tmp = TempDir::new().unwrap();
5404        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5405        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5406        assert!(config.epics.is_empty());
5407        config.save(&path).unwrap();
5408        let after = std::fs::read_to_string(&path).unwrap();
5409        assert_eq!(
5410            after, LEGACY_PRETTY_BOARD,
5411            "a repository with no epics must round-trip byte-identically"
5412        );
5413        assert!(
5414            !after.contains("[epics"),
5415            "an empty registry writes no section: {after}"
5416        );
5417    }
5418
5419    /// The two registries coexist: a config with `[projects]` and no `[epics]`
5420    /// round-trips cleanly, and one with both keeps each section.
5421    #[test]
5422    fn epics_and_projects_registries_coexist() {
5423        let tmp = TempDir::new().unwrap();
5424        let path = write_toml(tmp.path(), TWO_PROJECTS);
5425        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5426        assert!(config.epics.is_empty(), "no `[epics]` means no epics");
5427        config.save(&path).unwrap();
5428        let after = std::fs::read_to_string(&path).unwrap();
5429        assert!(!after.contains("[epics"), "got {after}");
5430        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5431        assert_eq!(reloaded.projects, config.projects);
5432        assert!(reloaded.epics.is_empty());
5433
5434        let both_dir = TempDir::new().unwrap();
5435        let both = write_toml(
5436            both_dir.path(),
5437            "issues_dir = \"docs/issues\"\n\n\
5438             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5439             [projects.\"p-3f9a2c\"]\nname = \"Platform\"\n\n\
5440             [epics.\"e-1\"]\nname = \"Harden\"\nprojects = [\"p-3f9a2c\"]\n",
5441        );
5442        let config = Config::load_with_warning(&both, |_| {}).unwrap();
5443        assert_eq!(config.projects.keys().collect::<Vec<_>>(), ["p-3f9a2c"]);
5444        assert_eq!(config.epics.keys().collect::<Vec<_>>(), ["e-1"]);
5445        config.save(&both).unwrap();
5446        let reloaded = Config::load_with_warning(&both, |_| {}).unwrap();
5447        assert_eq!(reloaded.projects, config.projects);
5448        assert_eq!(reloaded.epics, config.epics);
5449    }
5450
5451    #[test]
5452    fn an_empty_epic_record_writes_no_optional_keys() {
5453        let tmp = TempDir::new().unwrap();
5454        let path = write_toml(
5455            tmp.path(),
5456            "issues_dir = \"docs/issues\"\n\n\
5457             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5458             [epics.\"e-1\"]\nname = \"One\"\n",
5459        );
5460        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5461        config.save(&path).unwrap();
5462        let after = std::fs::read_to_string(&path).unwrap();
5463        for key in [
5464            "description",
5465            "status",
5466            "owner",
5467            "start_date",
5468            "due_date",
5469            "weight",
5470            "parent",
5471            "projects",
5472            "milestone",
5473            "cycle",
5474        ] {
5475            assert!(
5476                !after.contains(key),
5477                "an absent optional field must not be written: {key} in {after}"
5478            );
5479        }
5480        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5481        assert_eq!(reloaded, config);
5482    }
5483
5484    #[test]
5485    fn a_blank_epic_name_is_rejected() {
5486        let tmp = TempDir::new().unwrap();
5487        let path = write_toml(
5488            tmp.path(),
5489            "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"  \"\n",
5490        );
5491        let err = Config::load_with_warning(&path, |_| {})
5492            .unwrap_err()
5493            .to_string();
5494        assert!(err.contains("epics.e-1.name"), "got {err}");
5495    }
5496
5497    #[test]
5498    fn duplicate_epic_names_are_rejected() {
5499        for second in ["Same", "same"] {
5500            let tmp = TempDir::new().unwrap();
5501            let path = write_toml(
5502                tmp.path(),
5503                &format!(
5504                    "issues_dir = \"docs/issues\"\n\n\
5505                     [epics.\"e-1\"]\nname = \"Same\"\n\n\
5506                     [epics.\"e-2\"]\nname = \"{second}\"\n"
5507                ),
5508            );
5509            let err = Config::load_with_warning(&path, |_| {})
5510                .unwrap_err()
5511                .to_string();
5512            assert!(err.contains("must be unique"), "got {err}");
5513        }
5514    }
5515
5516    #[test]
5517    fn a_padded_epic_name_is_rejected() {
5518        let tmp = TempDir::new().unwrap();
5519        let path = write_toml(
5520            tmp.path(),
5521            "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" One \"\n",
5522        );
5523        let err = Config::load_with_warning(&path, |_| {})
5524            .unwrap_err()
5525            .to_string();
5526        assert!(err.contains("leading or trailing whitespace"), "got {err}");
5527    }
5528
5529    #[test]
5530    fn a_blank_epic_id_is_rejected() {
5531        let tmp = TempDir::new().unwrap();
5532        let path = write_toml(
5533            tmp.path(),
5534            "issues_dir = \"docs/issues\"\n\n[epics.\"\"]\nname = \"No id\"\n",
5535        );
5536        let err = Config::load_with_warning(&path, |_| {})
5537            .unwrap_err()
5538            .to_string();
5539        assert!(err.contains("must not be blank"), "got {err}");
5540    }
5541
5542    #[test]
5543    fn an_epic_id_with_a_structural_character_is_rejected() {
5544        let tmp = TempDir::new().unwrap();
5545        let path = write_toml(
5546            tmp.path(),
5547            "issues_dir = \"docs/issues\"\n\n[epics.\"e.1\"]\nname = \"Dotted\"\n",
5548        );
5549        let err = Config::load_with_warning(&path, |_| {})
5550            .unwrap_err()
5551            .to_string();
5552        assert!(err.contains("contains"), "got {err}");
5553    }
5554
5555    #[test]
5556    fn blank_optional_epic_text_is_rejected() {
5557        for key in ["description", "status", "owner", "milestone", "cycle"] {
5558            let tmp = TempDir::new().unwrap();
5559            let path = write_toml(
5560                tmp.path(),
5561                &format!(
5562                    "issues_dir = \"docs/issues\"\n\n\
5563                     [epics.\"e-1\"]\nname = \"One\"\n{key} = \"\"\n"
5564                ),
5565            );
5566            let err = Config::load_with_warning(&path, |_| {})
5567                .unwrap_err()
5568                .to_string();
5569            assert!(err.contains(&format!("epics.e-1.{key}")), "got {err}");
5570        }
5571    }
5572
5573    #[test]
5574    fn a_bad_or_inverted_epic_date_is_rejected() {
5575        let tmp = TempDir::new().unwrap();
5576        let bad = write_toml(
5577            tmp.path(),
5578            "issues_dir = \"docs/issues\"\n\n\
5579             [epics.\"e-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5580        );
5581        let err = Config::load_with_warning(&bad, |_| {})
5582            .unwrap_err()
5583            .to_string();
5584        assert!(err.contains("epics.e-1"), "got {err}");
5585
5586        let inverted = write_toml(
5587            tmp.path(),
5588            "issues_dir = \"docs/issues\"\n\n\
5589             [epics.\"e-1\"]\nname = \"One\"\n\
5590             start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5591        );
5592        let err = Config::load_with_warning(&inverted, |_| {})
5593            .unwrap_err()
5594            .to_string();
5595        assert!(err.contains("epics.e-1"), "got {err}");
5596    }
5597
5598    #[test]
5599    fn a_non_positive_or_unparseable_weight_is_rejected() {
5600        for body in ["weight = 0", "weight = -3"] {
5601            let tmp = TempDir::new().unwrap();
5602            let path = write_toml(
5603                tmp.path(),
5604                &format!(
5605                    "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5606                ),
5607            );
5608            let err = Config::load_with_warning(&path, |_| {})
5609                .unwrap_err()
5610                .to_string();
5611            assert!(err.contains("positive integer"), "{body}: got {err}");
5612            assert!(err.contains("epics.e-1.weight"), "{body}: got {err}");
5613        }
5614
5615        // Unparseable: the field is an integer, so the TOML parser refuses a string.
5616        let tmp = TempDir::new().unwrap();
5617        let path = write_toml(
5618            tmp.path(),
5619            "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\nweight = \"big\"\n",
5620        );
5621        let err = Config::load_with_warning(&path, |_| {})
5622            .unwrap_err()
5623            .to_string();
5624        assert!(err.contains("weight"), "got {err}");
5625    }
5626
5627    /// Config-layer validation is about the **shape** of a reference, never its
5628    /// resolution: a project, milestone, cycle, or parent epic that does not exist
5629    /// must stay loadable and repairable (C-005). Resolution and diagnosis belong to
5630    /// the epic service (WP02) and to `tix lint`.
5631    #[test]
5632    fn epic_references_are_shape_checked_but_never_resolved() {
5633        let tmp = TempDir::new().unwrap();
5634        let path = write_toml(
5635            tmp.path(),
5636            "issues_dir = \"docs/issues\"\n\n\
5637             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5638             [epics.\"e-1\"]\nname = \"Child\"\n\
5639             parent = \"e-missing\"\nprojects = [\"p-missing\"]\n\
5640             milestone = \"2026-Q4\"\ncycle = \"No such cycle\"\n",
5641        );
5642        let config = Config::load_with_warning(&path, |_| {})
5643            .expect("a dangling reference must stay loadable");
5644        let epic = &config.epics["e-1"];
5645        assert_eq!(epic.parent.as_deref(), Some("e-missing"));
5646        assert_eq!(epic.projects, ["p-missing"]);
5647        assert_eq!(epic.milestone.as_deref(), Some("2026-Q4"));
5648        assert_eq!(epic.cycle.as_deref(), Some("No such cycle"));
5649
5650        // Shape, however, is this layer's business.
5651        for (body, needle) in [
5652            ("parent = \"e.1\"", "epics.e-1.parent"),
5653            ("parent = \"\"", "epics.e-1.parent"),
5654            ("parent = \"e-1\"", "must not name the epic itself"),
5655            ("projects = [\"p.1\"]", "epics.e-1.projects"),
5656        ] {
5657            let tmp = TempDir::new().unwrap();
5658            let path = write_toml(
5659                tmp.path(),
5660                &format!(
5661                    "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5662                ),
5663            );
5664            let err = Config::load_with_warning(&path, |_| {})
5665                .unwrap_err()
5666                .to_string();
5667            assert!(err.contains(needle), "{body}: got {err}");
5668        }
5669    }
5670
5671    /// The shape the allocator produces: `e-` plus 16 hex digits, distinct draws.
5672    #[test]
5673    fn allocated_epic_ids_have_the_generated_shape_and_successive_draws_differ() {
5674        let mut config = Config::default();
5675        let first = config.allocate_epic_id();
5676        config.epics.insert(
5677            first.to_string(),
5678            EpicConfiguration {
5679                name: "One".into(),
5680                ..EpicConfiguration::default()
5681            },
5682        );
5683        let second = config.allocate_epic_id();
5684        assert_ne!(first, second, "two allocations must be distinct");
5685        assert_eq!(first.as_str().len(), 18, "`e-` plus 16 hex digits");
5686        assert!(first.as_str().starts_with("e-"));
5687
5688        // A deleted epic's id is spent: the never-reused rule is what keeps a member
5689        // issue's dangling reference diagnosable instead of silently rebound (C-005).
5690        config.epics.shift_remove(first.as_str());
5691        let third = config.allocate_epic_id();
5692        assert_ne!(third, first, "a deleted epic's id must not be handed out");
5693        assert_ne!(third, second);
5694    }
5695
5696    /// The exact half of the never-reused rule: allocation redraws past an id that is
5697    /// already stored. The draw is injected, because a real generator collides so
5698    /// rarely that the redraw loop is otherwise untestable.
5699    #[test]
5700    fn epic_allocation_redraws_past_a_stored_id() {
5701        let mut config = Config::default();
5702        config.epics.insert(
5703            "e-aaaa".into(),
5704            EpicConfiguration {
5705                name: "Stored".into(),
5706                ..EpicConfiguration::default()
5707            },
5708        );
5709        let scripted = ["e-aaaa", "e-bbbb"];
5710        let mut draws = 0;
5711        let allocated = config.allocate_epic_id_with(|| {
5712            let drawn = scripted[draws];
5713            draws += 1;
5714            EpicId::new(drawn).unwrap()
5715        });
5716        assert_eq!(allocated.as_str(), "e-bbbb");
5717        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5718    }
5719
5720    #[test]
5721    fn a_hand_written_epic_id_loads_and_is_never_allocated_again() {
5722        let tmp = TempDir::new().unwrap();
5723        let path = write_toml(
5724            tmp.path(),
5725            "issues_dir = \"docs/issues\"\n\n\
5726             [epics.\"e-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5727        );
5728        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5729        assert!(config.epics.contains_key("e-ffffffffffffffff"));
5730        // The stored id is never returned, however many times a draw scripted to
5731        // produce it is asked. A hand-written handle coexists with the generated shape.
5732        let mut draws = 0;
5733        let allocated = config.allocate_epic_id_with(|| {
5734            draws += 1;
5735            if draws < 3 {
5736                EpicId::new("e-ffffffffffffffff").unwrap()
5737            } else {
5738                EpicId::new("e-0000000000000000").unwrap()
5739            }
5740        });
5741        assert_eq!(allocated.as_str(), "e-0000000000000000");
5742        assert_eq!(draws, 3);
5743    }
5744
5745    #[test]
5746    fn an_epic_rename_changes_the_label_and_never_the_key() {
5747        let tmp = TempDir::new().unwrap();
5748        let path = write_toml(tmp.path(), TWO_EPICS);
5749        let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5750        config.epics["e-7c4a91"].name = "Harden the sync path (renamed)".into();
5751        config.save(&path).unwrap();
5752
5753        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5754        assert!(
5755            reloaded.epics.contains_key("e-7c4a91"),
5756            "the id is the key and must survive a rename"
5757        );
5758        assert_eq!(
5759            reloaded.epics["e-7c4a91"].name,
5760            "Harden the sync path (renamed)"
5761        );
5762    }
5763
5764    #[test]
5765    fn epic_id_validation_accepts_handles_and_rejects_junk() {
5766        assert!(EpicId::new("e-platform").is_ok());
5767        assert!(EpicId::new("e-7c4a91").is_ok());
5768        assert!(EpicId::new("").is_err());
5769        assert!(EpicId::new(" e-1").is_err(), "surrounding whitespace");
5770        assert!(EpicId::new("e 1").is_err(), "inner whitespace");
5771        assert!(EpicId::new("e.1").is_err(), "structural `.`");
5772        assert!(EpicId::new(&"x".repeat(EpicId::MAX_LEN + 1)).is_err());
5773    }
5774
5775    /// The two registries share one rule for a key, not two: the checks are the same
5776    /// and the message differs only in the prefix words. The project strings are
5777    /// asserted exactly, because they are the ones issue #178 shipped.
5778    #[test]
5779    fn the_two_registries_share_the_id_rules() {
5780        assert_eq!(
5781            ProjectId::new("").unwrap_err().to_string(),
5782            "config error: a project id must not be blank"
5783        );
5784        assert_eq!(
5785            EpicId::new("").unwrap_err().to_string(),
5786            "config error: an epic id must not be blank"
5787        );
5788        assert_eq!(
5789            ProjectId::new(" p-1").unwrap_err().to_string(),
5790            "config error: project id ` p-1` has leading or trailing whitespace"
5791        );
5792        assert_eq!(
5793            EpicId::new("e-1 ").unwrap_err().to_string(),
5794            "config error: epic id `e-1 ` has leading or trailing whitespace"
5795        );
5796        assert_eq!(ProjectId::MAX_LEN, EpicId::MAX_LEN);
5797
5798        // A handle that is valid for one kind is valid for the other, and the same
5799        // junk is refused by both.
5800        for handle in ["x-y", "a_b", "a:b", "a-1"] {
5801            assert!(ProjectId::new(handle).is_ok(), "{handle}");
5802            assert!(EpicId::new(handle).is_ok(), "{handle}");
5803        }
5804        for junk in ["", " a", "a b", "a.b", "a[b", "a\"b"] {
5805            assert!(ProjectId::new(junk).is_err(), "{junk}");
5806            assert!(EpicId::new(junk).is_err(), "{junk}");
5807        }
5808    }
5809
5810    const ONE_INITIATIVE: &str = "issues_dir = \"docs/issues\"\n\n\
5811        [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5812        [initiatives.\"i-1\"]\nname = \"Grow the platform\"\n";
5813
5814    #[test]
5815    fn initiative_id_validation_accepts_handles_and_rejects_junk() {
5816        assert!(InitiativeId::new("i-outcome").is_ok());
5817        assert!(InitiativeId::new("i-7c4a91").is_ok());
5818        assert!(InitiativeId::new("").is_err());
5819        assert!(InitiativeId::new(" i-1").is_err(), "surrounding whitespace");
5820        assert!(InitiativeId::new("i 1").is_err(), "inner whitespace");
5821        assert!(InitiativeId::new("i.1").is_err(), "structural `.`");
5822        assert!(InitiativeId::new(&"x".repeat(InitiativeId::MAX_LEN + 1)).is_err());
5823    }
5824
5825    /// The third registry shares the same key rule, worded for its own kind: the
5826    /// project/epic assertions above pin the rule; this pins the third spelling.
5827    #[test]
5828    fn the_three_registries_share_the_id_rules() {
5829        assert_eq!(
5830            InitiativeId::new("").unwrap_err().to_string(),
5831            "config error: an initiative id must not be blank"
5832        );
5833        assert_eq!(
5834            InitiativeId::new(" i-1").unwrap_err().to_string(),
5835            "config error: initiative id ` i-1` has leading or trailing whitespace"
5836        );
5837        assert_eq!(InitiativeId::MAX_LEN, ProjectId::MAX_LEN);
5838        assert_eq!(InitiativeId::MAX_LEN, EpicId::MAX_LEN);
5839    }
5840
5841    #[test]
5842    fn allocated_initiative_ids_have_the_generated_shape_and_successive_draws_differ() {
5843        let mut config = Config::default();
5844        let first = config.allocate_initiative_id();
5845        config.initiatives.insert(
5846            first.to_string(),
5847            InitiativeConfiguration {
5848                name: "One".into(),
5849                ..InitiativeConfiguration::default()
5850            },
5851        );
5852        let second = config.allocate_initiative_id();
5853        assert_ne!(first, second, "two allocations must be distinct");
5854        assert_eq!(first.as_str().len(), 18, "`i-` plus 16 hex digits");
5855        assert!(first.as_str().starts_with("i-"));
5856
5857        // A deleted initiative's id is spent: a later record must not be able to
5858        // masquerade as the one that was removed.
5859        config.initiatives.shift_remove(first.as_str());
5860        let third = config.allocate_initiative_id();
5861        assert_ne!(
5862            third, first,
5863            "a deleted initiative's id must not be handed out"
5864        );
5865        assert_ne!(third, second);
5866    }
5867
5868    #[test]
5869    fn initiative_allocation_redraws_past_a_stored_id() {
5870        let mut config = Config::default();
5871        config.initiatives.insert(
5872            "i-aaaa".into(),
5873            InitiativeConfiguration {
5874                name: "Stored".into(),
5875                ..InitiativeConfiguration::default()
5876            },
5877        );
5878        let scripted = ["i-aaaa", "i-bbbb"];
5879        let mut draws = 0;
5880        let allocated = config.allocate_initiative_id_with(|| {
5881            let drawn = scripted[draws];
5882            draws += 1;
5883            InitiativeId::new(drawn).unwrap()
5884        });
5885        assert_eq!(allocated.as_str(), "i-bbbb");
5886        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5887    }
5888
5889    /// An absent `[initiatives]` section gains no key on save (C-001), and a record
5890    /// with only a name writes no optional key — the "empty record" round trip.
5891    #[test]
5892    fn an_absent_initiatives_section_and_an_empty_record_write_nothing_extra() {
5893        let tmp = TempDir::new().unwrap();
5894        let legacy = write_toml(
5895            tmp.path(),
5896            "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
5897        );
5898        let config = Config::load_with_warning(&legacy, |_| {}).unwrap();
5899        assert!(config.initiatives.is_empty());
5900        config.save(&legacy).unwrap();
5901        assert!(
5902            !std::fs::read_to_string(&legacy)
5903                .unwrap()
5904                .contains("[initiatives"),
5905            "a legacy file gains no section"
5906        );
5907
5908        let path = write_toml(tmp.path(), ONE_INITIATIVE);
5909        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5910        config.save(&path).unwrap();
5911        let after = std::fs::read_to_string(&path).unwrap();
5912        for key in [
5913            "outcome",
5914            "owner",
5915            "status",
5916            "priority",
5917            "labels",
5918            "target_date",
5919            "projects",
5920            "epics",
5921            "milestones",
5922        ] {
5923            assert!(
5924                !after.contains(key),
5925                "an absent optional field must not be written: {key} in {after}"
5926            );
5927        }
5928        assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
5929    }
5930
5931    /// The record deliberately preserves unknown metadata: unlike `[projects]`/
5932    /// `[epics]`, a key this schema does not know round-trips instead of making the
5933    /// file unreadable. This is the trade-off the type-level doc records.
5934    #[test]
5935    fn an_initiative_preserves_unknown_metadata_through_a_round_trip() {
5936        let tmp = TempDir::new().unwrap();
5937        let path = write_toml(
5938            tmp.path(),
5939            "issues_dir = \"docs/issues\"\n\n\
5940             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5941             [initiatives.\"i-1\"]\nname = \"One\"\ncustom_field = \"keep me\"\n\
5942             nested = { answer = 42 }\n",
5943        );
5944        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5945        let extensions = &config.initiatives["i-1"].extensions;
5946        assert_eq!(extensions.len(), 2);
5947        assert_eq!(
5948            extensions.get("custom_field").and_then(toml::Value::as_str),
5949            Some("keep me")
5950        );
5951        config.save(&path).unwrap();
5952        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5953        assert_eq!(reloaded.initiatives["i-1"].extensions, *extensions);
5954    }
5955
5956    #[test]
5957    fn initiative_validation_refuses_blank_names_bad_dates_and_bad_references() {
5958        for (body, needle) in [
5959            ("name = \"  \"", "initiatives.i-1.name"),
5960            ("name = \" One \"", "leading or trailing whitespace"),
5961            (
5962                "name = \"One\"\ntarget_date = \"2026-13-01\"",
5963                "initiatives.i-1.target_date",
5964            ),
5965            (
5966                "name = \"One\"\ntarget_date = \"not a date\"",
5967                "initiatives.i-1.target_date",
5968            ),
5969            (
5970                "name = \"One\"\nprojects = [\"p.1\"]",
5971                "initiatives.i-1.projects",
5972            ),
5973            ("name = \"One\"\nepics = [\"e.1\"]", "initiatives.i-1.epics"),
5974            (
5975                "name = \"One\"\nmilestones = [\"\"]",
5976                "initiatives.i-1.milestones",
5977            ),
5978            (
5979                "name = \"One\"\nlabels = [\"  \"]",
5980                "initiatives.i-1.labels",
5981            ),
5982            ("name = \"One\"\nowner = \"\"", "initiatives.i-1.owner"),
5983        ] {
5984            let tmp = TempDir::new().unwrap();
5985            let path = write_toml(
5986                tmp.path(),
5987                &format!(
5988                    "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5989                     [initiatives.\"i-1\"]\n{body}\n"
5990                ),
5991            );
5992            let err = Config::load_with_warning(&path, |_| {})
5993                .unwrap_err()
5994                .to_string();
5995            assert!(err.contains(needle), "{body}: got {err}");
5996        }
5997    }
5998
5999    #[test]
6000    fn duplicate_initiative_names_are_rejected_case_folded() {
6001        for second in ["Same", "same"] {
6002            let tmp = TempDir::new().unwrap();
6003            let path = write_toml(
6004                tmp.path(),
6005                &format!(
6006                    "issues_dir = \"docs/issues\"\n\n\
6007                     [initiatives.\"i-1\"]\nname = \"Same\"\n\n\
6008                     [initiatives.\"i-2\"]\nname = \"{second}\"\n"
6009                ),
6010            );
6011            let err = Config::load_with_warning(&path, |_| {})
6012                .unwrap_err()
6013                .to_string();
6014            assert!(err.contains("must be unique"), "got {err}");
6015        }
6016    }
6017
6018    /// Shape is this layer's business; existence never is: a dangling reference must
6019    /// stay loadable and repairable (C-005).
6020    #[test]
6021    fn initiative_references_are_shape_checked_but_never_resolved() {
6022        let tmp = TempDir::new().unwrap();
6023        let path = write_toml(
6024            tmp.path(),
6025            "issues_dir = \"docs/issues\"\n\n\
6026             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6027             [initiatives.\"i-1\"]\nname = \"One\"\n\
6028             projects = [\"p-missing\"]\nepics = [\"e-missing\"]\nmilestones = [\"2026-Q4\"]\n",
6029        );
6030        let config = Config::load_with_warning(&path, |_| {})
6031            .expect("a dangling reference must stay loadable");
6032        let initiative = &config.initiatives["i-1"];
6033        assert_eq!(initiative.projects, ["p-missing"]);
6034        assert_eq!(initiative.epics, ["e-missing"]);
6035        assert_eq!(initiative.milestones, ["2026-Q4"]);
6036    }
6037
6038    /// The three stable-ID registries keep declaration order through save/reload
6039    /// (the map is an `IndexMap` and the session restores the header order the TOML
6040    /// parser loses).
6041    #[test]
6042    fn initiative_declaration_order_survives_a_save_and_reload() {
6043        let tmp = TempDir::new().unwrap();
6044        let path = write_toml(
6045            tmp.path(),
6046            "issues_dir = \"docs/issues\"\n\n\
6047             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6048             [initiatives.\"i-zulu\"]\nname = \"Zulu\"\n\n\
6049             [initiatives.\"i-alpha\"]\nname = \"Alpha\"\n",
6050        );
6051        let config = Config::load_with_warning(&path, |_| {}).unwrap();
6052        assert_eq!(
6053            config.initiatives.keys().collect::<Vec<_>>(),
6054            ["i-zulu", "i-alpha"]
6055        );
6056        config.save(&path).unwrap();
6057        let written = std::fs::read_to_string(&path).unwrap();
6058        assert!(
6059            written.find("[initiatives.i-zulu]").unwrap()
6060                < written.find("[initiatives.i-alpha]").unwrap(),
6061            "declaration order is preserved on write:\n{written}"
6062        );
6063        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6064        assert_eq!(
6065            reloaded.initiatives.keys().collect::<Vec<_>>(),
6066            ["i-zulu", "i-alpha"]
6067        );
6068    }
6069
6070    /// `priority` reuses the shared [`crate::model::Priority`]: a known spelling is
6071    /// normalized by the same rule issues use, and an unknown value round-trips
6072    /// verbatim.
6073    #[test]
6074    fn an_initiative_priority_uses_the_shared_shape() {
6075        let tmp = TempDir::new().unwrap();
6076        let path = write_toml(
6077            tmp.path(),
6078            "issues_dir = \"docs/issues\"\n\n\
6079             [initiatives.\"i-1\"]\nname = \"Known\"\npriority = \"HIGH\"\n\n\
6080             [initiatives.\"i-2\"]\nname = \"Unknown\"\npriority = \"urgent-ish\"\n",
6081        );
6082        let config = Config::load_with_warning(&path, |_| {}).unwrap();
6083        assert_eq!(
6084            config.initiatives["i-1"].priority,
6085            Some(crate::model::Priority::High)
6086        );
6087        assert_eq!(
6088            config.initiatives["i-2"].priority,
6089            Some(crate::model::Priority::Other("urgent-ish".into()))
6090        );
6091        config.save(&path).unwrap();
6092        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6093        let written = std::fs::read_to_string(&path).unwrap();
6094        assert!(written.contains("priority = \"high\""), "{written}");
6095        assert!(written.contains("priority = \"urgent-ish\""), "{written}");
6096        assert_eq!(reloaded.initiatives, config.initiatives);
6097    }
6098
6099    /// The preserved-extension map must not shadow a built-in field: `toml` would
6100    /// write the key twice with no duplicate detection, so an "accepted" write would
6101    /// make the next load fail. The check runs inside `validate_initiatives`, which
6102    /// every write path already goes through.
6103    #[test]
6104    fn a_shadowing_extension_key_is_refused_by_validation() {
6105        for key in INITIATIVE_KNOWN_FIELDS {
6106            let mut extensions = crate::roadmap::registry::InitiativeExtensions::default();
6107            extensions.insert(*key, toml::Value::String("evil".into()));
6108            let mut config = Config::default();
6109            config.initiatives.insert(
6110                "i-1".into(),
6111                InitiativeConfiguration {
6112                    name: "One".into(),
6113                    extensions,
6114                    ..InitiativeConfiguration::default()
6115                },
6116            );
6117            let err = config.validate().unwrap_err().to_string();
6118            assert!(
6119                err.contains("collides with a built-in field"),
6120                "{key}: got {err}"
6121            );
6122        }
6123
6124        // The list is the record's own field set, kept in step by this check: a
6125        // field missing from it would be shadowable, and an extra name would refuse
6126        // a harmless extension.
6127        let mut config = Config::default();
6128        config.initiatives.insert(
6129            "i-1".into(),
6130            InitiativeConfiguration {
6131                name: "One".into(),
6132                ..InitiativeConfiguration::default()
6133            },
6134        );
6135        config.validate().unwrap();
6136    }
6137}