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    /// How this repository talks to a [beads](https://github.com/gastownhall/beads)
1406    /// workspace (`[beads]`).
1407    ///
1408    /// Mission-tracking issues tix #1709/#1710. `off` (the default) writes no
1409    /// section, so a repository that never opts in is unchanged and round-trips
1410    /// byte-identically. `mirror` reads a beads workspace through the `bd` CLI
1411    /// without writing anything anywhere; `sync` keeps both sides in step through
1412    /// two one-way pipes. Every interaction is a `bd` CLI call — tix never opens
1413    /// the Dolt database. See [`crate::sync::beads`].
1414    #[serde(default, skip_serializing_if = "BeadsConfig::is_default")]
1415    pub beads: BeadsConfig,
1416}
1417
1418/// The `[beads]` section: how this repository talks to a beads workspace.
1419///
1420/// The `binary` and `id_prefix` are stated rather than inferred so a workspace
1421/// that renames its `bd` binary, or wants its exported rows under a different
1422/// prefix, can say so without patching tix.
1423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1424#[serde(default, deny_unknown_fields)]
1425pub struct BeadsConfig {
1426    /// Whether tix talks to a beads workspace at all, and how.
1427    pub mode: BeadsMode,
1428    /// The binary shelled out to. Default `bd`.
1429    pub binary: String,
1430    /// The beads workspace to talk to, when it is not the one `bd` would find.
1431    ///
1432    /// Set into `BEADS_DIR` for every `bd` call, which is beads' own override.
1433    /// `None` means "wherever `bd` looks", which is what a beads workspace in
1434    /// this repository (or in a parent) needs.
1435    #[serde(skip_serializing_if = "Option::is_none")]
1436    pub workspace: Option<PathBuf>,
1437    /// The prefix exported rows carry: issue `#1705` becomes `tix-1705`.
1438    ///
1439    /// Deterministic, which is what makes a re-export upsert rather than
1440    /// duplicate.
1441    pub id_prefix: String,
1442}
1443
1444impl Default for BeadsConfig {
1445    fn default() -> Self {
1446        BeadsConfig {
1447            mode: BeadsMode::default(),
1448            binary: crate::sync::beads::DEFAULT_BINARY.to_string(),
1449            workspace: None,
1450            id_prefix: crate::sync::beads::DEFAULT_ID_PREFIX.to_string(),
1451        }
1452    }
1453}
1454
1455impl BeadsConfig {
1456    /// Whether this section carries nothing worth writing: the default section is
1457    /// omitted, so a repository that never opted in gains no `[beads]` key on save
1458    /// — and an older binary (whose `Config` is `deny_unknown_fields`) can still
1459    /// read the file.
1460    #[must_use]
1461    pub fn is_default(&self) -> bool {
1462        *self == BeadsConfig::default()
1463    }
1464}
1465
1466/// How tix talks to a beads workspace ([`BeadsConfig::mode`]).
1467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1468#[serde(rename_all = "lowercase")]
1469pub enum BeadsMode {
1470    /// No beads interaction. The default, and what a repository without beads
1471    /// carries.
1472    #[default]
1473    Off,
1474    /// Read a beads workspace through `bd` for viewing only; tix writes nothing —
1475    /// neither to beads nor to an issue file.
1476    Mirror,
1477    /// Keep tix and beads in step through two one-way pipes
1478    /// (`tix` → `bd import`, and beads' own hooks → `tix beads pull`), joined by a
1479    /// watermark so neither side echoes the other's change back.
1480    Sync,
1481}
1482
1483impl BeadsMode {
1484    /// The `--beads` spelling.
1485    #[must_use]
1486    pub fn as_str(self) -> &'static str {
1487        match self {
1488            BeadsMode::Off => "off",
1489            BeadsMode::Mirror => "mirror",
1490            BeadsMode::Sync => "sync",
1491        }
1492    }
1493
1494    /// Parse the `--beads` spelling, or `None` for anything outside the vocabulary
1495    /// (which the caller reports rather than coercing).
1496    #[must_use]
1497    pub fn from_key(key: &str) -> Option<Self> {
1498        match key {
1499            "off" => Some(BeadsMode::Off),
1500            "mirror" => Some(BeadsMode::Mirror),
1501            "sync" => Some(BeadsMode::Sync),
1502            _ => None,
1503        }
1504    }
1505
1506    /// Every mode, in the vocabulary's order.
1507    pub const ALL: [BeadsMode; 3] = [BeadsMode::Off, BeadsMode::Mirror, BeadsMode::Sync];
1508}
1509
1510/// The `[custom_attributes]` section: top-level frontmatter keys this project
1511/// carries on purpose (#1361).
1512///
1513/// A **section** rather than a bare top-level array so there is somewhere to put
1514/// the next option, and so the declaration reads as a named decision rather than as
1515/// one more anonymous list in a file that is mostly named sections.
1516///
1517/// ```toml
1518/// [custom_attributes]
1519/// keys = ["build_id", "reviewed_by"]
1520/// ```
1521#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1522#[serde(default, deny_unknown_fields)]
1523pub struct CustomAttributes {
1524    /// Top-level frontmatter key names. Names only — no dotted paths; the thing
1525    /// being declared is a top-level key, and a path syntax here would imply nested
1526    /// declarations are checked when they are not.
1527    pub keys: Vec<String>,
1528}
1529
1530impl CustomAttributes {
1531    /// Whether anything is declared, for `skip_serializing_if`.
1532    pub fn is_empty(&self) -> bool {
1533        self.keys.is_empty()
1534    }
1535}
1536
1537/// Serde default for a boolean that defaults to `true`.
1538///
1539/// Shared with [`crate::capacity::config::CapacityEntry`], whose `enabled` follows the
1540/// same rule the WIP limits set: writing the default is not writing anything.
1541pub(crate) fn default_true() -> bool {
1542    true
1543}
1544/// `skip_serializing_if` for a boolean whose default is `true`: only an explicit
1545/// `false` is written to disk.
1546#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&bool) -> bool`
1547pub(crate) fn is_true(value: &bool) -> bool {
1548    *value
1549}
1550
1551/// The default issue types: what `tix init` writes into `types`, and what
1552/// `tix lint` accepts when a repository carries no `types`.
1553///
1554/// `default` is the value an issue without a `type:` falls back to (in `[layout]`
1555/// and as an explicit option). `bug`, `open` and `duplicate` are the requested
1556/// baseline, with the usual companions.
1557pub const DEFAULT_TYPES: &[&str] = &[
1558    "default",
1559    "bug",
1560    "feature",
1561    "task",
1562    "improvement",
1563    "documentation",
1564    "question",
1565    "duplicate",
1566    "chore",
1567    "open",
1568];
1569
1570/// Labels tix itself ships and always recognises — no configuration required.
1571///
1572/// `decision-pending` is the label behind the `DECISION:` convention: an open
1573/// question a human must settle before the issue can move. It is a **builtin**,
1574/// not a config default, so every repository resolves it whether or not its
1575/// `tix.toml` names it — and `tix init` does not write a `labels` key for it (a
1576/// new key would be rejected by an older binary's `deny_unknown_fields`).
1577pub const BUILTIN_LABELS: &[&str] = &["decision-pending"];
1578
1579/// A shipped accent color per default type (tix #1530).
1580///
1581/// The color names are the ones [`crate::ui::color::parse`] understands. A type
1582/// absent here — and absent from `[type_colors]` — renders uncolored. `open` and
1583/// `default` are deliberately neutral (gray/white) so a type chip does not
1584/// compete with the priority and blocked markers beside it.
1585pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
1586    ("default", "gray"),
1587    ("bug", "red"),
1588    ("feature", "green"),
1589    ("task", "blue"),
1590    ("improvement", "cyan"),
1591    ("documentation", "yellow"),
1592    ("question", "magenta"),
1593    ("duplicate", "dark_gray"),
1594    ("chore", "dark_gray"),
1595    ("open", "white"),
1596];
1597
1598/// The `[mentions]` section: durable `@handle` / `@email` mention tracking.
1599///
1600/// Default **enabled**, unlike the opt-in `[triage]` section: the feature is a scan that runs
1601/// on an issue write that already happened, and its footprint is bounded on the other side —
1602/// a mention file is created lazily, only once a real mention is found, and a bare `user@host`
1603/// or a `@word` inside code is not a mention. Setting `enabled = false` is the off switch, and
1604/// it stops both the scan and every write.
1605///
1606/// Omitted from the file when it is the default, so a `tix.toml` that has never configured
1607/// mentions round-trips byte-identically and gains no key on save.
1608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1609#[serde(default, deny_unknown_fields)]
1610pub struct MentionsConfig {
1611    /// Whether issue writes scan for mentions and record them.
1612    pub enabled: bool,
1613    /// Directory holding the per-target mention files, relative to the project root unless
1614    /// absolute. `None` is not a state: the default is [`DEFAULT_MENTIONS_DIR`].
1615    pub dir: PathBuf,
1616}
1617
1618/// The directory mention files live in, relative to the project root.
1619pub const DEFAULT_MENTIONS_DIR: &str = "docs/mentions";
1620
1621impl Default for MentionsConfig {
1622    fn default() -> Self {
1623        MentionsConfig {
1624            enabled: true,
1625            dir: PathBuf::from(DEFAULT_MENTIONS_DIR),
1626        }
1627    }
1628}
1629
1630impl MentionsConfig {
1631    /// Whether this is the default, for `skip_serializing_if`.
1632    #[must_use]
1633    pub fn is_default(&self) -> bool {
1634        *self == Self::default()
1635    }
1636
1637    /// The absolute directory the store reads and writes.
1638    ///
1639    /// A relative `dir` resolves against the **project root** — the directory holding
1640    /// `tix.toml` — not the invocation directory, the same rule `issues_dir` follows
1641    /// ([`crate::storage::Store::open`]).
1642    #[must_use]
1643    pub fn resolve_dir(&self, issues_dir: &Path) -> PathBuf {
1644        if self.dir.is_absolute() {
1645            return self.dir.clone();
1646        }
1647        match Config::project_root(issues_dir) {
1648            Some(root) => root.join(&self.dir),
1649            None => self.dir.clone(),
1650        }
1651    }
1652}
1653
1654/// The `[layout]` section: configurable on-disk issue layout.
1655///
1656/// Tokens are validated at config load ([`crate::layout::validate`]). The
1657/// defaults reproduce the documented layout — `{assignee}/{type}/{status}` for
1658/// the directory, `{date}-{slug}.md` for the filename, moving files when a
1659/// templated value changes.
1660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1661#[serde(default, deny_unknown_fields)]
1662pub struct LayoutConfig {
1663    /// Directory template, relative to `issues_dir`.
1664    pub directory: String,
1665    /// Filename template; must end in `.md`.
1666    pub filename: String,
1667    /// Relocate an issue when a templated value changes (status/assignee/type).
1668    pub move_on_change: bool,
1669}
1670
1671impl Default for LayoutConfig {
1672    fn default() -> Self {
1673        LayoutConfig {
1674            directory: "{assignee}/{type}/{status}".to_string(),
1675            // Date first, then the id: a lexicographic listing of the directory
1676            // is then creation order, while the id keeps the name globally
1677            // unique across branches (and disambiguates same-day, same-title
1678            // issues). `number` is deliberately absent — it can collide across
1679            // branches and is not an identity.
1680            filename: "{date}-{id}-{slug}.md".to_string(),
1681            move_on_change: true,
1682        }
1683    }
1684}
1685
1686impl LayoutConfig {
1687    /// Whether this is the default, for `skip_serializing_if`.
1688    #[must_use]
1689    pub fn is_default(&self) -> bool {
1690        *self == Self::default()
1691    }
1692}
1693
1694impl Default for Config {
1695    fn default() -> Self {
1696        Config {
1697            kanban: KanbanSection {
1698                configurations: IndexMap::from([(
1699                    "default".to_string(),
1700                    BoardConfiguration::default(),
1701                )]),
1702            },
1703            issues_dir: PathBuf::from("docs/issues"),
1704            milestones_dir: None,
1705            teams: IndexMap::new(),
1706            cycles: IndexMap::new(),
1707            templates: IndexMap::new(),
1708            views: crate::views::registry::ViewRegistry::new(),
1709            health: HealthConfig::default(),
1710            issue_templates: TemplateRegistry::default(),
1711            planning: PlanningConfig::default(),
1712            categories: CategoryConfig::default(),
1713            transitions: TransitionConfig::default(),
1714            // The bare default has no inbox, and that stays true: the mechanism is
1715            // opt-in, so a config with no `[triage]` section has no inbox and every
1716            // `tix triage` command refuses, naming the section. What changed is the
1717            // *starting point*: `tix init` writes the section (and the board columns it
1718            // needs), so a new repository begins with an inbox rather than with a
1719            // refusal. See [`crate::commands::init`].
1720            triage: None,
1721            editor: None,
1722            viewer: None,
1723            state: StateConfig::default(),
1724            export: ExportConfig::default(),
1725            agent: None,
1726            keybindings: KeyBindings::default(),
1727            custom_attributes: CustomAttributes::default(),
1728            lint_foreign_repositories: true,
1729            tips_on_startup: true,
1730            mentions: MentionsConfig::default(),
1731            layout: LayoutConfig::default(),
1732            types: None,
1733            labels: None,
1734            type_colors: IndexMap::new(),
1735            tags_dir: None,
1736            repos: IndexMap::new(),
1737            parent_repo: None,
1738            projects: IndexMap::new(),
1739            epics: IndexMap::new(),
1740            initiatives: IndexMap::new(),
1741            cycle_schedules: IndexMap::new(),
1742            capacity: CapacityConfig::new(),
1743            beads: BeadsConfig::default(),
1744        }
1745    }
1746}
1747
1748impl Config {
1749    /// The configuration a **new repository** starts with — what `tix init` writes.
1750    ///
1751    /// Deliberately not [`Default`]: that is the fallback for a missing or partial
1752    /// `tix.toml` (and the base hundreds of tests build fixtures on), and it stays
1753    /// minimal. The *starting point* is a product decision: a new repository gets the
1754    /// board [`starter_board`] lays out and a working `[triage]` section, so
1755    /// `tix triage list` works on a fresh checkout instead of refusing and asking for
1756    /// configuration first.
1757    ///
1758    /// The three keys match the board: unreviewed work sits in `triage`, an accept
1759    /// lands in `todo`, a decline in `wontfix`. `deferred_status` is left unset
1760    /// because it defaults to `entry_status`, and a default that is never written is a
1761    /// default that cannot drift from the meaning it stands for.
1762    #[must_use]
1763    pub fn starter() -> Config {
1764        let mut config = Config::default();
1765        config
1766            .kanban
1767            .configurations
1768            .insert("default".to_string(), starter_board());
1769        config.triage = Some(crate::triage::TriageConfig {
1770            entry_status: Some("triage".to_string()),
1771            accepted_status: Some("todo".to_string()),
1772            declined_status: Some("wontfix".to_string()),
1773            deferred_status: None,
1774        });
1775        // `tix init` writes the default issue types explicitly, so a new project
1776        // sees and can edit them; a repository without the key falls back to the
1777        // same list at lint time.
1778        config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
1779        // `labels` is deliberately NOT written: `decision-pending` is a builtin
1780        // (`BUILTIN_LABELS`), so no key is needed, and emitting a brand-new key
1781        // would make a fresh `tix.toml` unreadable to a released binary whose
1782        // `Config` denies unknown fields.
1783        // Ship the tag directory explicitly (tix #1530), so a new project has
1784        // somewhere to drop `docs/tags/*.md` without having to know the key.
1785        config.tags_dir = Some(PathBuf::from("docs/tags"));
1786        config
1787    }
1788
1789    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
1790    /// the `Config` error variant with a human message. Errors name the
1791    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
1792    pub(crate) fn validate(&self) -> Result<()> {
1793        if self.issues_dir.as_os_str().is_empty() {
1794            return Err(TixError::Config("`issues_dir` must not be blank".into()));
1795        }
1796        if self.milestones_dir().as_os_str().is_empty() {
1797            return Err(TixError::Config(
1798                "`milestones_dir` must not be blank".into(),
1799            ));
1800        }
1801        if self.tags_dir().as_os_str().is_empty() {
1802            return Err(TixError::Config("`tags_dir` must not be blank".into()));
1803        }
1804        validate_repos(&self.repos)?;
1805        // The health policy version is a closed set, and a repository naming one
1806        // this build does not implement is refused here rather than at the first
1807        // rollup: a value derived under a formula nobody can name is worse than a
1808        // file that will not load.
1809        self.health.policy()?;
1810        if self
1811            .parent_repo
1812            .as_deref()
1813            .is_some_and(|p| p.as_os_str().is_empty())
1814        {
1815            return Err(TixError::Config("`parent_repo` must not be blank".into()));
1816        }
1817        // A blank `viewer` is a mistake with a clear spelling: the way to ask for
1818        // the probe chain is `auto` (or omitting the key). Rejected here rather
1819        // than silently treated as auto, so a typo cannot quietly change the
1820        // meaning of a saved file.
1821        if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
1822            return Err(TixError::Config(
1823                "`viewer` must not be blank; use `auto` for the default chain".into(),
1824            ));
1825        }
1826        validate_planning(&self.planning)?;
1827        crate::layout::validate(&self.layout)?;
1828        validate_categories(&self.categories)?;
1829        self.effective_keybindings()?;
1830        if self.kanban.configurations.is_empty() {
1831            return Err(TixError::Config(
1832                "`[kanban]` must define at least one configuration".into(),
1833            ));
1834        }
1835        for (name, board) in &self.kanban.configurations {
1836            validate_board(name, board)?;
1837            validate_category_map(name, board, &self.categories)?;
1838        }
1839        for (label, team) in &self.teams {
1840            validate_team(label, team)?;
1841        }
1842        for (name, cycle) in &self.cycles {
1843            validate_cycle(name, cycle)?;
1844        }
1845        for (name, template) in &self.templates {
1846            validate_template(name, template)?;
1847        }
1848        // Project records: a valid stable id as the key, a non-blank unique display
1849        // name, present-but-blank optional text refused, and the shared date rules.
1850        // Project *resolution* (does a view/issue reference an existing project?) is
1851        // not `Config` validation, for the same reason view scopes are not: a
1852        // reference to a deleted project must stay loadable and diagnosable (C-005).
1853        validate_projects(&self.projects)?;
1854        // Epic records: the same structural rules as projects (a valid stable id as
1855        // the key, a non-blank unique display name, present-but-blank optional text
1856        // refused, the shared date rules), plus a positive `weight` and the
1857        // epic-specific reference *shapes*. Epic *resolution* — does a project,
1858        // milestone, cycle, or parent epic exist? — is deliberately not `Config`
1859        // validation, for the same reason project resolution is not: a dangling
1860        // reference caused by a deletion or a hand edit must stay loadable and
1861        // diagnosable (C-005).
1862        validate_epics(&self.epics)?;
1863        // Initiative records: the same structural rules as projects and epics (a
1864        // valid stable id as the key, a non-blank unique display name, the shared
1865        // calendar-date rule), plus the initiative-specific reference *shapes* —
1866        // project/epic ids for `projects`/`epics` and non-blank names for
1867        // `milestones`. Initiative *resolution* — does a referenced project, epic, or
1868        // milestone exist? — is deliberately not `Config` validation, for the same
1869        // reason project/epic resolution is not: a dangling reference caused by a
1870        // deletion or a hand edit must stay loadable and diagnosable (C-005).
1871        validate_initiatives(&self.initiatives)?;
1872        crate::cycle_schedule::config::validate_schedules(&self.cycle_schedules)?;
1873        // `[capacity]`'s rootless half — the entry handle, exactly one scope, the
1874        // ceiling, and the *declared unit* — is validated here, because every
1875        // validate-then-save path (`tix wip unit --remove`, the schedule registry, the MCP
1876        // write tools) validates with this method before it writes: without the unit check
1877        // there, removing a unit a ceiling names would persist a file the next load
1878        // refuses. The half that needs the cycle **records** — resolving a cycle reference
1879        // to a generated cycle's identity — is validated by the loader
1880        // (`Config::load_with_warning`) and by `crate::capacity::config::set_capacity`,
1881        // the only two gates with a project root; see `crate::capacity::config::validate`.
1882        crate::capacity::config::validate(&self.capacity, self)?;
1883        // View records are validated for *structure* here: a blank name, a scope
1884        // that contradicts itself, an id that disagrees with its table key. Project
1885        // *resolution* is deliberately not part of `Config` validation — it needs
1886        // the project registry, and a view pointing at a project that was deleted
1887        // must stay loadable (and diagnosable) rather than making `tix.toml`
1888        // unreadable. The write path refuses it instead
1889        // (`ViewRegistry::commit`, C-008).
1890        self.views
1891            .check_structure()
1892            .map_err(|refusal| TixError::Config(refusal.render()))?;
1893        // Stable-ID templates: scope binding, prompt shape, references and
1894        // preserved metadata. Field-aware errors name `issue_templates.<id>...`.
1895        self.issue_templates.validate(self)?;
1896        // Last, deliberately. `[triage]` names columns from the active board
1897        // *and* from that board's `category_map`, so it can only be judged once
1898        // both are; running it earlier would hand `workflow::resolution` an
1899        // unvalidated vocabulary, which is the `unreachable!` it documents
1900        // (C-002).
1901        crate::triage::config::validate(self)?;
1902        Ok(())
1903    }
1904
1905    /// Resolve sparse user overrides over the built-in TUI registry.
1906    pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
1907        KeyBindings::resolve(&self.keybindings)
1908    }
1909
1910    /// Allocate a [`ProjectId`] that is not already in the registry.
1911    ///
1912    /// Redraws against the stored ids, so a stored id is never handed out a second
1913    /// time. The rest of the never-reused guarantee is carried by the id shape
1914    /// itself (see [`StableId::generate`]); there is no counter persisted in
1915    /// `tix.toml`, so a deletion cannot reset an allocator — the id a deleted
1916    /// project carried stays spent because no draw can repeat one this process
1917    /// already produced.
1918    #[must_use]
1919    pub fn allocate_project_id(&self) -> ProjectId {
1920        self.allocate_project_id_with(ProjectId::generate)
1921    }
1922
1923    /// [`Self::allocate_project_id`] with the draw injected, so the redraw loop
1924    /// itself is testable: a real generator collides so rarely that a test driving
1925    /// it can only ever observe the first iteration.
1926    fn allocate_project_id_with(&self, draw: impl FnMut() -> ProjectId) -> ProjectId {
1927        allocate_stable_id(&self.projects, draw)
1928    }
1929
1930    /// Allocate an [`EpicId`] that is not already in the registry.
1931    ///
1932    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
1933    /// ids, so a stored id is never handed out a second time, and the rest of the
1934    /// never-reused guarantee is carried by the id shape itself
1935    /// ([`StableId::generate`]) — a deleted epic's id stays spent, keeping a member
1936    /// issue's dangling reference diagnosable rather than silently rebound (C-005).
1937    #[must_use]
1938    pub fn allocate_epic_id(&self) -> EpicId {
1939        self.allocate_epic_id_with(EpicId::generate)
1940    }
1941
1942    /// [`Self::allocate_epic_id`] with the draw injected, so the redraw loop itself
1943    /// is testable.
1944    fn allocate_epic_id_with(&self, draw: impl FnMut() -> EpicId) -> EpicId {
1945        allocate_stable_id(&self.epics, draw)
1946    }
1947
1948    /// Allocate an [`InitiativeId`] that is not already in the registry.
1949    ///
1950    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
1951    /// ids, so a stored id is never handed out a second time, and the rest of the
1952    /// never-reused guarantee is carried by the id shape itself
1953    /// ([`StableId::generate`]) — a deleted initiative's id stays spent, keeping the
1954    /// record's identity distinct from any later one.
1955    #[must_use]
1956    pub fn allocate_initiative_id(&self) -> InitiativeId {
1957        self.allocate_initiative_id_with(InitiativeId::generate)
1958    }
1959
1960    /// [`Self::allocate_initiative_id`] with the draw injected, so the redraw loop
1961    /// itself is testable.
1962    fn allocate_initiative_id_with(&self, draw: impl FnMut() -> InitiativeId) -> InitiativeId {
1963        allocate_stable_id(&self.initiatives, draw)
1964    }
1965
1966    /// Allocate a fresh [`crate::cycle_schedule::config::CycleScheduleId`].
1967    ///
1968    /// Mirrors [`Self::allocate_initiative_id`]: the draw is redrawn against the
1969    /// stored ids, so a stored id is never handed out a second time, and the rest of
1970    /// the never-reused guarantee is carried by the id shape itself
1971    /// ([`StableId::generate`]) — a deleted schedule's id stays spent, keeping the
1972    /// record's identity distinct from any later one (C-003).
1973    #[must_use]
1974    pub fn allocate_cycle_schedule_id(&self) -> crate::cycle_schedule::config::CycleScheduleId {
1975        self.allocate_cycle_schedule_id_with(
1976            crate::cycle_schedule::config::CycleScheduleId::generate,
1977        )
1978    }
1979
1980    /// [`Self::allocate_cycle_schedule_id`] with the draw injected, so the redraw loop
1981    /// itself is testable: a real generator collides with a stored id so rarely that
1982    /// the loop is otherwise unverifiable.
1983    fn allocate_cycle_schedule_id_with(
1984        &self,
1985        draw: impl FnMut() -> crate::cycle_schedule::config::CycleScheduleId,
1986    ) -> crate::cycle_schedule::config::CycleScheduleId {
1987        allocate_stable_id(&self.cycle_schedules, draw)
1988    }
1989
1990    /// Restore the canonical declaration order of named configurations,
1991    /// which the TOML parser loses (it sorts table keys). The order comes
1992    /// from `[kanban.<name>]` section headers in the source document.
1993    fn restore_declaration_order(&mut self, contents: &str) {
1994        crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
1995        crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
1996        crate::config::restore_section_order(&mut self.teams, contents, "teams");
1997        crate::config::restore_section_order(&mut self.templates, contents, "templates");
1998        self.views.restore_declaration_order(contents);
1999        crate::config::restore_section_order(
2000            &mut self.issue_templates.templates,
2001            contents,
2002            "issue_templates",
2003        );
2004        crate::config::restore_section_order(&mut self.projects, contents, "projects");
2005        crate::config::restore_section_order(&mut self.epics, contents, "epics");
2006        crate::config::restore_section_order(&mut self.initiatives, contents, "initiatives");
2007        crate::config::restore_section_order(
2008            &mut self.cycle_schedules,
2009            contents,
2010            "cycle_schedules",
2011        );
2012        self.capacity.restore_declaration_order(contents);
2013    }
2014
2015    /// Declared custom top-level frontmatter keys, lowercased for comparison.
2016    ///
2017    /// Lowercased because YAML keys are case-sensitive but nobody has ever
2018    /// intended `Titel` and `titel` to be different keys, and a suppression list
2019    /// that silently fails to match is worse than no list at all.
2020    pub fn custom_attributes(&self) -> Vec<String> {
2021        self.custom_attributes
2022            .keys
2023            .iter()
2024            .map(|k| k.trim().to_ascii_lowercase())
2025            .collect()
2026    }
2027
2028    /// The allowed issue types: the configured `types`, or [`DEFAULT_TYPES`]
2029    /// when unset. Compared case-insensitively by callers.
2030    #[must_use]
2031    pub fn types(&self) -> Vec<String> {
2032        self.types
2033            .clone()
2034            .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
2035    }
2036
2037    /// The pre-#1530 name for [`Self::types`], kept so existing call sites and
2038    /// tests read the same list under the old spelling.
2039    #[must_use]
2040    pub fn custom_types(&self) -> Vec<String> {
2041        self.types()
2042    }
2043
2044    /// The recognised issue labels: [`BUILTIN_LABELS`], plus any extra labels the
2045    /// project declares under `labels` in `tix.toml`.
2046    ///
2047    /// Builtins come first and are always present, so `decision-pending` resolves
2048    /// in a repository that never configured the key — which is why `tix init`
2049    /// does not write one.
2050    #[must_use]
2051    pub fn labels(&self) -> Vec<String> {
2052        let mut out: Vec<String> = BUILTIN_LABELS.iter().map(|s| (*s).to_string()).collect();
2053        if let Some(extra) = &self.labels {
2054            for label in extra {
2055                if !out.contains(label) {
2056                    out.push(label.clone());
2057                }
2058            }
2059        }
2060        out
2061    }
2062
2063    /// The color name for issue `type`, from `[type_colors]` when set for it, else
2064    /// the shipped [`DEFAULT_TYPE_COLORS`] entry. `None` for a type with no color
2065    /// anywhere (an unknown type). Compared case-insensitively (tix #1530).
2066    #[must_use]
2067    pub fn type_color(&self, type_name: &str) -> Option<String> {
2068        let needle = type_name.trim().to_ascii_lowercase();
2069        self.type_colors
2070            .iter()
2071            .find(|(name, _)| name.to_ascii_lowercase() == needle)
2072            .map(|(_, color)| color.clone())
2073            .or_else(|| {
2074                DEFAULT_TYPE_COLORS
2075                    .iter()
2076                    .find(|(name, _)| *name == needle)
2077                    .map(|(_, color)| (*color).to_string())
2078            })
2079    }
2080
2081    /// The attribute list shown and edited in the Config view: the declared
2082    /// `[custom_attributes].keys`, or the built-in attributes tix models when
2083    /// none are declared. Mirrors [`Self::custom_types`] (`configured-or-defaults`).
2084    #[must_use]
2085    pub fn attributes(&self) -> Vec<String> {
2086        if self.custom_attributes.keys.is_empty() {
2087            crate::model::KNOWN_FRONTMATTER_KEYS
2088                .iter()
2089                .filter(|key| **key != "links")
2090                .map(|key| (*key).to_string())
2091                .collect()
2092        } else {
2093            self.custom_attributes.keys.clone()
2094        }
2095    }
2096
2097    /// The resolved milestones directory: the configured value, or the
2098    /// contract default `docs/milestones` when unset.
2099    pub fn milestones_dir(&self) -> &Path {
2100        self.milestones_dir
2101            .as_deref()
2102            .unwrap_or(Path::new("docs/milestones"))
2103    }
2104
2105    /// The resolved tags directory: the configured `tags_dir`, or the contract
2106    /// default `docs/tags` when unset (tix #1530).
2107    pub fn tags_dir(&self) -> &Path {
2108        self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
2109    }
2110
2111    /// The child repositories this project coordinates (`[repos]`, tix #1529).
2112    pub fn repos(&self) -> &IndexMap<String, PathBuf> {
2113        &self.repos
2114    }
2115
2116    /// The parent repository, when this project is a managed child (tix #1529).
2117    pub fn parent_repo(&self) -> Option<&Path> {
2118        self.parent_repo.as_deref()
2119    }
2120
2121    /// Absolutize the paths that are resolved against the project root:
2122    /// an explicitly configured `tags_dir`, every `[repos]` entry, and
2123    /// `parent_repo` (tix #1529/#1530).
2124    ///
2125    /// Only a **relative** configured value is rewritten, so an unset key stays
2126    /// `None` (and is not materialized on save) and an absolute one is left
2127    /// alone. `issues_dir`/`milestones_dir` are resolved elsewhere; this covers
2128    /// the newer keys so the TUI can spawn `tix` in a child from any working
2129    /// directory.
2130    pub fn resolve_repo_paths(&mut self, base: &Path) {
2131        if let Some(path) = self.tags_dir.as_ref() {
2132            if path.is_relative() {
2133                self.tags_dir = Some(base.join(path));
2134            }
2135        }
2136        for path in self.repos.values_mut() {
2137            if path.is_relative() {
2138                *path = base.join(&*path);
2139            }
2140        }
2141        if let Some(path) = self.parent_repo.clone() {
2142            if path.is_relative() {
2143                self.parent_repo = Some(base.join(path));
2144            }
2145        }
2146    }
2147
2148    /// The configuration used by the CLI and other single-config surfaces:
2149    /// the one named `default` if present, else the first declared.
2150    pub fn default_config(&self) -> &BoardConfiguration {
2151        self.kanban
2152            .configurations
2153            .get("default")
2154            .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
2155            .expect("validate() guarantees at least one configuration")
2156    }
2157
2158    /// Named configurations in declaration order (tab order = declaration
2159    /// order, D-02).
2160    pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
2161        self.kanban
2162            .configurations
2163            .iter()
2164            .map(|(name, board)| (name.as_str(), board))
2165    }
2166
2167    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
2168    /// every cleartext token read from the file.
2169    ///
2170    /// Parse and validation failures surface as [`TixError::Config`] with
2171    /// the file's path and (where the TOML parser reports it) line.
2172    pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
2173        let contents = std::fs::read_to_string(path)
2174            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2175        let mut config: Config = toml::from_str(&contents)
2176            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2177        config.restore_declaration_order(&contents);
2178        // File stores win over the legacy tables whenever their directory
2179        // exists. Absent directories mean an existing project: its
2180        // `[kanban.*]`/`[cycles.*]` tables load exactly as before.
2181        let root = path
2182            .parent()
2183            .filter(|p| !p.as_os_str().is_empty())
2184            .unwrap_or_else(|| Path::new("."));
2185        let board_store = BoardStore::new(root);
2186        if board_store.exists() {
2187            let boards = board_store
2188                .list()
2189                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2190            if !boards.is_empty() {
2191                // The board **files** own a board's shape — its columns, its
2192                // categories, its display — and `tix.toml` owns its **limits**:
2193                // C-001 puts the work-in-progress configuration there, and a
2194                // scaffolded repository (`tix init`) keeps its board in
2195                // `docs/boards/<name>.md`. Replacing the section wholesale, which is
2196                // what this did before limits existed, silently dropped them — so a
2197                // `tix wip set` on such a project wrote a limit that nothing ever
2198                // read (found by WP06's MCP test, whose `tix init` repository showed
2199                // no limit at all).
2200                let declared = std::mem::take(&mut config.kanban.configurations);
2201                config.kanban = KanbanSection {
2202                    configurations: boards
2203                        .into_iter()
2204                        .map(|(name, mut board)| {
2205                            if let Some(from_toml) = declared.get(&name) {
2206                                board.wip = from_toml.wip.clone();
2207                            }
2208                            (name, board)
2209                        })
2210                        .collect(),
2211                };
2212            }
2213        }
2214        let cycle_store = CycleStore::new(root);
2215        if cycle_store.exists() {
2216            let cycles = cycle_store
2217                .list()
2218                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2219            config.cycles = cycles
2220                .into_iter()
2221                .map(|record| (record.name.clone(), record.into()))
2222                .collect();
2223        }
2224        if config.kanban.configurations.is_empty() {
2225            config.kanban = KanbanSection {
2226                configurations: IndexMap::from([(
2227                    "default".to_string(),
2228                    BoardConfiguration::default(),
2229                )]),
2230            };
2231        }
2232        config.validate().map_err(|e| at_file(path, e))?;
2233        // The records' half of the capacity section's validation: a cycle reference
2234        // resolves against `docs/cycles/` (or the legacy `[cycles.<name>]` tables when there
2235        // is no store), and the records are what tell a generated cycle's name from a
2236        // hand-written one. `capacity::config::set_capacity` is the other gate, over the
2237        // same records, for every programmatic change (WP01).
2238        crate::capacity::config::validate_cycle_references(&config.capacity, &config, root)
2239            .map_err(|e| at_file(path, e))?;
2240        for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
2241            .into_iter()
2242            .flatten()
2243        {
2244            if remote.token.is_some() {
2245                sink(
2246                    "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
2247                );
2248            }
2249        }
2250        Ok(config)
2251    }
2252
2253    /// Load the config for a project root: `tix.toml` at the root if
2254    /// present, otherwise the defaults. An absent file is *not* an error —
2255    /// the CLI layer decides whether to nag about `tix init`.
2256    pub fn load(project_root: &Path) -> Result<Config> {
2257        let path = project_root.join("tix.toml");
2258        if !path.is_file() {
2259            return Ok(Config::default());
2260        }
2261        Self::load_with_warning(&path, |w| eprintln!("{w}"))
2262    }
2263
2264    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
2265    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
2266    pub fn project_root(cwd: &Path) -> Option<PathBuf> {
2267        let mut dir = Some(cwd.to_path_buf());
2268        while let Some(current) = dir {
2269            if current.join("tix.toml").is_file() {
2270                return Some(current);
2271            }
2272            dir = current.parent().map(Path::to_path_buf);
2273        }
2274        None
2275    }
2276
2277    /// A copy of this config with every token stripped, for writing.
2278    fn sanitized_for_disk(&self) -> Config {
2279        let mut copy = self.clone();
2280        if let Some(gitlab) = copy.export.gitlab.as_mut() {
2281            gitlab.token = None;
2282        }
2283        if let Some(linear) = copy.export.linear.as_mut() {
2284            linear.token = None;
2285        }
2286        copy
2287    }
2288
2289    /// Write `tix.toml` atomically (temp file + rename in the same
2290    /// directory, with an `fsync` before the rename — NFR-006).
2291    ///
2292    /// Tokens are never written to disk, so a round-trip `load` → `save`
2293    /// drops any cleartext token (expected: prefer env vars).
2294    pub fn save(&self, path: &Path) -> Result<()> {
2295        let dir = match path.parent() {
2296            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
2297            _ => PathBuf::from("."),
2298        };
2299        std::fs::create_dir_all(&dir)?;
2300
2301        // A file store that exists is authoritative for its records: write
2302        // them there and leave them out of `tix.toml`. Every other setting
2303        // still round-trips through the TOML file.
2304        let board_store = BoardStore::new(&dir);
2305        let board_store_active = board_store.exists();
2306        if board_store_active {
2307            for (name, board) in &self.kanban.configurations {
2308                let filename = format!("{}.md", crate::slug::slugify(name));
2309                let body = RecordStore::new(board_store.dir()).body_of(&filename);
2310                board_store.save(name, board, body.as_deref())?;
2311            }
2312        }
2313        let cycle_store = CycleStore::new(&dir);
2314        let cycle_store_active = cycle_store.exists();
2315        if cycle_store_active {
2316            for (name, cycle) in &self.cycles {
2317                if is_implicit_default(name, cycle) {
2318                    continue;
2319                }
2320                let filename = format!("{}.md", crate::slug::slugify(name));
2321                let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
2322                cycle_store.save(name, cycle, body.as_deref())?;
2323            }
2324        }
2325
2326        let mut disk = self.sanitized_for_disk();
2327        if board_store_active {
2328            disk.kanban = KanbanSection::default();
2329        }
2330        if cycle_store_active {
2331            disk.cycles.clear();
2332        }
2333        let contents = toml::to_string_pretty(&disk)
2334            .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
2335
2336        let file_name = path
2337            .file_name()
2338            .and_then(|n| n.to_str())
2339            .unwrap_or("tix.toml");
2340        let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
2341
2342        let write_result = (|| -> std::io::Result<()> {
2343            let mut f = std::fs::File::create(&tmp)?;
2344            use std::io::Write;
2345            f.write_all(contents.as_bytes())?;
2346            f.sync_all()?;
2347            Ok(())
2348        })();
2349        if let Err(e) = write_result {
2350            let _ = std::fs::remove_file(&tmp);
2351            return Err(TixError::Io(e));
2352        }
2353
2354        if let Err(e) = std::fs::rename(&tmp, path) {
2355            let _ = std::fs::remove_file(&tmp);
2356            return Err(TixError::Io(e));
2357        }
2358        Ok(())
2359    }
2360}
2361
2362/// Prefix a configuration error with the file it came from.
2363///
2364/// A load failure has to name its source: a program that coordinates repositories
2365/// (tix #1529) reads more than one `tix.toml`, and a message that does not say which one
2366/// was refused is a message nobody can act on. Shared by the two validation gates a load
2367/// runs, so both name the file the same way.
2368fn at_file(path: &Path, error: TixError) -> TixError {
2369    match error {
2370        TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
2371        other => other,
2372    }
2373}
2374
2375/// Redraw a fresh id until it is not already a key of `registry`.
2376///
2377/// Shared by [`Config::allocate_project_id`] and [`Config::allocate_epic_id`]. It is
2378/// the exact half of the never-reused guarantee: a *stored* id is never handed out a
2379/// second time. A deletion cannot reset it, because the allocator holds no state a
2380/// removal could rewind — the remaining half is carried by the id shape itself
2381/// ([`StableId::generate`]).
2382pub(crate) fn allocate_stable_id<I: StableId, V>(
2383    registry: &IndexMap<String, V>,
2384    mut draw: impl FnMut() -> I,
2385) -> I {
2386    loop {
2387        let candidate = draw();
2388        if !registry.contains_key(candidate.as_str()) {
2389            return candidate;
2390        }
2391    }
2392}
2393
2394/// Reorder a named-config `IndexMap` to the declaration order of its
2395/// `[<section>.<name>]` section headers in the source document (D-02:
2396/// declaration order = tab order). The `toml` crate sorts table keys on
2397/// deserialize, so the parser's order is not trustworthy; this returns to the
2398/// document's byte order. Applies to `[kanban.*]`, `[cycles.*]`, `[teams.*]`,
2399/// `[projects.*]`, `[epics.*]`, `[initiatives.*]`, `[cycle_schedules.*]` and
2400/// `[views.*]` (the last through
2401/// its own wrapper). Names are unquoted when the header used a quoted key (e.g.
2402/// `[cycles."Cycle 1"]`).
2403pub(crate) fn restore_section_order<V: Clone>(
2404    map: &mut IndexMap<String, V>,
2405    contents: &str,
2406    section: &str,
2407) {
2408    let mut order: Vec<String> = Vec::new();
2409    for line in contents.lines() {
2410        let line = line.trim();
2411        if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
2412            && let Some((head, key)) = rest.split_once('.')
2413            && head.trim() == section
2414            && !key.trim().contains('.')
2415            && let Some(name) = unquote_key(key.trim())
2416            && !order.iter().any(|n| n == name)
2417        {
2418            order.push(name.to_string());
2419        }
2420    }
2421    if order.is_empty() {
2422        return;
2423    }
2424    let mut ordered = IndexMap::new();
2425    for name in &order {
2426        if let Some(value) = map.shift_remove(name) {
2427            ordered.insert(name.clone(), value);
2428        }
2429    }
2430    ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
2431    *map = ordered;
2432}
2433
2434/// Strip a single pair of surrounding single or double quotes from a TOML
2435/// table-header key, when present.
2436fn unquote_key(key: &str) -> Option<&str> {
2437    if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
2438        Some(inner)
2439    } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
2440        Some(inner)
2441    } else {
2442        Some(key)
2443    }
2444}
2445
2446/// Validation for the `[planning]` section (NFR-003): the counter must be
2447/// positive once the section is present.
2448fn validate_planning(planning: &PlanningConfig) -> Result<()> {
2449    if planning.cycle_number == 0 {
2450        return Err(TixError::Config(
2451            "`planning.cycle_number` must be at least 1".into(),
2452        ));
2453    }
2454    Ok(())
2455}
2456
2457/// `[repos]`: a name and a path, neither blank (tix #1529).
2458///
2459/// The depth rule is structural rather than a check here: a parent lists only
2460/// its own direct children, and a child reaches the parent through
2461/// `parent_repo`, so a two-level chain is two processes, never a nested walk.
2462fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
2463    for (name, path) in repos {
2464        if name.trim().is_empty() {
2465            return Err(TixError::Config(
2466                "`[repos]` entry names must not be blank".into(),
2467            ));
2468        }
2469        if path.as_os_str().is_empty() {
2470            return Err(TixError::Config(format!(
2471                "`repos.{name}` must name the repository path"
2472            )));
2473        }
2474    }
2475    Ok(())
2476}
2477
2478/// Per-team validation (NFR-003): non-blank label, non-empty members, unique
2479/// member identities.
2480fn validate_team(label: &str, team: &Team) -> Result<()> {
2481    if label.trim().is_empty() {
2482        return Err(TixError::Config("`teams.<label>` must not be blank".into()));
2483    }
2484    if team.members.is_empty() {
2485        return Err(TixError::Config(format!(
2486            "`teams.{label}.members` must not be empty"
2487        )));
2488    }
2489    let mut seen: HashSet<&str> = HashSet::new();
2490    for member in &team.members {
2491        if !seen.insert(member.as_str()) {
2492            return Err(TixError::Config(format!(
2493                "`teams.{label}.members` must be unique; found duplicate `{member}`"
2494            )));
2495        }
2496    }
2497    Ok(())
2498}
2499
2500/// The key of a `[<section>.<id>]` registry must be a valid stable id.
2501///
2502/// Shared by `[projects.<project-id>]`, `[epics.<epic-id>]`,
2503/// `[initiatives.<initiative-id>]` and `[cycle_schedules.<schedule-id>]`: the rule
2504/// that keeps a rename from breaking a reference — and the key safe as a TOML table
2505/// header — lives on [`StableId`], so this only names the section the error belongs
2506/// to.
2507pub(crate) fn validate_registry_key<I: StableId>(section: &str, id: &str) -> Result<()> {
2508    I::new(id).map_err(|err| TixError::Config(format!("`{section}`: {err}")))?;
2509    Ok(())
2510}
2511
2512/// The display-name rules shared by the named registries.
2513///
2514/// A name is required (an unnamed record cannot be selected), must not be padded (a
2515/// padded name selects differently from how it looks, exactly as a padded id does),
2516/// and must be unique within its registry, compared **case-folded**: the point is
2517/// that a user typing a name selects exactly one record, and a user does not tell
2518/// `Platform` from `platform`. The stored spelling is kept verbatim. `noun` is the
2519/// word the error uses for the kind of record (`project`, `epic`).
2520pub(crate) fn validate_display_name(
2521    section: &str,
2522    id: &str,
2523    name: &str,
2524    noun: &str,
2525    seen: &mut HashSet<String>,
2526) -> Result<()> {
2527    if name.trim().is_empty() {
2528        return Err(TixError::Config(format!(
2529            "`{section}.{id}.name` must not be blank"
2530        )));
2531    }
2532    if name.trim() != name {
2533        return Err(TixError::Config(format!(
2534            "`{section}.{id}.name` has leading or trailing whitespace"
2535        )));
2536    }
2537    if !seen.insert(name.to_lowercase()) {
2538        return Err(TixError::Config(format!(
2539            "`{section}.{id}.name` (`{name}`) must be unique; another {noun} already uses it"
2540        )));
2541    }
2542    Ok(())
2543}
2544
2545/// Optional text that is present but blank is a typo, and silence about it is the one
2546/// thing a config loader should not offer. Shared by the named registries, including
2547/// the name-keyed reference fields (`lead`, `owner`, `milestone`, `cycle`).
2548fn validate_optional_text(section: &str, id: &str, fields: &[(&str, Option<&str>)]) -> Result<()> {
2549    for (field, value) in fields {
2550        if value.is_some_and(|v| v.trim().is_empty()) {
2551            return Err(TixError::Config(format!(
2552                "`{section}.{id}.{field}` must not be blank when present"
2553            )));
2554        }
2555    }
2556    Ok(())
2557}
2558
2559/// The shared date rule for a registry record: `YYYY-MM-DD`, with
2560/// `due_date >= start_date`, via [`crate::model::validate_issue_dates`], wrapped so
2561/// the message names the record the way the rest of this module names its keys.
2562fn validate_record_dates(
2563    section: &str,
2564    id: &str,
2565    start_date: Option<&str>,
2566    due_date: Option<&str>,
2567) -> Result<()> {
2568    crate::model::validate_issue_dates(start_date, due_date)
2569        .map_err(|err| TixError::Config(format!("`{section}.{id}`: {err}")))
2570}
2571
2572/// Per-project validation for `[projects.<project-id>]` (FR-001, C-001, C-002).
2573///
2574/// Every rule here is the shared one: the key must be a valid stable [`ProjectId`]
2575/// (that is what keeps a rename from breaking an association, and what keeps the key
2576/// safe as a TOML table header); the display `name` is required, unpadded and unique
2577/// within the registry, since a user types the name and two projects answering to one
2578/// name would be ambiguous; optional text fields are refused when present but blank;
2579/// dates go through the shared rule. See the helpers for the reasoning.
2580fn validate_projects(projects: &IndexMap<String, ProjectConfiguration>) -> Result<()> {
2581    let mut seen_names: HashSet<String> = HashSet::new();
2582    for (id, project) in projects {
2583        validate_registry_key::<ProjectId>("projects", id)?;
2584        validate_display_name("projects", id, &project.name, "project", &mut seen_names)?;
2585        validate_optional_text(
2586            "projects",
2587            id,
2588            &[
2589                ("description", project.description.as_deref()),
2590                ("status", project.status.as_deref()),
2591                ("lead", project.lead.as_deref()),
2592            ],
2593        )?;
2594        validate_record_dates(
2595            "projects",
2596            id,
2597            project.start_date.as_deref(),
2598            project.due_date.as_deref(),
2599        )?;
2600    }
2601    Ok(())
2602}
2603
2604/// Per-epic validation for `[epics.<epic-id>]` (FR-001, FR-003, C-002, C-003, C-004).
2605///
2606/// The structural half is exactly the project registry's, through the shared helpers:
2607/// a valid stable [`EpicId`] key, a non-blank unique display name, present-but-blank
2608/// optional text refused, and the shared date rule — plus, new here, a **positive**
2609/// `weight`. A non-positive weight is a typo with a clear intent, and a relative size
2610/// has no meaning at or below zero.
2611///
2612/// The reference fields are validated for **shape** only. A `parent` must be a
2613/// well-formed [`EpicId`] and must not name the record's own key; every `projects`
2614/// entry must be a well-formed [`ProjectId`]; `milestone` and `cycle` must be
2615/// non-blank names — the same name-keyed references the existing registries use.
2616/// Whether those targets **exist** is deliberately not decided here: cross-record
2617/// resolution belongs to the epic service, and a parent that names an epic which was
2618/// deleted (or a project that never existed) must stay loadable and diagnosable
2619/// (C-005). A cycle across several parent records is likewise not checkable from a
2620/// single record; it belongs to the service and to `tix lint`.
2621fn validate_epics(epics: &IndexMap<String, EpicConfiguration>) -> Result<()> {
2622    let mut seen_names: HashSet<String> = HashSet::new();
2623    for (id, epic) in epics {
2624        validate_registry_key::<EpicId>("epics", id)?;
2625        validate_display_name("epics", id, &epic.name, "epic", &mut seen_names)?;
2626        validate_optional_text(
2627            "epics",
2628            id,
2629            &[
2630                ("description", epic.description.as_deref()),
2631                ("status", epic.status.as_deref()),
2632                ("owner", epic.owner.as_deref()),
2633                ("milestone", epic.milestone.as_deref()),
2634                ("cycle", epic.cycle.as_deref()),
2635            ],
2636        )?;
2637        validate_record_dates(
2638            "epics",
2639            id,
2640            epic.start_date.as_deref(),
2641            epic.due_date.as_deref(),
2642        )?;
2643        if let Some(weight) = epic.weight
2644            && weight <= 0
2645        {
2646            return Err(TixError::Config(format!(
2647                "`epics.{id}.weight` must be a positive integer; got {weight}"
2648            )));
2649        }
2650        if let Some(parent) = epic.parent.as_deref() {
2651            EpicId::new(parent)
2652                .map_err(|err| TixError::Config(format!("`epics.{id}.parent`: {err}")))?;
2653            if parent == id {
2654                return Err(TixError::Config(format!(
2655                    "`epics.{id}.parent` must not name the epic itself"
2656                )));
2657            }
2658        }
2659        for reference in &epic.projects {
2660            ProjectId::new(reference)
2661                .map_err(|err| TixError::Config(format!("`epics.{id}.projects`: {err}")))?;
2662        }
2663    }
2664    Ok(())
2665}
2666
2667/// Every field name [`InitiativeConfiguration`] declares.
2668///
2669/// This is the set serde itself treats as **known** for the record — the fields that
2670/// survive through the typed struct rather than being swept into the flattened
2671/// `extensions` map. It must stay in step with `InitiativeConfiguration`: add a field
2672/// there and add it here, or a later shadowing check would let the new name through.
2673/// It is listed in one place, and checked in [`validate_initiatives`], because that is
2674/// the single validation every write goes through.
2675const INITIATIVE_KNOWN_FIELDS: &[&str] = &[
2676    "name",
2677    "outcome",
2678    "owner",
2679    "status",
2680    "priority",
2681    "labels",
2682    "target_date",
2683    "projects",
2684    "epics",
2685    "milestones",
2686];
2687
2688/// Per-initiative validation for `[initiatives.<initiative-id>]` (FR-001, C-001, C-002).
2689///
2690/// The structural half is the project and epic registries' own, through the shared
2691/// helpers: a valid stable [`InitiativeId`] key, a non-blank unique (case-folded)
2692/// display name, present-but-blank optional text refused, and the shared calendar
2693/// rule for `target_date` ([`crate::model::validate_date`] — the same rule issues
2694/// use, so the format has one owner). Each `labels` entry must be non-blank.
2695///
2696/// The reference fields are validated for **shape** only. Every `projects` entry must
2697/// be a well-formed [`ProjectId`] and every `epics` entry a well-formed [`EpicId`],
2698/// because that is what a registry must hold to load; every `milestones` entry must
2699/// be a non-blank **name**, the name-keyed reference the existing milestone registry
2700/// uses. Whether those targets **exist** is deliberately not decided here:
2701/// cross-record resolution belongs to the initiative service (WP03) and `tix lint`,
2702/// and a dangling reference caused by a deletion must stay loadable and diagnosable
2703/// (C-005). Repeated entries are not refused here — the write path collapses them —
2704/// because identity is the concern, not redundancy.
2705///
2706/// A preserved extension key must not **shadow** a built-in field. A file can never
2707/// deliver one — serde matches the known field first, so the extension map is only
2708/// ever the leftovers — but the public write path can inject one through
2709/// [`crate::roadmap::registry::InitiativeExtensions`], and `toml` would then serialize
2710/// the same table key twice with no duplicate detection: the write would appear to
2711/// succeed while the next load failed on `duplicate key`, diverging the file from the
2712/// caller's in-memory config. Refusing it here is what keeps
2713/// "an accepted change persists the complete record" true.
2714fn validate_initiatives(initiatives: &IndexMap<String, InitiativeConfiguration>) -> Result<()> {
2715    let mut seen_names: HashSet<String> = HashSet::new();
2716    for (id, initiative) in initiatives {
2717        validate_registry_key::<InitiativeId>("initiatives", id)?;
2718        validate_display_name(
2719            "initiatives",
2720            id,
2721            &initiative.name,
2722            "initiative",
2723            &mut seen_names,
2724        )?;
2725        validate_optional_text(
2726            "initiatives",
2727            id,
2728            &[
2729                ("outcome", initiative.outcome.as_deref()),
2730                ("owner", initiative.owner.as_deref()),
2731                ("status", initiative.status.as_deref()),
2732            ],
2733        )?;
2734        for key in initiative.extensions.keys() {
2735            if INITIATIVE_KNOWN_FIELDS.contains(&key) {
2736                return Err(TixError::Config(format!(
2737                    "`initiatives.{id}.{key}` collides with a built-in field; rename the extension key"
2738                )));
2739            }
2740        }
2741        if let Some(date) = initiative.target_date.as_deref() {
2742            if !crate::model::validate_date(date) {
2743                return Err(TixError::Config(format!(
2744                    "`initiatives.{id}.target_date` (`{date}`) must be a `YYYY-MM-DD` calendar date"
2745                )));
2746            }
2747        }
2748        for label in &initiative.labels {
2749            if label.trim().is_empty() {
2750                return Err(TixError::Config(format!(
2751                    "`initiatives.{id}.labels` must not contain a blank value"
2752                )));
2753            }
2754        }
2755        for reference in &initiative.projects {
2756            ProjectId::new(reference)
2757                .map_err(|err| TixError::Config(format!("`initiatives.{id}.projects`: {err}")))?;
2758        }
2759        for reference in &initiative.epics {
2760            EpicId::new(reference)
2761                .map_err(|err| TixError::Config(format!("`initiatives.{id}.epics`: {err}")))?;
2762        }
2763        for reference in &initiative.milestones {
2764            if reference.trim().is_empty() {
2765                return Err(TixError::Config(format!(
2766                    "`initiatives.{id}.milestones` must not contain a blank value"
2767                )));
2768            }
2769        }
2770    }
2771    Ok(())
2772}
2773
2774/// Per-cycle validation (NFR-003): non-blank name, parseable dates, and
2775/// `due_date >= start_date` when both are present.
2776fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
2777    if name.trim().is_empty() {
2778        return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
2779    }
2780    if let Some(start) = &cycle.start_date {
2781        if !crate::model::validate_date(start) {
2782            return Err(TixError::Config(format!(
2783                "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2784            )));
2785        }
2786    }
2787    if let Some(due) = &cycle.due_date {
2788        if !crate::model::validate_date(due) {
2789            return Err(TixError::Config(format!(
2790                "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2791            )));
2792        }
2793        if let Some(start) = &cycle.start_date
2794            && due.as_str() < start.as_str()
2795        {
2796            return Err(TixError::Config(format!(
2797                "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2798            )));
2799        }
2800    }
2801    Ok(())
2802}
2803
2804/// Per-template validation for `[templates.<name>]` (`tui-new-issue-mode`).
2805///
2806/// The rules exist because of how a template reaches the form: `labels`,
2807/// `cycles` and `milestones` are joined with `,` into their single text rows and
2808/// split again at submit, so a value that itself contains a comma would silently
2809/// become two values the user never typed. A blank label is refused for the same
2810/// reason the form filters blanks — a template that "sets" an empty label sets
2811/// nothing. Dates follow the cycle rules verbatim.
2812fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
2813    if name.trim().is_empty() {
2814        return Err(TixError::Config(
2815            "`templates` must not contain a blank name".into(),
2816        ));
2817    }
2818    for (field, values) in [
2819        ("labels", &template.labels),
2820        ("cycles", &template.cycles),
2821        ("milestones", &template.milestones),
2822    ] {
2823        for value in values {
2824            if value.trim().is_empty() {
2825                return Err(TixError::Config(format!(
2826                    "`templates.{name}.{field}` must not contain a blank value"
2827                )));
2828            }
2829            if value.contains(',') {
2830                return Err(TixError::Config(format!(
2831                    "`templates.{name}.{field}` value `{value}` must not contain a comma \
2832                     — the form joins these with `,`"
2833                )));
2834            }
2835        }
2836    }
2837    if let Some(start) = &template.start_date
2838        && !crate::model::validate_date(start)
2839    {
2840        return Err(TixError::Config(format!(
2841            "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2842        )));
2843    }
2844    if let Some(due) = &template.due_date {
2845        if !crate::model::validate_date(due) {
2846            return Err(TixError::Config(format!(
2847                "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2848            )));
2849        }
2850        if let Some(start) = &template.start_date
2851            && due.as_str() < start.as_str()
2852        {
2853            return Err(TixError::Config(format!(
2854                "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2855            )));
2856        }
2857    }
2858    Ok(())
2859}
2860
2861/// Per-configuration validation (NFR-003): non-empty unique non-blank
2862/// columns, `done_column` ⊆ columns, `hidden_columns` ⊆ columns.
2863/// The `[categories]` section's own rules (contract §2, §4).
2864///
2865/// Both are refusals of a *default*, which is the escalation the contract names:
2866/// a default naming a category outside the vocabulary is a typo, and a default
2867/// that resolves to `completed` would silently mark every unmapped column done.
2868fn validate_categories(categories: &CategoryConfig) -> Result<()> {
2869    if categories.order.is_empty() {
2870        return Err(TixError::Config(
2871            "`categories.order` must not be empty — it is the global vocabulary".into(),
2872        ));
2873    }
2874    for key in &categories.order {
2875        if !CATEGORY_KEYS.contains(&key.as_str()) {
2876            return Err(TixError::Config(format!(
2877                "unknown_category: `categories.order` names `{key}`, which is not in the \
2878                 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
2879                 board maps columns into it and does not define it.",
2880                CATEGORY_KEYS.join(", ")
2881            )));
2882        }
2883    }
2884    if !categories.contains(&categories.default) {
2885        return Err(TixError::Config(format!(
2886            "unknown_category: `categories.default` is `{}`, which is not in \
2887             `categories.order`",
2888            categories.default
2889        )));
2890    }
2891    if categories.default == COMPLETE_CATEGORY {
2892        return Err(TixError::Config(format!(
2893            "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
2894             without an explicit mapping would resolve to complete, which changes \
2895             completion meaning by accident. Map the completing column explicitly \
2896             instead."
2897        )));
2898    }
2899    Ok(())
2900}
2901
2902/// Per-board mapping validation (contract §3).
2903///
2904/// A board either has a completion category or is an explicit configuration
2905/// error; there is no silent "nothing completes". A *missing* mapping is never an
2906/// error — it resolves by the safe default — because erroring would reject every
2907/// existing custom configuration (NFR-003, C-007).
2908fn validate_category_map(
2909    name: &str,
2910    board: &BoardConfiguration,
2911    categories: &CategoryConfig,
2912) -> Result<()> {
2913    for (column, key) in &board.category_map {
2914        if !board.columns.iter().any(|c| c == column) {
2915            return Err(TixError::Config(format!(
2916                "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
2917                 `{column}`, which is not one of the board's columns ({})",
2918                board.columns.join(", ")
2919            )));
2920        }
2921        if !categories.contains(key) {
2922            return Err(TixError::Config(format!(
2923                "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
2924                 `{key}`, which is not in the vocabulary ({})",
2925                categories.order.join(", ")
2926            )));
2927        }
2928    }
2929    // Only a board that *declares* a map is held to naming a completion column. A
2930    // board with no map gets `done_column` -> `completed` in `category_for`, so
2931    // counting completions here would reject every legacy config.
2932    if board.category_map.is_empty() {
2933        return Ok(());
2934    }
2935    let completers: Vec<&String> = board
2936        .category_map
2937        .iter()
2938        .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
2939        .map(|(column, _)| column)
2940        .collect();
2941    match completers.as_slice() {
2942        [] => Err(TixError::Config(format!(
2943            "missing_completion_category: `kanban.{name}.category_map` maps no column \
2944             to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
2945             is ever complete — map exactly one column to it.",
2946            board.category_map.len()
2947        ))),
2948        [_only] => Ok(()),
2949        many => Err(TixError::Config(format!(
2950            "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
2951             ({}); exactly one column may complete work",
2952            many.len(),
2953            many.iter()
2954                .map(|c| c.as_str())
2955                .collect::<Vec<_>>()
2956                .join(", ")
2957        ))),
2958    }
2959}
2960
2961fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
2962    if board.columns.is_empty() {
2963        return Err(TixError::Config(format!(
2964            "`kanban.{name}.columns` must not be empty"
2965        )));
2966    }
2967    if board.columns.iter().any(|c| c.trim().is_empty()) {
2968        return Err(TixError::Config(format!(
2969            "`kanban.{name}.columns` must not contain blank names"
2970        )));
2971    }
2972    let mut seen: HashSet<&str> = HashSet::new();
2973    for column in &board.columns {
2974        if !seen.insert(column.as_str()) {
2975            return Err(TixError::Config(format!(
2976                "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
2977            )));
2978        }
2979    }
2980    if let Some(done) = &board.done_column
2981        && !board.columns.contains(done)
2982    {
2983        return Err(TixError::Config(format!(
2984            "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
2985        )));
2986    }
2987    for hidden in &board.hidden_columns {
2988        if !board.columns.contains(hidden) {
2989            return Err(TixError::Config(format!(
2990                "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
2991            )));
2992        }
2993    }
2994    if board.title_lines == 0 {
2995        return Err(TixError::Config(format!(
2996            "`kanban.{name}.title_lines` must be at least 1"
2997        )));
2998    }
2999    // The WIP schema validates itself against this board's own columns, so a limit
3000    // can only ever be attached to a column the board declares (T001/T003).
3001    board.wip.validate(name, &board.columns)?;
3002    Ok(())
3003}
3004
3005#[cfg(test)]
3006mod tests {
3007    use super::*;
3008    use std::path::PathBuf;
3009    use tempfile::TempDir;
3010
3011    use crate::templates::{
3012        PromptKind, ReferenceKind, Template, TemplateDefaults, TemplatePrompt, TemplateReference,
3013        TemplateScope,
3014    };
3015
3016    /// The canonical legacy `tix.toml`: `toml`'s pretty form, with no
3017    /// board field that already equals the loader's value and no empty
3018    /// `[export]`. A save must reproduce it byte-for-byte (#1415).
3019    const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
3020
3021[kanban.default]
3022columns = [
3023    "todo",
3024    "in progress",
3025    "in review",
3026    "done",
3027]
3028"#;
3029
3030    fn write_toml(dir: &Path, contents: &str) -> PathBuf {
3031        let path = dir.join("tix.toml");
3032        std::fs::write(&path, contents).unwrap();
3033        path
3034    }
3035
3036    #[test]
3037    fn starter_config_carries_a_working_inbox() {
3038        let config = Config::starter();
3039        config
3040            .validate()
3041            .expect("the config `tix init` writes must load");
3042
3043        let board = config.default_config();
3044        for column in [
3045            "triage",
3046            "todo",
3047            "in progress",
3048            "in review",
3049            "done",
3050            "wontfix",
3051        ] {
3052            assert!(
3053                board.columns.iter().any(|c| c == column),
3054                "the starter board is missing `{column}`: {:?}",
3055                board.columns
3056            );
3057        }
3058
3059        let triage = config
3060            .triage
3061            .as_ref()
3062            .expect("the starter config has an inbox");
3063        assert_eq!(triage.entry_status.as_deref(), Some("triage"));
3064        assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
3065        assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
3066        // Left unset: the default *is* the entry column, and a default that is never
3067        // written cannot drift from the meaning it stands for.
3068        assert_eq!(triage.effective_deferred_status(), Some("triage"));
3069
3070        // The one mapping triage requires, plus the completion column.
3071        assert_eq!(
3072            board.category_map.get("wontfix").map(String::as_str),
3073            Some("canceled")
3074        );
3075        assert_eq!(
3076            board.category_map.get("done").map(String::as_str),
3077            Some("completed")
3078        );
3079        // The working columns stay **unmapped** on purpose: declaring a map makes the
3080        // board an explicit policy, and adjacency then refuses a *backwards* move with
3081        // no override to offer — `in progress -> todo` is a move people make daily.
3082        // Left out, they all resolve to the same safe default, so movement among them
3083        // stays free while `done`/`wontfix` remain terminal facts.
3084        for column in ["triage", "todo", "in progress", "in review"] {
3085            assert!(
3086                !board.category_map.contains_key(column),
3087                "`{column}` must stay unmapped, or adjacency will refuse work moving back"
3088            );
3089        }
3090    }
3091
3092    #[test]
3093    fn repos_and_parent_repo_load_round_trip_and_resolve() {
3094        let tmp = TempDir::new().unwrap();
3095        // `parent_repo` must precede the `[repos]` table, or TOML would file it
3096        // inside that table.
3097        let raw = "issues_dir = \"docs/issues\"\n\
3098                   parent_repo = \"../parent\"\n\n\
3099                   [repos]\n\
3100                   tix = \"../tix\"\n\
3101                   website = \"child/website\"\n";
3102        let path = write_toml(tmp.path(), raw);
3103        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3104        assert_eq!(cfg.repos.len(), 2);
3105        assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
3106        assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
3107        assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
3108
3109        // A save keeps both, and they re-load identically.
3110        cfg.save(&path).unwrap();
3111        let reread = Config::load_with_warning(&path, |_| {}).unwrap();
3112        assert_eq!(reread.repos, cfg.repos, "repos round-trip");
3113        assert_eq!(
3114            reread.parent_repo(),
3115            cfg.parent_repo(),
3116            "parent round-trips"
3117        );
3118
3119        // Relative paths resolve against a base, absolute ones are left alone.
3120        let mut resolved = cfg.clone();
3121        resolved.resolve_repo_paths(Path::new("/root"));
3122        assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
3123        assert_eq!(
3124            resolved.repos["website"],
3125            PathBuf::from("/root/child/website")
3126        );
3127        assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
3128        let mut absolute = Config::default();
3129        absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
3130        absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
3131        absolute.resolve_repo_paths(Path::new("/root"));
3132        assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
3133        assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
3134    }
3135
3136    #[test]
3137    fn types_rename_and_type_colors() {
3138        let tmp = TempDir::new().unwrap();
3139        // The pre-#1530 key still loads (serde alias) but serializes as `types`.
3140        let path = write_toml(
3141            tmp.path(),
3142            "issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
3143        );
3144        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3145        assert_eq!(cfg.types(), vec!["bug", "epic"]);
3146        cfg.save(&path).unwrap();
3147        let after = std::fs::read_to_string(&path).unwrap();
3148        assert!(after.contains("types = ["), "{after}");
3149        assert!(!after.contains("custom_types"), "{after}");
3150
3151        // Every shipped type has a default color; `[type_colors]` overrides it,
3152        // matched case-insensitively. An unknown type has none.
3153        let defaults = Config::default();
3154        assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
3155        assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
3156        assert_eq!(defaults.type_color("nonsense"), None);
3157        let mut overridden = Config::default();
3158        overridden.type_colors.insert("bug".into(), "cyan".into());
3159        assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
3160    }
3161
3162    #[test]
3163    fn a_blank_repo_path_is_rejected() {
3164        let tmp = TempDir::new().unwrap();
3165        let path = write_toml(
3166            tmp.path(),
3167            "issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
3168        );
3169        let err = Config::load_with_warning(&path, |_| {})
3170            .unwrap_err()
3171            .to_string();
3172        assert!(err.contains("repos.tix"), "got {err}");
3173    }
3174
3175    #[test]
3176    fn an_empty_repos_section_is_not_written() {
3177        let tmp = TempDir::new().unwrap();
3178        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3179        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3180        cfg.save(&path).unwrap();
3181        let after = std::fs::read_to_string(&path).unwrap();
3182        assert!(
3183            !after.contains("[repos]") && !after.contains("parent_repo"),
3184            "an untouched config gains no repos keys on save: {after}"
3185        );
3186    }
3187
3188    #[test]
3189    fn defaults_match_contract() {
3190        let c = Config::default();
3191        let board = c.default_config();
3192        assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
3193        assert_eq!(board.done_column.as_deref(), Some("done"));
3194        assert_eq!(board.hidden_columns, Vec::<String>::new());
3195        assert!(!board.show_mismatch);
3196        assert!(board.wrap_titles);
3197        assert_eq!(board.title_lines, 3);
3198        assert_eq!(board.card_order, [] as [std::string::String; 0]);
3199        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3200        assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
3201        assert_eq!(c.editor, None);
3202        assert_eq!(c.state.next_number, 0);
3203        assert_eq!(c.export.gitlab, None);
3204        assert_eq!(c.export.linear, None);
3205    }
3206
3207    /// `decision-pending` is a builtin: it resolves with no `labels` key at all,
3208    /// and `tix init` writes no key for it.
3209    #[test]
3210    fn decision_pending_is_a_builtin_label_that_needs_no_key() {
3211        let bare = Config::default();
3212        assert_eq!(bare.labels, None);
3213        assert_eq!(bare.labels(), vec!["decision-pending".to_string()]);
3214
3215        // The starting point does not declare it either, so `tix init` emits no
3216        // `labels` key.
3217        let starter = Config::starter();
3218        assert_eq!(starter.labels, None);
3219        assert_eq!(starter.labels(), vec!["decision-pending".to_string()]);
3220        for written in [
3221            toml::to_string_pretty(&bare).unwrap(),
3222            toml::to_string_pretty(&starter).unwrap(),
3223        ] {
3224            assert!(
3225                !written.contains("labels ="),
3226                "no labels key is written:\n{written}"
3227            );
3228        }
3229
3230        // A project may declare extras; they follow the builtins, deduplicated.
3231        let configured = Config {
3232            labels: Some(vec!["decision-pending".into(), "spec-kitty".into()]),
3233            ..Config::default()
3234        };
3235        assert_eq!(
3236            configured.labels(),
3237            vec!["decision-pending".to_string(), "spec-kitty".to_string()]
3238        );
3239    }
3240
3241    #[test]
3242    fn load_returns_defaults_when_file_absent() {
3243        let tmp = TempDir::new().unwrap();
3244        let cfg = Config::load(tmp.path()).unwrap();
3245        assert_eq!(cfg.state.next_number, 0);
3246        assert_eq!(cfg.default_config().columns.len(), 4);
3247    }
3248
3249    #[test]
3250    fn load_synthesizes_default_config_when_kanban_section_missing() {
3251        let tmp = TempDir::new().unwrap();
3252        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3253        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3254        assert_eq!(cfg.kanban.configurations.len(), 1);
3255        assert!(cfg.kanban.configurations.contains_key("default"));
3256        assert_eq!(cfg.default_config().columns.len(), 4);
3257    }
3258
3259    #[test]
3260    fn load_parses_state_and_keeps_editor_and_remote_targets() {
3261        let tmp = TempDir::new().unwrap();
3262        let path = write_toml(
3263            tmp.path(),
3264            r#"issues_dir = "tickets"
3265editor = "nvim"
3266
3267[kanban.default]
3268columns = ["todo", "done"]
3269done_column = "done"
3270
3271[state]
3272next_number = 17
3273
3274[export.gitlab]
3275project = "acme/tix"
3276"#,
3277        );
3278        let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
3279        assert_eq!(
3280            cfg.kanban.configurations.get("default").unwrap().columns,
3281            ["todo", "done"]
3282        );
3283        assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
3284        assert_eq!(cfg.editor.as_deref(), Some("nvim"));
3285        assert_eq!(cfg.state.next_number, 17);
3286        assert_eq!(
3287            cfg.export.gitlab.unwrap().project.as_deref(),
3288            Some("acme/tix")
3289        );
3290        assert_eq!(cfg.export.linear, None);
3291    }
3292
3293    #[test]
3294    fn viewer_is_parsed_and_blank_is_rejected() {
3295        let tmp = TempDir::new().unwrap();
3296        let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
3297        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3298        assert_eq!(cfg.viewer.as_deref(), Some("less"));
3299
3300        let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"  \"\n");
3301        let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
3302        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3303        assert!(err.to_string().contains("viewer"), "{err}");
3304    }
3305
3306    #[test]
3307    fn viewer_is_omitted_from_the_file_when_unset() {
3308        let dir = TempDir::new().unwrap();
3309        let path = dir.path().join("tix.toml");
3310        let mut config = Config::default();
3311        config.save(&path).unwrap();
3312        assert!(
3313            !std::fs::read_to_string(&path).unwrap().contains("viewer"),
3314            "an unset viewer writes no key"
3315        );
3316
3317        config.viewer = Some("less".to_string());
3318        config.save(&path).unwrap();
3319        assert!(
3320            std::fs::read_to_string(&path)
3321                .unwrap()
3322                .contains(r#"viewer = "less""#),
3323            "a set viewer round-trips"
3324        );
3325    }
3326
3327    #[test]
3328    fn empty_columns_rejected_as_config_error() {
3329        let tmp = TempDir::new().unwrap();
3330        let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
3331        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3332        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3333        assert!(err.to_string().contains("kanban.scratch.columns"));
3334    }
3335
3336    #[test]
3337    fn duplicate_columns_rejected_as_config_error() {
3338        let tmp = TempDir::new().unwrap();
3339        let path = write_toml(
3340            tmp.path(),
3341            "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
3342        );
3343        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3344        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3345        assert!(err.to_string().contains("duplicate"));
3346    }
3347
3348    #[test]
3349    fn blank_column_name_rejected() {
3350        let tmp = TempDir::new().unwrap();
3351        let path = write_toml(
3352            tmp.path(),
3353            "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
3354        );
3355        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3356        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3357    }
3358
3359    #[test]
3360    fn done_column_must_be_in_columns() {
3361        let tmp = TempDir::new().unwrap();
3362        let path = write_toml(
3363            tmp.path(),
3364            "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
3365        );
3366        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3367        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3368        assert!(err.to_string().contains("kanban.scratch.done_column"));
3369    }
3370
3371    #[test]
3372    fn hidden_columns_must_be_subset_of_columns() {
3373        let tmp = TempDir::new().unwrap();
3374        let path = write_toml(
3375            tmp.path(),
3376            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
3377        );
3378        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3379        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3380        assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
3381    }
3382
3383    #[test]
3384    fn hidden_done_column_loads_fine() {
3385        let tmp = TempDir::new().unwrap();
3386        let path = write_toml(
3387            tmp.path(),
3388            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
3389        );
3390        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3391        let board = cfg.kanban.configurations.get("scratch").unwrap();
3392        assert_eq!(board.done_column.as_deref(), Some("done"));
3393        assert_eq!(board.hidden_columns, ["done"]);
3394    }
3395
3396    #[test]
3397    fn legacy_top_level_keys_rejected() {
3398        let tmp = TempDir::new().unwrap();
3399        let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
3400        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3401        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3402        assert!(err.to_string().contains("tix.toml"));
3403        assert!(err.to_string().contains("columns"));
3404    }
3405
3406    #[test]
3407    fn token_warning_fires_once_per_remote_with_token() {
3408        let tmp = TempDir::new().unwrap();
3409        let path = write_toml(
3410            tmp.path(),
3411            r#"[export.gitlab]
3412project = "a/b"
3413token = "gl-secret"
3414
3415[export.linear]
3416team = "Eng"
3417token = "lin-secret"
3418"#,
3419        );
3420        let mut warnings: Vec<String> = Vec::new();
3421        let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
3422        assert_eq!(warnings.len(), 2);
3423        assert!(warnings[0].contains("GITLAB_TOKEN"));
3424        assert!(warnings[1].contains("LINEAR_TOKEN"));
3425        // token values must never leak into the warning
3426        for w in &warnings {
3427            assert!(!w.contains("secret"));
3428        }
3429        assert_eq!(
3430            cfg.export.gitlab.unwrap().token.as_deref(),
3431            Some("gl-secret")
3432        );
3433    }
3434
3435    #[test]
3436    fn no_token_means_no_warning() {
3437        let tmp = TempDir::new().unwrap();
3438        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3439        let mut fired = false;
3440        Config::load_with_warning(&path, |_| fired = true).unwrap();
3441        assert!(!fired);
3442    }
3443
3444    #[test]
3445    fn save_never_writes_the_state_section() {
3446        let tmp = TempDir::new().unwrap();
3447        let path = tmp.path().join("tix.toml");
3448        // Simulate a legacy load: `[state].next_number` is kept in memory
3449        // but must never be written back out (issue #52).
3450        let mut cfg = Config::default();
3451        cfg.state.next_number = 42;
3452        cfg.save(&path).unwrap();
3453
3454        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3455        assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
3456        assert_eq!(
3457            reloaded.default_config().columns,
3458            cfg.default_config().columns
3459        );
3460
3461        let on_disk = std::fs::read_to_string(&path).unwrap();
3462        assert!(
3463            !on_disk.contains("next_number"),
3464            "next_number leaked into tix.toml:\n{on_disk}"
3465        );
3466        assert!(
3467            !on_disk.contains("[state]"),
3468            "state section written:\n{on_disk}"
3469        );
3470    }
3471
3472    #[test]
3473    fn multi_config_roundtrip_preserves_declaration_order() {
3474        let tmp = TempDir::new().unwrap();
3475        let path = write_toml(
3476            tmp.path(),
3477            r#"issues_dir = "docs/issues"
3478
3479[kanban.release]
3480columns = ["planned", "in progress", "done"]
3481done_column = "done"
3482show_mismatch = true
3483
3484[kanban.personal]
3485columns = ["todo", "done"]
3486done_column = "done"
3487hidden_columns = ["done"]
3488"#,
3489        );
3490        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3491        let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
3492        assert_eq!(names, ["release", "personal"]);
3493
3494        // default_config falls back to the first declared when no "default".
3495        assert_eq!(
3496            cfg.default_config().columns,
3497            ["planned", "in progress", "done"]
3498        );
3499
3500        cfg.save(&path).unwrap();
3501        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3502        assert_eq!(
3503            cfg, reloaded,
3504            "order and values survive save/load round trip"
3505        );
3506        let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
3507        assert_eq!(names2, ["release", "personal"]);
3508    }
3509
3510    #[test]
3511    fn save_never_serializes_tokens_back_out() {
3512        let tmp = TempDir::new().unwrap();
3513        let path = tmp.path().join("tix.toml");
3514        let mut cfg = Config::default();
3515        cfg.export.gitlab = Some(RemoteConfig {
3516            token: Some("secret".into()),
3517            project: Some("a/b".into()),
3518            ..RemoteConfig::default()
3519        });
3520        cfg.save(&path).unwrap();
3521
3522        let on_disk = std::fs::read_to_string(&path).unwrap();
3523        assert!(
3524            !on_disk.contains("secret"),
3525            "token leaked to disk:\n{on_disk}"
3526        );
3527        assert!(
3528            on_disk.contains("a/b"),
3529            "non-secret fields survive:\n{on_disk}"
3530        );
3531
3532        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3533        assert_eq!(reloaded.export.gitlab.unwrap().token, None);
3534    }
3535
3536    #[test]
3537    fn save_is_atomic_and_leaves_no_temp_file() {
3538        let tmp = TempDir::new().unwrap();
3539        let path = tmp.path().join("tix.toml");
3540        Config::default().save(&path).unwrap();
3541        let entries: Vec<_> = std::fs::read_dir(tmp.path())
3542            .unwrap()
3543            .filter_map(|e| e.ok())
3544            .collect();
3545        assert_eq!(entries.len(), 1);
3546        assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
3547    }
3548
3549    #[test]
3550    fn default_loadwrites_are_idempotent() {
3551        let tmp = TempDir::new().unwrap();
3552        let path = tmp.path().join("tix.toml");
3553        let cfg = Config::default();
3554        cfg.save(&path).unwrap();
3555        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3556        assert_eq!(cfg, reloaded);
3557    }
3558
3559    #[test]
3560    fn effective_done_column_falls_back_to_last() {
3561        let board = BoardConfiguration {
3562            columns: vec!["todo".into(), "later".into()],
3563            done_column: None,
3564            ..Default::default()
3565        };
3566        assert_eq!(board.effective_done_column(), Some("later"));
3567
3568        let with_done = BoardConfiguration {
3569            columns: vec!["todo".into(), "done".into(), "later".into()],
3570            done_column: None,
3571            ..Default::default()
3572        };
3573        assert_eq!(with_done.effective_done_column(), Some("done"));
3574    }
3575
3576    #[test]
3577    fn project_root_walks_up_the_tree() {
3578        let tmp = TempDir::new().unwrap();
3579        let nested = tmp.path().join("a").join("b");
3580        std::fs::create_dir_all(&nested).unwrap();
3581        std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
3582
3583        assert_eq!(
3584            Config::project_root(&nested),
3585            Some(tmp.path().to_path_buf())
3586        );
3587        assert_eq!(
3588            Config::project_root(tmp.path()),
3589            Some(tmp.path().to_path_buf())
3590        );
3591    }
3592
3593    #[test]
3594    fn project_root_is_none_when_no_toml_exists() {
3595        let tmp = TempDir::new().unwrap();
3596        assert_eq!(Config::project_root(tmp.path()), None);
3597    }
3598
3599    #[test]
3600    fn order_by_parses_all_names_and_defaults_to_desc() {
3601        let tmp = TempDir::new().unwrap();
3602        for name in ["created_at_desc", "created_at_asc", "none"] {
3603            let path = write_toml(
3604                tmp.path(),
3605                &format!(
3606                    "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
3607                ),
3608            );
3609            let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3610            let board = cfg.kanban.configurations.get("x").unwrap();
3611            assert_eq!(board.order_by.as_str(), name);
3612        }
3613        assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
3614        assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
3615        assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
3616        assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
3617    }
3618
3619    #[test]
3620    fn zero_title_lines_rejected_as_config_error() {
3621        let tmp = TempDir::new().unwrap();
3622        let path = write_toml(
3623            tmp.path(),
3624            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
3625        );
3626        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3627        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3628        assert!(
3629            err.to_string().contains("kanban.scratch.title_lines"),
3630            "error names the offending field: {err}"
3631        );
3632    }
3633
3634    #[test]
3635    fn unknown_order_by_rejected_as_config_error() {
3636        let tmp = TempDir::new().unwrap();
3637        let path = write_toml(
3638            tmp.path(),
3639            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
3640        );
3641        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3642        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3643    }
3644
3645    #[test]
3646    fn board_without_display_keys_loads_default_display_settings() {
3647        let tmp = TempDir::new().unwrap();
3648        let path = write_toml(
3649            tmp.path(),
3650            "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
3651        );
3652        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3653        let board = cfg.default_config();
3654        assert!(board.wrap_titles);
3655        assert_eq!(board.title_lines, 3);
3656        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3657    }
3658
3659    #[test]
3660    fn display_settings_roundtrip_through_save() {
3661        let tmp = TempDir::new().unwrap();
3662        let path = tmp.path().join("tix.toml");
3663        let mut cfg = Config::default();
3664        {
3665            let board = cfg.kanban.configurations.get_mut("default").unwrap();
3666            board.wrap_titles = false;
3667            board.title_lines = 5;
3668            board.order_by = OrderBy::None;
3669        }
3670        cfg.save(&path).unwrap();
3671        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3672        assert_eq!(
3673            reloaded, cfg,
3674            "display settings survive save/load round trip"
3675        );
3676        let on_disk = std::fs::read_to_string(&path).unwrap();
3677        for key in ["wrap_titles", "title_lines", "order_by"] {
3678            assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
3679        }
3680    }
3681
3682    #[test]
3683    fn manual_card_order_roundtrips_through_save() {
3684        let tmp = TempDir::new().unwrap();
3685        let path = tmp.path().join("tix.toml");
3686        let mut cfg = Config::default();
3687        {
3688            let board = cfg.kanban.configurations.get_mut("default").unwrap();
3689            board.card_order = vec!["a.md".into(), "b.md".into()];
3690        }
3691        cfg.save(&path).unwrap();
3692        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3693        assert_eq!(
3694            reloaded
3695                .kanban
3696                .configurations
3697                .get("default")
3698                .unwrap()
3699                .card_order,
3700            vec!["a.md".to_string(), "b.md".to_string()]
3701        );
3702        let on_disk = std::fs::read_to_string(&path).unwrap();
3703        assert!(
3704            on_disk.contains("card_order"),
3705            "missing card_order in:\n{on_disk}"
3706        );
3707    }
3708
3709    #[test]
3710    fn planning_defaults_match_contract() {
3711        let c = Config::default();
3712        assert_eq!(c.planning.cycle_number, 1);
3713        assert_eq!(c.planning.default_cycle, None);
3714        assert_eq!(c.planning.default_milestone, None);
3715        assert_eq!(c.planning.default_team, None);
3716        assert!(c.teams.is_empty());
3717        assert!(c.cycles.is_empty());
3718        assert_eq!(c.milestones_dir, None);
3719        assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
3720    }
3721
3722    #[test]
3723    fn planning_sections_roundtrip_and_preserve_declaration_order() {
3724        let tmp = TempDir::new().unwrap();
3725        let path = write_toml(
3726            tmp.path(),
3727            r#"issues_dir = "docs/issues"
3728milestones_dir = "docs/milestones"
3729
3730[planning]
3731cycle_number = 1
3732default_cycle = "Cycle 1"
3733default_milestone = "Backlog"
3734default_team = "default"
3735
3736[cycles."Cycle 1"]
3737
3738[cycles."Cycle 2"]
3739start_date = "2026-09-21"
3740due_date = "2026-09-30"
3741
3742[teams.default]
3743members = ["Herb Mason <herb@example.com>", "@alice"]
3744
3745[teams.platform]
3746members = ["bob"]
3747"#,
3748        );
3749        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3750        assert_eq!(cfg.planning.cycle_number, 1);
3751        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
3752        assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
3753        assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
3754        assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
3755
3756        // Names are map keys; declaration order is restored from headers.
3757        let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
3758        assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
3759        let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
3760        assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
3761        assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
3762        assert_eq!(cycle2.description, None);
3763
3764        let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
3765        assert_eq!(teams, ["default", "platform"]);
3766        assert_eq!(
3767            cfg.teams.get("default").unwrap().members,
3768            ["Herb Mason <herb@example.com>", "@alice"]
3769        );
3770
3771        // Save then reload: order + values survive the round trip (NFR-002).
3772        cfg.save(&path).unwrap();
3773        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3774        assert_eq!(cfg, reloaded);
3775        assert_eq!(
3776            reloaded
3777                .cycles
3778                .keys()
3779                .map(String::as_str)
3780                .collect::<Vec<_>>(),
3781            ["Cycle 1", "Cycle 2"]
3782        );
3783        assert_eq!(
3784            reloaded
3785                .teams
3786                .keys()
3787                .map(String::as_str)
3788                .collect::<Vec<_>>(),
3789            ["default", "platform"]
3790        );
3791    }
3792
3793    // ---------------------------------------------------------------------
3794    // WP01 / T001-T005 — the category vocabulary and mapping
3795    // ---------------------------------------------------------------------
3796
3797    const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
3798
3799[categories]
3800order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
3801default = "unstarted"
3802
3803[transitions]
3804terminal_locked = true
3805
3806[kanban.default]
3807columns = ["triage", "wip", "review", "shipped"]
3808done_column = "shipped"
3809category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
3810
3811[export]
3812"#;
3813
3814    #[test]
3815    fn category_sections_parse_and_default_sensibly() {
3816        let tmp = TempDir::new().unwrap();
3817        let path = write_toml(tmp.path(), FULL_CATEGORIES);
3818        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3819        assert_eq!(cfg.categories.order, CATEGORY_KEYS);
3820        assert_eq!(cfg.categories.default, "unstarted");
3821        assert!(cfg.transitions.terminal_locked);
3822
3823        let board = cfg.default_config();
3824        assert_eq!(
3825            board.category_map.get("shipped").map(String::as_str),
3826            Some("completed")
3827        );
3828        assert_eq!(
3829            board.category_map.get("triage").map(String::as_str),
3830            Some("backlog")
3831        );
3832    }
3833
3834    #[test]
3835    fn category_sections_default_when_absent() {
3836        let tmp = TempDir::new().unwrap();
3837        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3838        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3839        // A config omitting `[categories]` still works, off the built-in
3840        // vocabulary — T001's "sensible defaults".
3841        assert_eq!(cfg.categories, CategoryConfig::default());
3842        assert_eq!(cfg.transitions, TransitionConfig::default());
3843        assert!(cfg.categories.is_default(), "and is not written back");
3844        assert!(cfg.transitions.is_default());
3845    }
3846
3847    #[test]
3848    fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
3849        let tmp = TempDir::new().unwrap();
3850        let bad_categories = write_toml(
3851            tmp.path(),
3852            "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
3853        );
3854        let err = Config::load_with_warning(&bad_categories, |_| {})
3855            .expect_err("an unknown key in [categories] must be refused");
3856        assert!(err.to_string().contains("unknown field"), "got: {err}");
3857
3858        let tmp2 = TempDir::new().unwrap();
3859        let bad_transitions = write_toml(
3860            tmp2.path(),
3861            "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
3862        );
3863        let err = Config::load_with_warning(&bad_transitions, |_| {})
3864            .expect_err("an unknown key in [transitions] must be refused");
3865        assert!(err.to_string().contains("unknown field"), "got: {err}");
3866    }
3867
3868    #[test]
3869    fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
3870        let tmp = TempDir::new().unwrap();
3871        let path = write_toml(
3872            tmp.path(),
3873            "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
3874        );
3875        let err = Config::load_with_warning(&path, |_| {})
3876            .expect_err("the vocabulary is global and fixed; a new key is not a default");
3877        assert!(err.to_string().contains("unknown_category"), "got: {err}");
3878    }
3879
3880    /// The escalation the contract names: a default that resolves to `completed`
3881    /// would mark every unmapped column done.
3882    #[test]
3883    fn a_default_of_completed_is_refused() {
3884        let tmp = TempDir::new().unwrap();
3885        let path = write_toml(
3886            tmp.path(),
3887            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
3888        );
3889        let err = Config::load_with_warning(&path, |_| {})
3890            .expect_err("a completion-changing default must be an escalation");
3891        let msg = err.to_string();
3892        assert!(msg.contains("may not be `completed`"), "got: {msg}");
3893    }
3894
3895    #[test]
3896    fn a_default_outside_the_vocabulary_is_refused() {
3897        let tmp = TempDir::new().unwrap();
3898        let path = write_toml(
3899            tmp.path(),
3900            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
3901        );
3902        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3903        assert!(err.to_string().contains("unknown_category"), "got: {err}");
3904    }
3905
3906    /// The contract's central invariant: a board either has a completion category
3907    /// or is an explicit configuration error. There is no silent "nothing
3908    /// completes".
3909    #[test]
3910    fn a_mapped_board_with_no_completion_column_is_refused() {
3911        let tmp = TempDir::new().unwrap();
3912        let path = write_toml(
3913            tmp.path(),
3914            r#"issues_dir = "docs/issues"
3915
3916[kanban.default]
3917columns = ["triage", "wip", "shipped"]
3918done_column = "shipped"
3919category_map = { triage = "backlog", wip = "active" }
3920"#,
3921        );
3922        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3923        assert!(
3924            err.to_string().contains("missing_completion_category"),
3925            "got: {err}"
3926        );
3927    }
3928
3929    #[test]
3930    fn a_board_with_two_completion_columns_is_refused() {
3931        let tmp = TempDir::new().unwrap();
3932        let path = write_toml(
3933            tmp.path(),
3934            r#"issues_dir = "docs/issues"
3935
3936[kanban.default]
3937columns = ["shipped", "cancelled"]
3938done_column = "shipped"
3939category_map = { shipped = "completed", cancelled = "completed" }
3940"#,
3941        );
3942        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3943        let msg = err.to_string();
3944        assert!(
3945            msg.contains("exactly one column may complete"),
3946            "got: {msg}"
3947        );
3948    }
3949
3950    #[test]
3951    fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
3952        let tmp = TempDir::new().unwrap();
3953        let path = write_toml(
3954            tmp.path(),
3955            r#"issues_dir = "docs/issues"
3956
3957[kanban.default]
3958columns = ["triage", "shipped"]
3959done_column = "shipped"
3960category_map = { triage = "backlog", shipped = "completed", nope = "active" }
3961"#,
3962        );
3963        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3964        assert!(
3965            err.to_string().contains("mapping_for_unknown_column"),
3966            "got: {err}"
3967        );
3968    }
3969
3970    #[test]
3971    fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
3972        let tmp = TempDir::new().unwrap();
3973        let path = write_toml(
3974            tmp.path(),
3975            r#"issues_dir = "docs/issues"
3976
3977[kanban.default]
3978columns = ["triage", "shipped"]
3979done_column = "shipped"
3980category_map = { triage = "wontfix", shipped = "completed" }
3981"#,
3982        );
3983        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3984        assert!(err.to_string().contains("unknown_category"), "got: {err}");
3985    }
3986
3987    /// T003 step 5, and the boundary that keeps NFR-003 satisfiable: a configured
3988    /// column with **no** mapping is valid. It resolves by the safe default, and
3989    /// erroring here would reject every existing custom configuration.
3990    #[test]
3991    fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
3992        let tmp = TempDir::new().unwrap();
3993        let path = write_toml(
3994            tmp.path(),
3995            r#"issues_dir = "docs/issues"
3996
3997[kanban.default]
3998columns = ["triage", "wip", "shipped"]
3999done_column = "shipped"
4000category_map = { shipped = "completed" }
4001"#,
4002        );
4003        let cfg =
4004            Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
4005        let board = cfg.default_config();
4006        assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
4007        assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
4008        assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
4009    }
4010
4011    /// The legacy reconciliation, and the reason `missing_completion_category`
4012    /// cannot fire for a board with no map. Without it, a board with no
4013    /// `category_map` would map nothing to `completed` and be rejected — which
4014    /// would reject every legacy `tix.toml` the contract requires to load
4015    /// unchanged.
4016    #[test]
4017    fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
4018        let tmp = TempDir::new().unwrap();
4019        let path = write_toml(
4020            tmp.path(),
4021            r#"issues_dir = "docs/issues"
4022
4023[kanban.default]
4024columns = ["todo", "in progress", "in review", "done"]
4025done_column = "done"
4026"#,
4027        );
4028        let cfg = Config::load_with_warning(&path, |_| {})
4029            .expect("a legacy board must load, and it does");
4030        let board = cfg.default_config();
4031        assert!(board.category_map.is_empty());
4032        assert_eq!(board.category_for("done", &cfg.categories), "completed");
4033        assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
4034        assert_eq!(
4035            board.category_for("in review", &cfg.categories),
4036            "unstarted",
4037            "and nothing else is silently complete"
4038        );
4039    }
4040
4041    /// NFR-003 / C-007: the strongest guard. A defaulted configuration must not
4042    /// materialize — otherwise a default that writes new keys is exactly the
4043    /// "silent change" the requirement forbids.
4044    #[test]
4045    fn a_defaulted_config_gains_no_new_keys_on_save() {
4046        let tmp = TempDir::new().unwrap();
4047        let legacy = LEGACY_PRETTY_BOARD;
4048        let path = write_toml(tmp.path(), legacy);
4049        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4050        cfg.save(&path).unwrap();
4051        let after = std::fs::read_to_string(&path).unwrap();
4052        assert_eq!(
4053            after, legacy,
4054            "no `[categories]`, no `[transitions]`, no `category_map` may appear"
4055        );
4056    }
4057
4058    /// An explicitly-defaulted board is **normalized**: every field equal to the
4059    /// loader's value is dropped on save, so the file converges on the canonical
4060    /// legacy form (#1415) rather than carrying redundant keys forever.
4061    #[test]
4062    fn explicit_default_board_keys_are_normalized_away_by_save() {
4063        let tmp = TempDir::new().unwrap();
4064        let explicit = r#"issues_dir = "docs/issues"
4065
4066[kanban.default]
4067columns = [
4068    "todo",
4069    "in progress",
4070    "in review",
4071    "done",
4072]
4073done_column = "done"
4074hidden_columns = []
4075show_mismatch = false
4076wrap_titles = true
4077title_lines = 3
4078order_by = "created_at_desc"
4079
4080[export]
4081"#;
4082        let path = write_toml(tmp.path(), explicit);
4083        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4084        cfg.save(&path).unwrap();
4085        let after = std::fs::read_to_string(&path).unwrap();
4086        assert_eq!(
4087            after, LEGACY_PRETTY_BOARD,
4088            "a redundant explicit default collapses to the canonical form"
4089        );
4090        for key in [
4091            "done_column",
4092            "hidden_columns",
4093            "show_mismatch",
4094            "wrap_titles",
4095            "title_lines",
4096            "order_by",
4097            "[export]",
4098        ] {
4099            assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
4100        }
4101        assert_eq!(
4102            cfg,
4103            Config::load_with_warning(&path, |_| {}).unwrap(),
4104            "and the collapsed form reloads to the same config"
4105        );
4106    }
4107
4108    /// A board mapping with **no** `[categories]` section loads on the built-in
4109    /// vocabulary, and saving it back does not materialize the section.
4110    #[test]
4111    fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
4112        let tmp = TempDir::new().unwrap();
4113        let path = write_toml(
4114            tmp.path(),
4115            r#"issues_dir = "docs/issues"
4116
4117[kanban.default]
4118columns = ["triage", "shipped"]
4119done_column = "shipped"
4120category_map = { triage = "backlog", shipped = "completed" }
4121
4122[export]
4123"#,
4124        );
4125        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4126        assert_eq!(cfg.categories, CategoryConfig::default());
4127        cfg.save(&path).unwrap();
4128        let after = std::fs::read_to_string(&path).unwrap();
4129        assert!(
4130            !after.contains("[categories]"),
4131            "the section must stay unmaterialized: {after}"
4132        );
4133        assert!(
4134            after.contains("category_map"),
4135            "while the board's own mapping survives: {after}"
4136        );
4137    }
4138
4139    /// What round-trips is the **values** and the `order` vector, not the raw
4140    /// bytes: `category_map` serializes as a `[kanban.<name>.category_map]`
4141    /// sub-table with its keys emitted in whatever order the TOML writer chooses,
4142    /// so the authored inline form
4143    /// `category_map = { triage = "backlog", ... }` is *not* what comes back.
4144    ///
4145    /// That is a contract/code mismatch, and the WP's rule is to fix the
4146    /// contract in the same change rather than let the two disagree — so the
4147    /// contract's example is corrected alongside this.
4148    ///
4149    /// The order that *does* matter, `[categories].order`, is a `Vec` and
4150    /// round-trips exactly. The mapping's key order has no semantic effect: it
4151    /// maps a column to a category, and the columns' presentation order comes
4152    /// from `columns`.
4153    #[test]
4154    fn a_full_category_config_round_trips_with_its_values_and_order() {
4155        let tmp = TempDir::new().unwrap();
4156        // A **non-default** vocabulary, so `[categories]` is written at all.
4157        // `FULL_CATEGORIES` spells out the built-in vocabulary verbatim, which
4158        // makes `CategoryConfig::is_default()` true and the section is then
4159        // *correctly* omitted on save — which is the property the other test
4160        // asserts. Using it here would have tested nothing about the round trip.
4161        let authored = r#"issues_dir = "docs/issues"
4162
4163[categories]
4164order = ["backlog", "active", "completed", "canceled"]
4165default = "backlog"
4166
4167[transitions]
4168terminal_locked = false
4169
4170[kanban.default]
4171columns = ["triage", "wip", "shipped"]
4172done_column = "shipped"
4173category_map = { triage = "backlog", wip = "active", shipped = "completed" }
4174
4175[export]
4176"#;
4177        let path = write_toml(tmp.path(), authored);
4178        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4179        cfg.save(&path).unwrap();
4180        let after = std::fs::read_to_string(&path).unwrap();
4181        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4182
4183        assert_eq!(
4184            reloaded.categories, cfg.categories,
4185            "the vocabulary and its default survive"
4186        );
4187        assert_eq!(
4188            reloaded.categories.order,
4189            vec!["backlog", "active", "completed", "canceled"],
4190            "and `order` is a Vec, so the declared order is preserved exactly"
4191        );
4192
4193        assert_eq!(
4194            reloaded.default_config().category_map,
4195            cfg.default_config().category_map,
4196            "the mapping's contents survive, in whatever order they are written"
4197        );
4198        // Both new sections are written, since neither is at its default.
4199        // Both new sections are written — and only because neither is at its
4200        // default. The first version of this fixture set `terminal_locked = true`,
4201        // which **is** the default, so `[transitions]` was correctly omitted and
4202        // the round trip had nothing to prove. Omitted-when-defaulted is the
4203        // NFR-003 trade this WP makes deliberately, so a fixture that pins the
4204        // default is a fixture that tests nothing.
4205        assert!(after.contains("[categories]"), "after: {after}");
4206        assert!(after.contains("[transitions]"), "after: {after}");
4207        assert!(
4208            !reloaded.transitions.terminal_locked,
4209            "and the value survives"
4210        );
4211
4212        // **Save is idempotent.** The top-level section order is the serializer's
4213        // (struct field) order, not the authored order — the first version of this
4214        // assertion expected the authored order and failed on a correctly-saved
4215        // file. What actually has to hold is that a second save changes nothing,
4216        // which is the property a diff and a merge depend on. The authored order
4217        // that *is* load-bearing is preserved by the two assertions above.
4218        let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
4219        reloaded_again.save(&path).unwrap();
4220        assert_eq!(
4221            std::fs::read_to_string(&path).unwrap(),
4222            after,
4223            "a second save must be a no-op"
4224        );
4225    }
4226
4227    /// Two boards with different column names, one mapped and one not — the
4228    /// intake's first acceptance scenario.
4229    #[test]
4230    fn two_boards_with_different_columns_map_to_the_same_categories() {
4231        let tmp = TempDir::new().unwrap();
4232        let path = write_toml(
4233            tmp.path(),
4234            r#"issues_dir = "docs/issues"
4235
4236[kanban.alpha]
4237columns = ["triage", "wip", "review", "shipped"]
4238done_column = "shipped"
4239category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
4240
4241[kanban.beta]
4242columns = ["new", "doing", "merged"]
4243done_column = "merged"
4244
4245[export]
4246"#,
4247        );
4248        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4249        let alpha = &cfg.kanban.configurations["alpha"];
4250        let beta = &cfg.kanban.configurations["beta"];
4251        // Different column names, same categories: `shipped` and `merged` both
4252        // complete, and both boards' working columns are `active`/`unstarted`.
4253        assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
4254        assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
4255        assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
4256        assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
4257        assert!(beta.category_map.is_empty());
4258    }
4259
4260    /// The vocabulary's load-bearing distinction, asserted directly: `canceled` is
4261    /// terminal and **not** complete. Treating it as complete would change
4262    /// completion meaning, which the contract makes an escalation.
4263    #[test]
4264    fn canceled_is_terminal_but_not_complete() {
4265        assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
4266        assert!(TERMINAL_CATEGORIES.contains(&"completed"));
4267        assert_eq!(
4268            COMPLETE_CATEGORY, "completed",
4269            "completion is defined by this category alone"
4270        );
4271        assert_ne!(COMPLETE_CATEGORY, "canceled");
4272        // And a board may map a column to `canceled` — the category exists and is
4273        // mappable, it simply is not the completing one.
4274        let tmp = TempDir::new().unwrap();
4275        let path = write_toml(
4276            tmp.path(),
4277            r#"issues_dir = "docs/issues"
4278
4279[kanban.default]
4280columns = ["triage", "dropped", "shipped"]
4281done_column = "shipped"
4282category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
4283"#,
4284        );
4285        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4286        let board = cfg.default_config();
4287        assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
4288        assert_ne!(
4289            board.category_for("dropped", &cfg.categories),
4290            COMPLETE_CATEGORY
4291        );
4292    }
4293
4294    /// `blocked` as a category is a workflow column position, not the derived
4295    /// blocked state owned by issue 169's mission. Asserted as distinctness, since
4296    /// conflating them is a named rejected alternative.
4297    #[test]
4298    fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
4299        assert!(CATEGORY_KEYS.contains(&"blocked"));
4300        let tmp = TempDir::new().unwrap();
4301        let path = write_toml(
4302            tmp.path(),
4303            r#"issues_dir = "docs/issues"
4304
4305[kanban.default]
4306columns = ["wip", "on hold", "shipped"]
4307done_column = "shipped"
4308category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
4309"#,
4310        );
4311        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4312        let board = cfg.default_config();
4313        assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
4314        // The derived blocked state lives in the relationship graph, not here.
4315        assert!(
4316            !cfg.default_config()
4317                .category_map
4318                .values()
4319                .any(|v| v.contains("state")),
4320            "a category is a position, never a derived state"
4321        );
4322    }
4323
4324    #[test]
4325    fn legacy_file_roundtrips_byte_identical() {
4326        let tmp = TempDir::new().unwrap();
4327        // A pre-mission tix.toml in the canonical toml pretty form. The save
4328        // path must not add, drop, or reorder any pre-existing key (NFR-003):
4329        // maximum already-pretty formatting proves a true byte-stable save.
4330        let legacy = LEGACY_PRETTY_BOARD;
4331        let path = write_toml(tmp.path(), legacy);
4332        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4333        assert_eq!(cfg.planning, PlanningConfig::default());
4334        assert!(cfg.teams.is_empty());
4335        assert!(cfg.cycles.is_empty());
4336        assert_eq!(cfg.milestones_dir, None);
4337        // Behavior the omitted keys would have carried is unchanged: the
4338        // `done_column` is still the loader's `"done"` and ordering keeps its
4339        // default (#1415).
4340        assert_eq!(
4341            cfg.default_config().columns,
4342            ["todo", "in progress", "in review", "done"]
4343        );
4344        assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
4345        assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
4346        assert_eq!(
4347            cfg.default_config().hidden_columns,
4348            [] as [std::string::String; 0]
4349        );
4350        assert!(!cfg.default_config().show_mismatch);
4351        assert!(cfg.default_config().wrap_titles);
4352        assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
4353        assert!(!cfg.default_config().category_map.contains_key("done"));
4354
4355        cfg.save(&path).unwrap();
4356        let on_disk = std::fs::read_to_string(&path).unwrap();
4357        assert_eq!(
4358            on_disk, legacy,
4359            "legacy tix.toml must save byte-identical (NFR-003)"
4360        );
4361        assert!(
4362            !on_disk.contains("[export]"),
4363            "an empty `[export]` table must not be materialized:\n{on_disk}"
4364        );
4365        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4366        assert_eq!(cfg, reloaded);
4367    }
4368
4369    #[test]
4370    fn legacy_file_in_any_layout_never_gains_planning_keys() {
4371        let tmp = TempDir::new().unwrap();
4372        // Hand-written single-line arrays (typical of real repos) parse fine;
4373        // the pretty re-serializer may reformat them, but must never add a
4374        // planning/teams/cycles/milestones key.
4375        let legacy = r#"issues_dir = "docs/issues"
4376[kanban.default]
4377columns = ["todo", "in progress", "in review", "done"]
4378done_column = "done"
4379"#;
4380        let path = write_toml(tmp.path(), legacy);
4381        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4382        cfg.save(&path).unwrap();
4383        let on_disk = std::fs::read_to_string(&path).unwrap();
4384        assert!(!on_disk.contains("milestones_dir"));
4385        assert!(!on_disk.contains("[planning]"));
4386        assert!(!on_disk.contains("[teams]"));
4387        assert!(!on_disk.contains("[cycles"));
4388    }
4389
4390    #[test]
4391    fn planning_default_section_with_partial_keys_loads() {
4392        let tmp = TempDir::new().unwrap();
4393        let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
4394        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4395        assert_eq!(
4396            cfg.planning.cycle_number, 1,
4397            "counter defaults when omitted"
4398        );
4399        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
4400        assert_eq!(cfg.planning.default_milestone, None);
4401        assert_eq!(cfg.planning.default_team, None);
4402    }
4403
4404    #[test]
4405    fn planning_rejects_unknown_keys() {
4406        let tmp = TempDir::new().unwrap();
4407        let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
4408        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4409        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4410        assert!(err.to_string().contains("counter"));
4411    }
4412
4413    #[test]
4414    fn planning_rejects_zero_cycle_number() {
4415        let tmp = TempDir::new().unwrap();
4416        let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
4417        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4418        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4419        assert!(err.to_string().contains("cycle_number"));
4420    }
4421
4422    #[test]
4423    fn cycle_dates_are_validated_and_compared() {
4424        let tmp = TempDir::new().unwrap();
4425        // parsing failure
4426        let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
4427        let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
4428        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4429        assert!(err.to_string().contains("start_date"));
4430
4431        // ordering failure
4432        let ordered = write_toml(
4433            tmp.path(),
4434            "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
4435        );
4436        let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
4437        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4438        assert!(err.to_string().contains("due_date"));
4439        assert!(err.to_string().contains("start_date"));
4440    }
4441
4442    #[test]
4443    fn cycles_reject_unknown_keys() {
4444        let tmp = TempDir::new().unwrap();
4445        let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
4446        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4447        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4448        assert!(err.to_string().contains("bogus"));
4449    }
4450
4451    #[test]
4452    fn cycle_without_dates_loads_fine() {
4453        let tmp = TempDir::new().unwrap();
4454        let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
4455        let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
4456        assert_eq!(
4457            cfg.cycles.get("Alpha").unwrap().description.as_deref(),
4458            Some("a")
4459        );
4460    }
4461
4462    #[test]
4463    fn teams_validate_members_nonempty_and_unique() {
4464        let tmp = TempDir::new().unwrap();
4465        let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
4466        let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
4467        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4468        assert!(err.to_string().contains("members"));
4469
4470        let dup = write_toml(
4471            tmp.path(),
4472            "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
4473        );
4474        let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
4475        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4476        assert!(err.to_string().contains("unique"));
4477    }
4478
4479    #[test]
4480    fn empty_teams_table_is_disabled_not_error() {
4481        let tmp = TempDir::new().unwrap();
4482        let path = write_toml(tmp.path(), "[teams]\n");
4483        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4484        assert!(cfg.teams.is_empty());
4485        // An empty [teams] is never written back out.
4486        cfg.save(&path).unwrap();
4487        let on_disk = std::fs::read_to_string(&path).unwrap();
4488        assert!(!on_disk.contains("[teams]"));
4489    }
4490
4491    #[test]
4492    fn blank_milestones_dir_rejected() {
4493        let tmp = TempDir::new().unwrap();
4494        let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
4495        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4496        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4497        assert!(err.to_string().contains("milestones_dir"));
4498    }
4499
4500    #[test]
4501    fn sparse_keybindings_roundtrip_without_defaults() {
4502        let tmp = TempDir::new().unwrap();
4503        let path = write_toml(
4504            tmp.path(),
4505            "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
4506        );
4507        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4508        config.save(&path).unwrap();
4509        let contents = std::fs::read_to_string(&path).unwrap();
4510        assert!(contents.contains("[keybindings.board]"));
4511        assert!(!contents.contains("move_left"));
4512        assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
4513    }
4514
4515    #[test]
4516    fn omitted_keybindings_retain_effective_defaults() {
4517        let tmp = TempDir::new().unwrap();
4518        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
4519        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4520        let bindings = config.effective_keybindings().unwrap();
4521        assert_eq!(
4522            bindings.label(
4523                crate::keybindings::InputContext::Board,
4524                crate::keybindings::TuiAction::NewIssue
4525            ),
4526            "n"
4527        );
4528    }
4529
4530    /// `[keybindings.edit_conflict]` is a real table (tix #1510): an override lands
4531    /// on the conflict prompt's decisions and nothing else moves. Without this the
4532    /// `edit_conflict` rows in `docs/key-bindings.md` would be documentation for a
4533    /// setting that cannot be written.
4534    #[test]
4535    fn edit_conflict_keybindings_override_the_prompt() {
4536        use crate::keybindings::{InputContext, TuiAction};
4537        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4538
4539        let tmp = TempDir::new().unwrap();
4540        let path = write_toml(
4541            tmp.path(),
4542            "[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
4543        );
4544        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4545        let bindings = config.effective_keybindings().unwrap();
4546
4547        assert_eq!(
4548            bindings.label(InputContext::EditConflict, TuiAction::Confirm),
4549            "Ctrl+k"
4550        );
4551        assert_eq!(
4552            bindings.action(
4553                InputContext::EditConflict,
4554                KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
4555            ),
4556            Some(TuiAction::Confirm)
4557        );
4558        assert_eq!(
4559            bindings.action(
4560                InputContext::EditConflict,
4561                KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
4562            ),
4563            None,
4564            "an override replaces the action's whole alias list"
4565        );
4566        // The actions that were not overridden keep their defaults.
4567        assert_eq!(
4568            bindings.action(
4569                InputContext::EditConflict,
4570                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
4571            ),
4572            Some(TuiAction::Reedit)
4573        );
4574        assert_eq!(
4575            bindings.action(
4576                InputContext::EditConflict,
4577                KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
4578            ),
4579            Some(TuiAction::Ignore)
4580        );
4581    }
4582
4583    #[test]
4584    fn keybindings_reject_unknown_fields_and_empty_actions() {
4585        let tmp = TempDir::new().unwrap();
4586        let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
4587        assert!(
4588            Config::load_with_warning(&unknown, |_| {})
4589                .unwrap_err()
4590                .to_string()
4591                .contains("bogus")
4592        );
4593        let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
4594        assert!(
4595            Config::load_with_warning(&empty, |_| {})
4596                .unwrap_err()
4597                .to_string()
4598                .contains("new_issue")
4599        );
4600    }
4601
4602    /// The mode switches are configurable under `[keybindings.shell]`: an
4603    /// override moves the key *and* the label the rail renders, an unknown action
4604    /// name is refused, and an unparsable chord names the key it came from.
4605    #[test]
4606    fn shell_mode_switches_are_configurable() {
4607        use crate::keybindings::{InputContext, TuiAction};
4608        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4609
4610        let tmp = TempDir::new().unwrap();
4611        let path = write_toml(
4612            tmp.path(),
4613            "[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
4614        );
4615        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4616        let bindings = config.effective_keybindings().unwrap();
4617
4618        // The label is what the rail renders (`nav::render` reads it back).
4619        assert_eq!(
4620            bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
4621            "g"
4622        );
4623        assert_eq!(
4624            bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
4625            "F3 / b"
4626        );
4627        // The key resolves, and the default F5 no longer does.
4628        assert_eq!(
4629            bindings.action(
4630                InputContext::Shell,
4631                KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
4632            ),
4633            Some(TuiAction::SwitchToMilestones)
4634        );
4635        assert_eq!(
4636            bindings.action(
4637                InputContext::Shell,
4638                KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
4639            ),
4640            None,
4641            "an override replaces the action's whole alias list"
4642        );
4643        assert_eq!(
4644            bindings.action(
4645                InputContext::Shell,
4646                KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
4647            ),
4648            Some(TuiAction::SwitchToBoard),
4649            "a second alias keeps working"
4650        );
4651
4652        // An unknown action name is a load error naming the key.
4653        let unknown = write_toml(
4654            tmp.path(),
4655            "[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
4656        );
4657        let error = Config::load_with_warning(&unknown, |_| {})
4658            .unwrap_err()
4659            .to_string();
4660        assert!(
4661            error.contains("switch_to_nowhere"),
4662            "the error names the offending key: {error:?}"
4663        );
4664
4665        // An unparsable chord is a load error naming the action and the chord.
4666        let bad_chord = write_toml(
4667            tmp.path(),
4668            "[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
4669        );
4670        let error = Config::load_with_warning(&bad_chord, |_| {})
4671            .unwrap_err()
4672            .to_string();
4673        assert!(
4674            error.contains("switch_to_milestones") && error.contains("nosuchkey"),
4675            "the error names the key and the chord: {error:?}"
4676        );
4677    }
4678
4679    // --- new-issue templates (tui-new-issue-mode, task 6.2) ---
4680
4681    #[test]
4682    fn templates_round_trip_through_save_and_load() {
4683        let tmp = TempDir::new().unwrap();
4684        let path = tmp.path().join("tix.toml");
4685        let mut config = Config::starter();
4686        config.templates.insert(
4687            "bug".into(),
4688            IssueTemplate {
4689                title: Some("Bug: ".into()),
4690                body: Some("## Steps\n".into()),
4691                labels: vec!["bug".into(), "urgent".into()],
4692                status: Some("todo".into()),
4693                cycles: vec!["Default".into()],
4694                start_date: Some("2026-01-01".into()),
4695                due_date: Some("2026-01-02".into()),
4696                ..IssueTemplate::default()
4697            },
4698        );
4699        config.validate().expect("the fixture is a valid config");
4700        config.save(&path).unwrap();
4701
4702        let raw = std::fs::read_to_string(&path).unwrap();
4703        assert!(raw.contains("[templates.bug]"), "{raw}");
4704        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4705        assert_eq!(reloaded.templates, config.templates);
4706        let bug = &reloaded.templates["bug"];
4707        assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
4708        assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
4709        assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
4710    }
4711
4712    #[test]
4713    fn a_config_without_templates_writes_no_section() {
4714        let tmp = TempDir::new().unwrap();
4715        let path = tmp.path().join("tix.toml");
4716        Config::default().save(&path).unwrap();
4717        let raw = std::fs::read_to_string(&path).unwrap();
4718        assert!(
4719            !raw.contains("templates"),
4720            "an absent section must not appear on disk:\n{raw}"
4721        );
4722        assert!(
4723            Config::load_with_warning(&path, |_| {})
4724                .unwrap()
4725                .templates
4726                .is_empty()
4727        );
4728    }
4729
4730    #[test]
4731    fn a_template_value_with_a_comma_is_rejected() {
4732        let tmp = TempDir::new().unwrap();
4733        let path = write_toml(
4734            tmp.path(),
4735            "issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
4736        );
4737        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4738        assert!(
4739            err.to_string().contains("must not contain a comma"),
4740            "got {err}"
4741        );
4742    }
4743
4744    #[test]
4745    fn a_template_with_a_backwards_date_range_is_rejected() {
4746        let tmp = TempDir::new().unwrap();
4747        let path = write_toml(
4748            tmp.path(),
4749            "issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
4750             start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
4751        );
4752        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4753        assert!(err.to_string().contains(">= start_date"), "got {err}");
4754    }
4755
4756    #[test]
4757    fn an_unknown_template_key_is_rejected() {
4758        let tmp = TempDir::new().unwrap();
4759        let path = write_toml(
4760            tmp.path(),
4761            "issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
4762        );
4763        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4764        assert!(err.to_string().contains("unknown field"), "got {err}");
4765    }
4766
4767    // --- stable-ID issue templates (issue-form-templates, WP01) ---
4768
4769    #[test]
4770    fn issue_templates_round_trip_through_save_and_load() {
4771        let tmp = TempDir::new().unwrap();
4772        let path = tmp.path().join("tix.toml");
4773        let mut config = Config::starter();
4774        config
4775            .issue_templates
4776            .insert(
4777                "bug",
4778                Template {
4779                    name: Some("Bug report".to_string()),
4780                    defaults: TemplateDefaults {
4781                        labels: vec!["bug".to_string()],
4782                        priority: Some("high".to_string()),
4783                        ..TemplateDefaults::default()
4784                    },
4785                    prompts: vec![TemplatePrompt {
4786                        key: "priority".to_string(),
4787                        label: Some("Priority".to_string()),
4788                        kind: PromptKind::Select,
4789                        required: true,
4790                        default: None,
4791                        options_ref: Some("priorities".to_string()),
4792                    }],
4793                    references: vec![TemplateReference {
4794                        kind: ReferenceKind::Label,
4795                        value: "bug".to_string(),
4796                        required: true,
4797                    }],
4798                    ..Template::default()
4799                },
4800            )
4801            .unwrap();
4802        config.validate().expect("the fixture is a valid config");
4803        config.save(&path).unwrap();
4804
4805        let raw = std::fs::read_to_string(&path).unwrap();
4806        assert!(raw.contains("[issue_templates.bug]"), "{raw}");
4807        assert!(raw.contains("[[issue_templates.bug.prompts]]"), "{raw}");
4808
4809        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4810        assert_eq!(reloaded.issue_templates, config.issue_templates);
4811        let bug = reloaded.issue_templates.get("bug").unwrap();
4812        assert_eq!(bug.name.as_deref(), Some("Bug report"));
4813        assert_eq!(bug.defaults.labels, ["bug".to_string()]);
4814        assert_eq!(bug.prompts[0].kind, PromptKind::Select);
4815        assert!(bug.prompts[0].required);
4816        assert_eq!(bug.references[0].kind, ReferenceKind::Label);
4817    }
4818
4819    #[test]
4820    fn a_config_without_issue_templates_writes_no_section() {
4821        let tmp = TempDir::new().unwrap();
4822        let path = tmp.path().join("tix.toml");
4823        Config::default().save(&path).unwrap();
4824        let raw = std::fs::read_to_string(&path).unwrap();
4825        assert!(
4826            !raw.contains("issue_templates"),
4827            "an absent section must not appear on disk:\n{raw}"
4828        );
4829        assert!(
4830            Config::load_with_warning(&path, |_| {})
4831                .unwrap()
4832                .issue_templates
4833                .is_empty()
4834        );
4835    }
4836
4837    #[test]
4838    fn a_legacy_config_gains_no_issue_templates_section() {
4839        let tmp = TempDir::new().unwrap();
4840        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
4841        let before = std::fs::read_to_string(&path).unwrap();
4842        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4843        assert!(
4844            config.issue_templates.is_empty(),
4845            "a legacy config has no stable-ID registry"
4846        );
4847        config.save(&path).unwrap();
4848        assert_eq!(
4849            std::fs::read_to_string(&path).unwrap(),
4850            before,
4851            "a legacy config must round-trip byte-identically"
4852        );
4853    }
4854
4855    #[test]
4856    fn issue_template_declaration_order_is_restored() {
4857        let tmp = TempDir::new().unwrap();
4858        let path = write_toml(
4859            tmp.path(),
4860            "issues_dir = \"docs/issues\"\n\n\
4861             [issue_templates.zeta]\nname = \"Zeta\"\n\n\
4862             [issue_templates.alpha]\nname = \"Alpha\"\n",
4863        );
4864        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4865        assert_eq!(
4866            config.issue_templates.ids().collect::<Vec<_>>(),
4867            ["zeta", "alpha"],
4868            "declaration order is the display order"
4869        );
4870        config.save(&path).unwrap();
4871        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4872        assert_eq!(
4873            reloaded.issue_templates.ids().collect::<Vec<_>>(),
4874            ["zeta", "alpha"]
4875        );
4876    }
4877
4878    #[test]
4879    fn a_rejected_template_write_leaves_tix_toml_unchanged() {
4880        let tmp = TempDir::new().unwrap();
4881        let path = write_toml(
4882            tmp.path(),
4883            "issues_dir = \"docs/issues\"\n\n[issue_templates.bug]\nname = \"Bug report\"\n",
4884        );
4885        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4886        let before = std::fs::read(&path).unwrap();
4887
4888        // A scope naming a board that does not exist is a validation error, so
4889        // the caller never reaches `Config::save`.
4890        let mut rejected = config.clone();
4891        rejected.issue_templates.get_mut("bug").unwrap().scope =
4892            TemplateScope::board("no-such-board");
4893        let err = rejected.validate().unwrap_err().to_string();
4894        assert!(
4895            err.contains("issue_templates.bug.scope") && err.contains("no-such-board"),
4896            "{err}"
4897        );
4898        assert_eq!(
4899            std::fs::read(&path).unwrap(),
4900            before,
4901            "a rejected write must not touch tix.toml"
4902        );
4903
4904        // A duplicate identity is refused before any save, leaving both the
4905        // file and the loaded registry untouched.
4906        let mut duplicate = config.clone();
4907        let err = duplicate
4908            .issue_templates
4909            .insert("bug", Template::default())
4910            .unwrap_err();
4911        assert!(err.to_string().contains("already exists"), "{err}");
4912        assert_eq!(duplicate.issue_templates, config.issue_templates);
4913        assert_eq!(std::fs::read(&path).unwrap(), before);
4914    }
4915
4916    #[test]
4917    fn unknown_issue_template_metadata_round_trips_byte_stably() {
4918        let tmp = TempDir::new().unwrap();
4919        let path = write_toml(
4920            tmp.path(),
4921            "issues_dir = \"docs/issues\"\n\n\
4922             [issue_templates.bug]\nname = \"Bug report\"\n\
4923             reporter_hint = \"include the build id\"\n\n\
4924             [issue_templates.bug.future]\nlevel = 3\n",
4925        );
4926        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4927        let bug = config.issue_templates.get("bug").unwrap();
4928        assert_eq!(
4929            bug.extensions
4930                .get("reporter_hint")
4931                .and_then(toml::Value::as_str),
4932            Some("include the build id")
4933        );
4934        assert!(bug.extensions.get("future").is_some());
4935
4936        config.save(&path).unwrap();
4937        let first = std::fs::read(&path).unwrap();
4938        let text = String::from_utf8(first.clone()).unwrap();
4939        assert!(
4940            text.contains("reporter_hint") && text.contains("future"),
4941            "unknown metadata must survive a save:\n{text}"
4942        );
4943
4944        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4945        reloaded.save(&path).unwrap();
4946        assert_eq!(
4947            std::fs::read(&path).unwrap(),
4948            first,
4949            "a second round trip must be byte-identical"
4950        );
4951    }
4952
4953    /// A `tix.toml` that configures limits must survive a save byte-for-byte, and
4954    /// the resolved view of it must be the one the file states (T005).
4955    #[test]
4956    fn wip_config_round_trips_byte_stably_and_resolves() {
4957        let tmp = TempDir::new().unwrap();
4958        let configured = "issues_dir = \"docs/issues\"\n\n\
4959             [kanban.default]\ncolumns = [\n    \"todo\",\n    \"in progress\",\n    \"done\",\n]\n\n\
4960             [kanban.default.wip.units]\nd = 1\nw = 5\n\n\
4961             [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4962             max_estimate = 10\nunit = \"d\"\npolicy = \"block\"\n";
4963        let path = write_toml(tmp.path(), configured);
4964        let config = Config::load_with_warning(&path, |_| {}).unwrap();
4965
4966        let board = config.default_config();
4967        let limit = board
4968            .wip
4969            .effective_limit("in progress")
4970            .expect("the configured limit is in force");
4971        assert_eq!(limit.max_items, Some(3));
4972        assert_eq!(limit.max_estimate, Some(10));
4973        assert_eq!(limit.unit.as_deref(), Some("d"));
4974        assert_eq!(limit.policy, Some(crate::wip::LimitPolicy::Block));
4975        assert_eq!(board.wip.factor("w"), Some(5));
4976        assert!(
4977            board.wip.effective_limit("done").is_none(),
4978            "a column without a limit has none"
4979        );
4980
4981        config.save(&path).unwrap();
4982        assert_eq!(
4983            std::fs::read_to_string(&path).unwrap(),
4984            configured,
4985            "a configured board must round trip byte-for-byte"
4986        );
4987    }
4988
4989    /// The limits live inside the board table, so a limit aimed at a column the
4990    /// board does not declare is refused at load, by name.
4991    #[test]
4992    fn wip_config_limit_on_an_unknown_column_is_refused() {
4993        let tmp = TempDir::new().unwrap();
4994        let path = write_toml(
4995            tmp.path(),
4996            "issues_dir = \"docs/issues\"\n\n\
4997             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4998             [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4999             policy = \"warn\"\n",
5000        );
5001        let error = Config::load_with_warning(&path, |_| {})
5002            .expect_err("an unknown column is refused")
5003            .to_string();
5004        assert!(
5005            error.contains("kanban.default.wip.limits.`in progress`"),
5006            "{error}"
5007        );
5008    }
5009
5010    /// Limits are absent unless declared: the legacy board keeps its exact bytes
5011    /// and gains no `wip` key (T005).
5012    #[test]
5013    fn wip_config_absent_limits_add_no_key() {
5014        let tmp = TempDir::new().unwrap();
5015        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5016        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5017        assert!(config.default_config().wip.is_empty());
5018        config.save(&path).unwrap();
5019        assert_eq!(
5020            std::fs::read_to_string(&path).unwrap(),
5021            LEGACY_PRETTY_BOARD,
5022            "a board without limits must not gain a `wip` key"
5023        );
5024    }
5025
5026    // --- WP01: the `[projects.<project-id>]` registry (FR-001, C-001, C-002) ---
5027
5028    const TWO_PROJECTS: &str = "issues_dir = \"docs/issues\"\n\n\
5029        [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5030        [projects.\"p-3f9a2c\"]\n\
5031        name = \"Platform hardening\"\n\
5032        description = \"Reduce operational risk across the sync path.\"\n\
5033        status = \"active\"\n\
5034        lead = \"Alice <alice@example.com>\"\n\
5035        start_date = \"2026-09-01\"\n\
5036        due_date = \"2026-12-31\"\n\n\
5037        [projects.\"p-7b1e04\"]\n\
5038        name = \"Docs refresh\"\n";
5039
5040    #[test]
5041    fn project_registry_parses_every_contract_field() {
5042        let tmp = TempDir::new().unwrap();
5043        let path = write_toml(tmp.path(), TWO_PROJECTS);
5044        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5045
5046        let platform = &config.projects["p-3f9a2c"];
5047        assert_eq!(platform.name, "Platform hardening");
5048        assert_eq!(
5049            platform.description.as_deref(),
5050            Some("Reduce operational risk across the sync path.")
5051        );
5052        assert_eq!(platform.status.as_deref(), Some("active"));
5053        assert_eq!(platform.lead.as_deref(), Some("Alice <alice@example.com>"));
5054        assert_eq!(platform.start_date.as_deref(), Some("2026-09-01"));
5055        assert_eq!(platform.due_date.as_deref(), Some("2026-12-31"));
5056
5057        // The key is the id; a record that omits every optional field carries no
5058        // empty value.
5059        let docs = &config.projects["p-7b1e04"];
5060        assert_eq!(docs.name, "Docs refresh");
5061        assert!(docs.description.is_none() && docs.status.is_none() && docs.lead.is_none());
5062        assert!(docs.start_date.is_none() && docs.due_date.is_none());
5063    }
5064
5065    #[test]
5066    fn project_registry_rejects_an_unknown_field() {
5067        let tmp = TempDir::new().unwrap();
5068        let path = write_toml(
5069            tmp.path(),
5070            "issues_dir = \"docs/issues\"\n\n\
5071             [projects.\"p-1\"]\nname = \"One\"\nownr = \"typo\"\n",
5072        );
5073        let err = Config::load_with_warning(&path, |_| {})
5074            .unwrap_err()
5075            .to_string();
5076        assert!(err.contains("ownr"), "got {err}");
5077    }
5078
5079    #[test]
5080    fn project_declaration_order_is_restored() {
5081        let tmp = TempDir::new().unwrap();
5082        let path = write_toml(
5083            tmp.path(),
5084            "issues_dir = \"docs/issues\"\n\n\
5085             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5086             [projects.\"p-zeta\"]\nname = \"Zeta\"\n\n\
5087             [projects.\"p-alpha\"]\nname = \"Alpha\"\n",
5088        );
5089        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5090        assert_eq!(
5091            config.projects.keys().collect::<Vec<_>>(),
5092            ["p-zeta", "p-alpha"],
5093            "declaration order is the display order"
5094        );
5095        config.save(&path).unwrap();
5096        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5097        assert_eq!(
5098            reloaded.projects.keys().collect::<Vec<_>>(),
5099            ["p-zeta", "p-alpha"],
5100            "declaration order survives a save/reload through the ordered map"
5101        );
5102    }
5103
5104    #[test]
5105    fn a_legacy_tix_toml_gains_no_projects_section() {
5106        let tmp = TempDir::new().unwrap();
5107        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5108        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5109        assert!(config.projects.is_empty());
5110        config.save(&path).unwrap();
5111        assert_eq!(
5112            std::fs::read_to_string(&path).unwrap(),
5113            LEGACY_PRETTY_BOARD,
5114            "a repository with no projects must round-trip byte-identically"
5115        );
5116    }
5117
5118    #[test]
5119    fn an_empty_project_record_writes_no_optional_keys() {
5120        let tmp = TempDir::new().unwrap();
5121        let path = write_toml(
5122            tmp.path(),
5123            "issues_dir = \"docs/issues\"\n\n\
5124             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5125             [projects.\"p-1\"]\nname = \"One\"\n",
5126        );
5127        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5128        config.save(&path).unwrap();
5129        let after = std::fs::read_to_string(&path).unwrap();
5130        for key in ["description", "status", "lead", "start_date", "due_date"] {
5131            assert!(
5132                !after.contains(key),
5133                "an absent optional field must not be written: {key} in {after}"
5134            );
5135        }
5136        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5137        assert_eq!(reloaded, config);
5138    }
5139
5140    #[test]
5141    fn a_blank_project_name_is_rejected() {
5142        let tmp = TempDir::new().unwrap();
5143        let path = write_toml(
5144            tmp.path(),
5145            "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \"  \"\n",
5146        );
5147        let err = Config::load_with_warning(&path, |_| {})
5148            .unwrap_err()
5149            .to_string();
5150        assert!(err.contains("projects.p-1.name"), "got {err}");
5151    }
5152
5153    #[test]
5154    fn duplicate_project_names_are_rejected() {
5155        // `Same`/`Same` and `Same`/`same` are both refusals: the point of the rule
5156        // is that a name a user types selects exactly one project, and a user does
5157        // not tell the two spellings apart.
5158        for second in ["Same", "same"] {
5159            let tmp = TempDir::new().unwrap();
5160            let path = write_toml(
5161                tmp.path(),
5162                &format!(
5163                    "issues_dir = \"docs/issues\"\n\n\
5164                     [projects.\"p-1\"]\nname = \"Same\"\n\n\
5165                     [projects.\"p-2\"]\nname = \"{second}\"\n"
5166                ),
5167            );
5168            let err = Config::load_with_warning(&path, |_| {})
5169                .unwrap_err()
5170                .to_string();
5171            assert!(err.contains("must be unique"), "got {err}");
5172        }
5173    }
5174
5175    #[test]
5176    fn a_padded_project_name_is_rejected() {
5177        let tmp = TempDir::new().unwrap();
5178        let path = write_toml(
5179            tmp.path(),
5180            "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n",
5181        );
5182        let err = Config::load_with_warning(&path, |_| {})
5183            .unwrap_err()
5184            .to_string();
5185        assert!(err.contains("leading or trailing whitespace"), "got {err}");
5186    }
5187
5188    #[test]
5189    fn a_blank_project_id_is_rejected() {
5190        let tmp = TempDir::new().unwrap();
5191        let path = write_toml(
5192            tmp.path(),
5193            "issues_dir = \"docs/issues\"\n\n[projects.\"\"]\nname = \"No id\"\n",
5194        );
5195        let err = Config::load_with_warning(&path, |_| {})
5196            .unwrap_err()
5197            .to_string();
5198        assert!(err.contains("must not be blank"), "got {err}");
5199    }
5200
5201    #[test]
5202    fn a_project_id_with_a_structural_character_is_rejected() {
5203        let tmp = TempDir::new().unwrap();
5204        let path = write_toml(
5205            tmp.path(),
5206            "issues_dir = \"docs/issues\"\n\n[projects.\"p.1\"]\nname = \"Dotted\"\n",
5207        );
5208        let err = Config::load_with_warning(&path, |_| {})
5209            .unwrap_err()
5210            .to_string();
5211        assert!(err.contains("contains"), "got {err}");
5212    }
5213
5214    #[test]
5215    fn blank_optional_project_text_is_rejected() {
5216        for key in ["description", "status", "lead"] {
5217            let tmp = TempDir::new().unwrap();
5218            let path = write_toml(
5219                tmp.path(),
5220                &format!(
5221                    "issues_dir = \"docs/issues\"\n\n\
5222                     [projects.\"p-1\"]\nname = \"One\"\n{key} = \"\"\n"
5223                ),
5224            );
5225            let err = Config::load_with_warning(&path, |_| {})
5226                .unwrap_err()
5227                .to_string();
5228            assert!(err.contains(&format!("projects.p-1.{key}")), "got {err}");
5229        }
5230    }
5231
5232    #[test]
5233    fn a_bad_or_inverted_project_date_is_rejected() {
5234        let tmp = TempDir::new().unwrap();
5235        let bad = write_toml(
5236            tmp.path(),
5237            "issues_dir = \"docs/issues\"\n\n\
5238             [projects.\"p-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5239        );
5240        let err = Config::load_with_warning(&bad, |_| {})
5241            .unwrap_err()
5242            .to_string();
5243        assert!(err.contains("projects.p-1"), "got {err}");
5244
5245        let inverted = write_toml(
5246            tmp.path(),
5247            "issues_dir = \"docs/issues\"\n\n\
5248             [projects.\"p-1\"]\nname = \"One\"\n\
5249             start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5250        );
5251        let err = Config::load_with_warning(&inverted, |_| {})
5252            .unwrap_err()
5253            .to_string();
5254        assert!(err.contains("projects.p-1"), "got {err}");
5255    }
5256
5257    #[test]
5258    fn a_valid_project_date_pair_passes() {
5259        let tmp = TempDir::new().unwrap();
5260        let path = write_toml(tmp.path(), TWO_PROJECTS);
5261        Config::load_with_warning(&path, |_| {}).expect("a valid registry must load");
5262    }
5263
5264    /// The shape the allocator produces: `p-` plus 16 hex digits, distinct draws.
5265    #[test]
5266    fn allocated_project_ids_have_the_generated_shape_and_successive_draws_differ() {
5267        let mut config = Config::default();
5268        let first = config.allocate_project_id();
5269        config.projects.insert(
5270            first.to_string(),
5271            ProjectConfiguration {
5272                name: "One".into(),
5273                ..ProjectConfiguration::default()
5274            },
5275        );
5276        let second = config.allocate_project_id();
5277        assert_ne!(first, second, "two allocations must be distinct");
5278        assert_eq!(first.as_str().len(), 18, "`p-` plus 16 hex digits");
5279        assert!(first.as_str().starts_with("p-"));
5280
5281        // From the never-reused rule's second half: a deleted project's id is
5282        // spent. With the real generator this is observed as "a fresh draw is a
5283        // different id"; the redraw half is pinned exactly by
5284        // `allocation_redraws_past_a_stored_id`.
5285        config.projects.shift_remove(first.as_str());
5286        let third = config.allocate_project_id();
5287        assert_ne!(
5288            third, first,
5289            "a deleted project's id must not be handed out"
5290        );
5291        assert_ne!(third, second);
5292    }
5293
5294    /// The exact half of the never-reused rule: allocation redraws past an id that
5295    /// is already stored. The draw is injected, because a real generator collides
5296    /// so rarely that the redraw loop is otherwise untestable.
5297    #[test]
5298    fn allocation_redraws_past_a_stored_id() {
5299        let mut config = Config::default();
5300        config.projects.insert(
5301            "p-aaaa".into(),
5302            ProjectConfiguration {
5303                name: "Stored".into(),
5304                ..ProjectConfiguration::default()
5305            },
5306        );
5307        let scripted = ["p-aaaa", "p-bbbb"];
5308        let mut draws = 0;
5309        let allocated = config.allocate_project_id_with(|| {
5310            let drawn = scripted[draws];
5311            draws += 1;
5312            ProjectId::new(drawn).unwrap()
5313        });
5314        assert_eq!(allocated.as_str(), "p-bbbb");
5315        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5316    }
5317
5318    /// The same exact half, for the schedule registry: allocation redraws past an id
5319    /// that is already stored. The draw is injected for the same reason — a fresh
5320    /// `cs-` id cannot collide with a stored one, so the real generator cannot exercise
5321    /// the loop.
5322    #[test]
5323    fn schedule_allocation_redraws_past_a_stored_id() {
5324        let mut config = Config::default();
5325        config.cycle_schedules.insert(
5326            "cs-aaaa".into(),
5327            crate::cycle_schedule::config::CycleScheduleConfiguration {
5328                name: "Stored".into(),
5329                ..crate::cycle_schedule::config::CycleScheduleConfiguration::default()
5330            },
5331        );
5332        let scripted = ["cs-aaaa", "cs-bbbb"];
5333        let mut draws = 0;
5334        let allocated = config.allocate_cycle_schedule_id_with(|| {
5335            let drawn = scripted[draws];
5336            draws += 1;
5337            crate::cycle_schedule::config::CycleScheduleId::new(drawn).unwrap()
5338        });
5339        assert_eq!(allocated.as_str(), "cs-bbbb");
5340        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5341    }
5342
5343    #[test]
5344    fn a_hand_written_project_id_loads_and_is_never_allocated_again() {
5345        let tmp = TempDir::new().unwrap();
5346        let path = write_toml(
5347            tmp.path(),
5348            "issues_dir = \"docs/issues\"\n\n\
5349             [projects.\"p-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5350        );
5351        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5352        assert!(config.projects.contains_key("p-ffffffffffffffff"));
5353        // The stored id is never returned, however many times a draw scripted to
5354        // produce it is asked.
5355        let mut draws = 0;
5356        let allocated = config.allocate_project_id_with(|| {
5357            draws += 1;
5358            if draws < 3 {
5359                ProjectId::new("p-ffffffffffffffff").unwrap()
5360            } else {
5361                ProjectId::new("p-0000000000000000").unwrap()
5362            }
5363        });
5364        assert_eq!(allocated.as_str(), "p-0000000000000000");
5365        assert_eq!(draws, 3);
5366    }
5367
5368    #[test]
5369    fn a_rename_changes_the_label_and_never_the_key() {
5370        let tmp = TempDir::new().unwrap();
5371        let path = write_toml(tmp.path(), TWO_PROJECTS);
5372        let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5373        config.projects["p-3f9a2c"].name = "Platform hardening (renamed)".into();
5374        config.save(&path).unwrap();
5375
5376        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5377        assert!(
5378            reloaded.projects.contains_key("p-3f9a2c"),
5379            "the id is the key and must survive a rename"
5380        );
5381        assert_eq!(
5382            reloaded.projects["p-3f9a2c"].name,
5383            "Platform hardening (renamed)"
5384        );
5385    }
5386
5387    #[test]
5388    fn project_id_validation_accepts_handles_and_rejects_junk() {
5389        assert!(ProjectId::new("p-platform").is_ok());
5390        assert!(ProjectId::new("p-3f9a2c").is_ok());
5391        assert!(ProjectId::new("").is_err());
5392        assert!(ProjectId::new(" p-1").is_err(), "surrounding whitespace");
5393        assert!(ProjectId::new("p 1").is_err(), "inner whitespace");
5394        assert!(ProjectId::new("p.1").is_err(), "structural `.`");
5395        assert!(ProjectId::new(&"x".repeat(ProjectId::MAX_LEN + 1)).is_err());
5396    }
5397
5398    // --- WP01: the `[epics.<epic-id>]` registry (FR-001, FR-003, C-002, C-003, C-004) ---
5399
5400    const TWO_EPICS: &str = "issues_dir = \"docs/issues\"\n\n\
5401        [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5402        [epics.\"e-7c4a91\"]\n\
5403        name = \"Harden the sync path\"\n\
5404        description = \"Reduce operational risk across every tracker adapter.\"\n\
5405        status = \"active\"\n\
5406        owner = \"Alice <alice@example.com>\"\n\
5407        start_date = \"2026-09-01\"\n\
5408        due_date = \"2026-12-31\"\n\
5409        weight = 5\n\
5410        parent = \"e-2b8f30\"\n\
5411        projects = [\"p-3f9a2c\"]\n\
5412        milestone = \"2026-Q4\"\n\
5413        cycle = \"October 2026\"\n\n\
5414        [epics.\"e-2b8f30\"]\n\
5415        name = \"Q4 reliability\"\n";
5416
5417    #[test]
5418    fn epic_registry_parses_every_contract_field() {
5419        let tmp = TempDir::new().unwrap();
5420        let path = write_toml(tmp.path(), TWO_EPICS);
5421        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5422
5423        let sync = &config.epics["e-7c4a91"];
5424        assert_eq!(sync.name, "Harden the sync path");
5425        assert_eq!(
5426            sync.description.as_deref(),
5427            Some("Reduce operational risk across every tracker adapter.")
5428        );
5429        assert_eq!(sync.status.as_deref(), Some("active"));
5430        assert_eq!(sync.owner.as_deref(), Some("Alice <alice@example.com>"));
5431        assert_eq!(sync.start_date.as_deref(), Some("2026-09-01"));
5432        assert_eq!(sync.due_date.as_deref(), Some("2026-12-31"));
5433        assert_eq!(sync.weight, Some(5));
5434        assert_eq!(sync.parent.as_deref(), Some("e-2b8f30"));
5435        assert_eq!(sync.projects, ["p-3f9a2c"]);
5436        assert_eq!(sync.milestone.as_deref(), Some("2026-Q4"));
5437        assert_eq!(sync.cycle.as_deref(), Some("October 2026"));
5438
5439        // The key is the id; a record that omits every optional field carries no
5440        // value — and no empty `projects` list.
5441        let q4 = &config.epics["e-2b8f30"];
5442        assert_eq!(q4.name, "Q4 reliability");
5443        assert!(q4.description.is_none() && q4.status.is_none() && q4.owner.is_none());
5444        assert!(q4.start_date.is_none() && q4.due_date.is_none());
5445        assert!(q4.weight.is_none() && q4.parent.is_none() && q4.milestone.is_none());
5446        assert!(q4.cycle.is_none());
5447        assert_eq!(q4.projects, [] as [std::string::String; 0]);
5448    }
5449
5450    #[test]
5451    fn epic_registry_rejects_an_unknown_field() {
5452        let tmp = TempDir::new().unwrap();
5453        let path = write_toml(
5454            tmp.path(),
5455            "issues_dir = \"docs/issues\"\n\n\
5456             [epics.\"e-1\"]\nname = \"One\"\nownr = \"typo\"\n",
5457        );
5458        let err = Config::load_with_warning(&path, |_| {})
5459            .unwrap_err()
5460            .to_string();
5461        assert!(err.contains("ownr"), "got {err}");
5462    }
5463
5464    /// `weight` is epic metadata and there is **no** size field on the record:
5465    /// `Issue.estimate` stays the free-form string it is (research R3).
5466    #[test]
5467    fn an_epic_record_takes_weight_but_never_an_estimate_field() {
5468        let tmp = TempDir::new().unwrap();
5469        let path = write_toml(
5470            tmp.path(),
5471            "issues_dir = \"docs/issues\"\n\n\
5472             [epics.\"e-1\"]\nname = \"One\"\nestimate = \"2\"\n",
5473        );
5474        let err = Config::load_with_warning(&path, |_| {})
5475            .unwrap_err()
5476            .to_string();
5477        assert!(err.contains("estimate"), "got {err}");
5478    }
5479
5480    #[test]
5481    fn epic_declaration_order_is_restored() {
5482        let tmp = TempDir::new().unwrap();
5483        let path = write_toml(
5484            tmp.path(),
5485            "issues_dir = \"docs/issues\"\n\n\
5486             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5487             [epics.\"e-zeta\"]\nname = \"Zeta\"\n\n\
5488             [epics.\"e-alpha\"]\nname = \"Alpha\"\n",
5489        );
5490        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5491        assert_eq!(
5492            config.epics.keys().collect::<Vec<_>>(),
5493            ["e-zeta", "e-alpha"],
5494            "declaration order is the display order"
5495        );
5496        config.save(&path).unwrap();
5497        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5498        assert_eq!(
5499            reloaded.epics.keys().collect::<Vec<_>>(),
5500            ["e-zeta", "e-alpha"],
5501            "declaration order survives a save/reload through the ordered map"
5502        );
5503    }
5504
5505    #[test]
5506    fn a_legacy_tix_toml_gains_no_epics_section() {
5507        let tmp = TempDir::new().unwrap();
5508        let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5509        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5510        assert!(config.epics.is_empty());
5511        config.save(&path).unwrap();
5512        let after = std::fs::read_to_string(&path).unwrap();
5513        assert_eq!(
5514            after, LEGACY_PRETTY_BOARD,
5515            "a repository with no epics must round-trip byte-identically"
5516        );
5517        assert!(
5518            !after.contains("[epics"),
5519            "an empty registry writes no section: {after}"
5520        );
5521    }
5522
5523    /// The two registries coexist: a config with `[projects]` and no `[epics]`
5524    /// round-trips cleanly, and one with both keeps each section.
5525    #[test]
5526    fn epics_and_projects_registries_coexist() {
5527        let tmp = TempDir::new().unwrap();
5528        let path = write_toml(tmp.path(), TWO_PROJECTS);
5529        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5530        assert!(config.epics.is_empty(), "no `[epics]` means no epics");
5531        config.save(&path).unwrap();
5532        let after = std::fs::read_to_string(&path).unwrap();
5533        assert!(!after.contains("[epics"), "got {after}");
5534        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5535        assert_eq!(reloaded.projects, config.projects);
5536        assert!(reloaded.epics.is_empty());
5537
5538        let both_dir = TempDir::new().unwrap();
5539        let both = write_toml(
5540            both_dir.path(),
5541            "issues_dir = \"docs/issues\"\n\n\
5542             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5543             [projects.\"p-3f9a2c\"]\nname = \"Platform\"\n\n\
5544             [epics.\"e-1\"]\nname = \"Harden\"\nprojects = [\"p-3f9a2c\"]\n",
5545        );
5546        let config = Config::load_with_warning(&both, |_| {}).unwrap();
5547        assert_eq!(config.projects.keys().collect::<Vec<_>>(), ["p-3f9a2c"]);
5548        assert_eq!(config.epics.keys().collect::<Vec<_>>(), ["e-1"]);
5549        config.save(&both).unwrap();
5550        let reloaded = Config::load_with_warning(&both, |_| {}).unwrap();
5551        assert_eq!(reloaded.projects, config.projects);
5552        assert_eq!(reloaded.epics, config.epics);
5553    }
5554
5555    #[test]
5556    fn an_empty_epic_record_writes_no_optional_keys() {
5557        let tmp = TempDir::new().unwrap();
5558        let path = write_toml(
5559            tmp.path(),
5560            "issues_dir = \"docs/issues\"\n\n\
5561             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5562             [epics.\"e-1\"]\nname = \"One\"\n",
5563        );
5564        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5565        config.save(&path).unwrap();
5566        let after = std::fs::read_to_string(&path).unwrap();
5567        for key in [
5568            "description",
5569            "status",
5570            "owner",
5571            "start_date",
5572            "due_date",
5573            "weight",
5574            "parent",
5575            "projects",
5576            "milestone",
5577            "cycle",
5578        ] {
5579            assert!(
5580                !after.contains(key),
5581                "an absent optional field must not be written: {key} in {after}"
5582            );
5583        }
5584        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5585        assert_eq!(reloaded, config);
5586    }
5587
5588    #[test]
5589    fn a_blank_epic_name_is_rejected() {
5590        let tmp = TempDir::new().unwrap();
5591        let path = write_toml(
5592            tmp.path(),
5593            "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"  \"\n",
5594        );
5595        let err = Config::load_with_warning(&path, |_| {})
5596            .unwrap_err()
5597            .to_string();
5598        assert!(err.contains("epics.e-1.name"), "got {err}");
5599    }
5600
5601    #[test]
5602    fn duplicate_epic_names_are_rejected() {
5603        for second in ["Same", "same"] {
5604            let tmp = TempDir::new().unwrap();
5605            let path = write_toml(
5606                tmp.path(),
5607                &format!(
5608                    "issues_dir = \"docs/issues\"\n\n\
5609                     [epics.\"e-1\"]\nname = \"Same\"\n\n\
5610                     [epics.\"e-2\"]\nname = \"{second}\"\n"
5611                ),
5612            );
5613            let err = Config::load_with_warning(&path, |_| {})
5614                .unwrap_err()
5615                .to_string();
5616            assert!(err.contains("must be unique"), "got {err}");
5617        }
5618    }
5619
5620    #[test]
5621    fn a_padded_epic_name_is_rejected() {
5622        let tmp = TempDir::new().unwrap();
5623        let path = write_toml(
5624            tmp.path(),
5625            "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" One \"\n",
5626        );
5627        let err = Config::load_with_warning(&path, |_| {})
5628            .unwrap_err()
5629            .to_string();
5630        assert!(err.contains("leading or trailing whitespace"), "got {err}");
5631    }
5632
5633    #[test]
5634    fn a_blank_epic_id_is_rejected() {
5635        let tmp = TempDir::new().unwrap();
5636        let path = write_toml(
5637            tmp.path(),
5638            "issues_dir = \"docs/issues\"\n\n[epics.\"\"]\nname = \"No id\"\n",
5639        );
5640        let err = Config::load_with_warning(&path, |_| {})
5641            .unwrap_err()
5642            .to_string();
5643        assert!(err.contains("must not be blank"), "got {err}");
5644    }
5645
5646    #[test]
5647    fn an_epic_id_with_a_structural_character_is_rejected() {
5648        let tmp = TempDir::new().unwrap();
5649        let path = write_toml(
5650            tmp.path(),
5651            "issues_dir = \"docs/issues\"\n\n[epics.\"e.1\"]\nname = \"Dotted\"\n",
5652        );
5653        let err = Config::load_with_warning(&path, |_| {})
5654            .unwrap_err()
5655            .to_string();
5656        assert!(err.contains("contains"), "got {err}");
5657    }
5658
5659    #[test]
5660    fn blank_optional_epic_text_is_rejected() {
5661        for key in ["description", "status", "owner", "milestone", "cycle"] {
5662            let tmp = TempDir::new().unwrap();
5663            let path = write_toml(
5664                tmp.path(),
5665                &format!(
5666                    "issues_dir = \"docs/issues\"\n\n\
5667                     [epics.\"e-1\"]\nname = \"One\"\n{key} = \"\"\n"
5668                ),
5669            );
5670            let err = Config::load_with_warning(&path, |_| {})
5671                .unwrap_err()
5672                .to_string();
5673            assert!(err.contains(&format!("epics.e-1.{key}")), "got {err}");
5674        }
5675    }
5676
5677    #[test]
5678    fn a_bad_or_inverted_epic_date_is_rejected() {
5679        let tmp = TempDir::new().unwrap();
5680        let bad = write_toml(
5681            tmp.path(),
5682            "issues_dir = \"docs/issues\"\n\n\
5683             [epics.\"e-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5684        );
5685        let err = Config::load_with_warning(&bad, |_| {})
5686            .unwrap_err()
5687            .to_string();
5688        assert!(err.contains("epics.e-1"), "got {err}");
5689
5690        let inverted = write_toml(
5691            tmp.path(),
5692            "issues_dir = \"docs/issues\"\n\n\
5693             [epics.\"e-1\"]\nname = \"One\"\n\
5694             start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5695        );
5696        let err = Config::load_with_warning(&inverted, |_| {})
5697            .unwrap_err()
5698            .to_string();
5699        assert!(err.contains("epics.e-1"), "got {err}");
5700    }
5701
5702    #[test]
5703    fn a_non_positive_or_unparseable_weight_is_rejected() {
5704        for body in ["weight = 0", "weight = -3"] {
5705            let tmp = TempDir::new().unwrap();
5706            let path = write_toml(
5707                tmp.path(),
5708                &format!(
5709                    "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5710                ),
5711            );
5712            let err = Config::load_with_warning(&path, |_| {})
5713                .unwrap_err()
5714                .to_string();
5715            assert!(err.contains("positive integer"), "{body}: got {err}");
5716            assert!(err.contains("epics.e-1.weight"), "{body}: got {err}");
5717        }
5718
5719        // Unparseable: the field is an integer, so the TOML parser refuses a string.
5720        let tmp = TempDir::new().unwrap();
5721        let path = write_toml(
5722            tmp.path(),
5723            "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\nweight = \"big\"\n",
5724        );
5725        let err = Config::load_with_warning(&path, |_| {})
5726            .unwrap_err()
5727            .to_string();
5728        assert!(err.contains("weight"), "got {err}");
5729    }
5730
5731    /// Config-layer validation is about the **shape** of a reference, never its
5732    /// resolution: a project, milestone, cycle, or parent epic that does not exist
5733    /// must stay loadable and repairable (C-005). Resolution and diagnosis belong to
5734    /// the epic service (WP02) and to `tix lint`.
5735    #[test]
5736    fn epic_references_are_shape_checked_but_never_resolved() {
5737        let tmp = TempDir::new().unwrap();
5738        let path = write_toml(
5739            tmp.path(),
5740            "issues_dir = \"docs/issues\"\n\n\
5741             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5742             [epics.\"e-1\"]\nname = \"Child\"\n\
5743             parent = \"e-missing\"\nprojects = [\"p-missing\"]\n\
5744             milestone = \"2026-Q4\"\ncycle = \"No such cycle\"\n",
5745        );
5746        let config = Config::load_with_warning(&path, |_| {})
5747            .expect("a dangling reference must stay loadable");
5748        let epic = &config.epics["e-1"];
5749        assert_eq!(epic.parent.as_deref(), Some("e-missing"));
5750        assert_eq!(epic.projects, ["p-missing"]);
5751        assert_eq!(epic.milestone.as_deref(), Some("2026-Q4"));
5752        assert_eq!(epic.cycle.as_deref(), Some("No such cycle"));
5753
5754        // Shape, however, is this layer's business.
5755        for (body, needle) in [
5756            ("parent = \"e.1\"", "epics.e-1.parent"),
5757            ("parent = \"\"", "epics.e-1.parent"),
5758            ("parent = \"e-1\"", "must not name the epic itself"),
5759            ("projects = [\"p.1\"]", "epics.e-1.projects"),
5760        ] {
5761            let tmp = TempDir::new().unwrap();
5762            let path = write_toml(
5763                tmp.path(),
5764                &format!(
5765                    "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5766                ),
5767            );
5768            let err = Config::load_with_warning(&path, |_| {})
5769                .unwrap_err()
5770                .to_string();
5771            assert!(err.contains(needle), "{body}: got {err}");
5772        }
5773    }
5774
5775    /// The shape the allocator produces: `e-` plus 16 hex digits, distinct draws.
5776    #[test]
5777    fn allocated_epic_ids_have_the_generated_shape_and_successive_draws_differ() {
5778        let mut config = Config::default();
5779        let first = config.allocate_epic_id();
5780        config.epics.insert(
5781            first.to_string(),
5782            EpicConfiguration {
5783                name: "One".into(),
5784                ..EpicConfiguration::default()
5785            },
5786        );
5787        let second = config.allocate_epic_id();
5788        assert_ne!(first, second, "two allocations must be distinct");
5789        assert_eq!(first.as_str().len(), 18, "`e-` plus 16 hex digits");
5790        assert!(first.as_str().starts_with("e-"));
5791
5792        // A deleted epic's id is spent: the never-reused rule is what keeps a member
5793        // issue's dangling reference diagnosable instead of silently rebound (C-005).
5794        config.epics.shift_remove(first.as_str());
5795        let third = config.allocate_epic_id();
5796        assert_ne!(third, first, "a deleted epic's id must not be handed out");
5797        assert_ne!(third, second);
5798    }
5799
5800    /// The exact half of the never-reused rule: allocation redraws past an id that is
5801    /// already stored. The draw is injected, because a real generator collides so
5802    /// rarely that the redraw loop is otherwise untestable.
5803    #[test]
5804    fn epic_allocation_redraws_past_a_stored_id() {
5805        let mut config = Config::default();
5806        config.epics.insert(
5807            "e-aaaa".into(),
5808            EpicConfiguration {
5809                name: "Stored".into(),
5810                ..EpicConfiguration::default()
5811            },
5812        );
5813        let scripted = ["e-aaaa", "e-bbbb"];
5814        let mut draws = 0;
5815        let allocated = config.allocate_epic_id_with(|| {
5816            let drawn = scripted[draws];
5817            draws += 1;
5818            EpicId::new(drawn).unwrap()
5819        });
5820        assert_eq!(allocated.as_str(), "e-bbbb");
5821        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5822    }
5823
5824    #[test]
5825    fn a_hand_written_epic_id_loads_and_is_never_allocated_again() {
5826        let tmp = TempDir::new().unwrap();
5827        let path = write_toml(
5828            tmp.path(),
5829            "issues_dir = \"docs/issues\"\n\n\
5830             [epics.\"e-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5831        );
5832        let config = Config::load_with_warning(&path, |_| {}).unwrap();
5833        assert!(config.epics.contains_key("e-ffffffffffffffff"));
5834        // The stored id is never returned, however many times a draw scripted to
5835        // produce it is asked. A hand-written handle coexists with the generated shape.
5836        let mut draws = 0;
5837        let allocated = config.allocate_epic_id_with(|| {
5838            draws += 1;
5839            if draws < 3 {
5840                EpicId::new("e-ffffffffffffffff").unwrap()
5841            } else {
5842                EpicId::new("e-0000000000000000").unwrap()
5843            }
5844        });
5845        assert_eq!(allocated.as_str(), "e-0000000000000000");
5846        assert_eq!(draws, 3);
5847    }
5848
5849    #[test]
5850    fn an_epic_rename_changes_the_label_and_never_the_key() {
5851        let tmp = TempDir::new().unwrap();
5852        let path = write_toml(tmp.path(), TWO_EPICS);
5853        let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5854        config.epics["e-7c4a91"].name = "Harden the sync path (renamed)".into();
5855        config.save(&path).unwrap();
5856
5857        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5858        assert!(
5859            reloaded.epics.contains_key("e-7c4a91"),
5860            "the id is the key and must survive a rename"
5861        );
5862        assert_eq!(
5863            reloaded.epics["e-7c4a91"].name,
5864            "Harden the sync path (renamed)"
5865        );
5866    }
5867
5868    #[test]
5869    fn epic_id_validation_accepts_handles_and_rejects_junk() {
5870        assert!(EpicId::new("e-platform").is_ok());
5871        assert!(EpicId::new("e-7c4a91").is_ok());
5872        assert!(EpicId::new("").is_err());
5873        assert!(EpicId::new(" e-1").is_err(), "surrounding whitespace");
5874        assert!(EpicId::new("e 1").is_err(), "inner whitespace");
5875        assert!(EpicId::new("e.1").is_err(), "structural `.`");
5876        assert!(EpicId::new(&"x".repeat(EpicId::MAX_LEN + 1)).is_err());
5877    }
5878
5879    /// The two registries share one rule for a key, not two: the checks are the same
5880    /// and the message differs only in the prefix words. The project strings are
5881    /// asserted exactly, because they are the ones issue #178 shipped.
5882    #[test]
5883    fn the_two_registries_share_the_id_rules() {
5884        assert_eq!(
5885            ProjectId::new("").unwrap_err().to_string(),
5886            "config error: a project id must not be blank"
5887        );
5888        assert_eq!(
5889            EpicId::new("").unwrap_err().to_string(),
5890            "config error: an epic id must not be blank"
5891        );
5892        assert_eq!(
5893            ProjectId::new(" p-1").unwrap_err().to_string(),
5894            "config error: project id ` p-1` has leading or trailing whitespace"
5895        );
5896        assert_eq!(
5897            EpicId::new("e-1 ").unwrap_err().to_string(),
5898            "config error: epic id `e-1 ` has leading or trailing whitespace"
5899        );
5900        assert_eq!(ProjectId::MAX_LEN, EpicId::MAX_LEN);
5901
5902        // A handle that is valid for one kind is valid for the other, and the same
5903        // junk is refused by both.
5904        for handle in ["x-y", "a_b", "a:b", "a-1"] {
5905            assert!(ProjectId::new(handle).is_ok(), "{handle}");
5906            assert!(EpicId::new(handle).is_ok(), "{handle}");
5907        }
5908        for junk in ["", " a", "a b", "a.b", "a[b", "a\"b"] {
5909            assert!(ProjectId::new(junk).is_err(), "{junk}");
5910            assert!(EpicId::new(junk).is_err(), "{junk}");
5911        }
5912    }
5913
5914    const ONE_INITIATIVE: &str = "issues_dir = \"docs/issues\"\n\n\
5915        [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5916        [initiatives.\"i-1\"]\nname = \"Grow the platform\"\n";
5917
5918    #[test]
5919    fn initiative_id_validation_accepts_handles_and_rejects_junk() {
5920        assert!(InitiativeId::new("i-outcome").is_ok());
5921        assert!(InitiativeId::new("i-7c4a91").is_ok());
5922        assert!(InitiativeId::new("").is_err());
5923        assert!(InitiativeId::new(" i-1").is_err(), "surrounding whitespace");
5924        assert!(InitiativeId::new("i 1").is_err(), "inner whitespace");
5925        assert!(InitiativeId::new("i.1").is_err(), "structural `.`");
5926        assert!(InitiativeId::new(&"x".repeat(InitiativeId::MAX_LEN + 1)).is_err());
5927    }
5928
5929    /// The third registry shares the same key rule, worded for its own kind: the
5930    /// project/epic assertions above pin the rule; this pins the third spelling.
5931    #[test]
5932    fn the_three_registries_share_the_id_rules() {
5933        assert_eq!(
5934            InitiativeId::new("").unwrap_err().to_string(),
5935            "config error: an initiative id must not be blank"
5936        );
5937        assert_eq!(
5938            InitiativeId::new(" i-1").unwrap_err().to_string(),
5939            "config error: initiative id ` i-1` has leading or trailing whitespace"
5940        );
5941        assert_eq!(InitiativeId::MAX_LEN, ProjectId::MAX_LEN);
5942        assert_eq!(InitiativeId::MAX_LEN, EpicId::MAX_LEN);
5943    }
5944
5945    #[test]
5946    fn allocated_initiative_ids_have_the_generated_shape_and_successive_draws_differ() {
5947        let mut config = Config::default();
5948        let first = config.allocate_initiative_id();
5949        config.initiatives.insert(
5950            first.to_string(),
5951            InitiativeConfiguration {
5952                name: "One".into(),
5953                ..InitiativeConfiguration::default()
5954            },
5955        );
5956        let second = config.allocate_initiative_id();
5957        assert_ne!(first, second, "two allocations must be distinct");
5958        assert_eq!(first.as_str().len(), 18, "`i-` plus 16 hex digits");
5959        assert!(first.as_str().starts_with("i-"));
5960
5961        // A deleted initiative's id is spent: a later record must not be able to
5962        // masquerade as the one that was removed.
5963        config.initiatives.shift_remove(first.as_str());
5964        let third = config.allocate_initiative_id();
5965        assert_ne!(
5966            third, first,
5967            "a deleted initiative's id must not be handed out"
5968        );
5969        assert_ne!(third, second);
5970    }
5971
5972    #[test]
5973    fn initiative_allocation_redraws_past_a_stored_id() {
5974        let mut config = Config::default();
5975        config.initiatives.insert(
5976            "i-aaaa".into(),
5977            InitiativeConfiguration {
5978                name: "Stored".into(),
5979                ..InitiativeConfiguration::default()
5980            },
5981        );
5982        let scripted = ["i-aaaa", "i-bbbb"];
5983        let mut draws = 0;
5984        let allocated = config.allocate_initiative_id_with(|| {
5985            let drawn = scripted[draws];
5986            draws += 1;
5987            InitiativeId::new(drawn).unwrap()
5988        });
5989        assert_eq!(allocated.as_str(), "i-bbbb");
5990        assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5991    }
5992
5993    /// An absent `[initiatives]` section gains no key on save (C-001), and a record
5994    /// with only a name writes no optional key — the "empty record" round trip.
5995    #[test]
5996    fn an_absent_initiatives_section_and_an_empty_record_write_nothing_extra() {
5997        let tmp = TempDir::new().unwrap();
5998        let legacy = write_toml(
5999            tmp.path(),
6000            "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
6001        );
6002        let config = Config::load_with_warning(&legacy, |_| {}).unwrap();
6003        assert!(config.initiatives.is_empty());
6004        config.save(&legacy).unwrap();
6005        assert!(
6006            !std::fs::read_to_string(&legacy)
6007                .unwrap()
6008                .contains("[initiatives"),
6009            "a legacy file gains no section"
6010        );
6011
6012        let path = write_toml(tmp.path(), ONE_INITIATIVE);
6013        let config = Config::load_with_warning(&path, |_| {}).unwrap();
6014        config.save(&path).unwrap();
6015        let after = std::fs::read_to_string(&path).unwrap();
6016        for key in [
6017            "outcome",
6018            "owner",
6019            "status",
6020            "priority",
6021            "labels",
6022            "target_date",
6023            "projects",
6024            "epics",
6025            "milestones",
6026        ] {
6027            assert!(
6028                !after.contains(key),
6029                "an absent optional field must not be written: {key} in {after}"
6030            );
6031        }
6032        assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
6033    }
6034
6035    /// The record deliberately preserves unknown metadata: unlike `[projects]`/
6036    /// `[epics]`, a key this schema does not know round-trips instead of making the
6037    /// file unreadable. This is the trade-off the type-level doc records.
6038    #[test]
6039    fn an_initiative_preserves_unknown_metadata_through_a_round_trip() {
6040        let tmp = TempDir::new().unwrap();
6041        let path = write_toml(
6042            tmp.path(),
6043            "issues_dir = \"docs/issues\"\n\n\
6044             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6045             [initiatives.\"i-1\"]\nname = \"One\"\ncustom_field = \"keep me\"\n\
6046             nested = { answer = 42 }\n",
6047        );
6048        let config = Config::load_with_warning(&path, |_| {}).unwrap();
6049        let extensions = &config.initiatives["i-1"].extensions;
6050        assert_eq!(extensions.len(), 2);
6051        assert_eq!(
6052            extensions.get("custom_field").and_then(toml::Value::as_str),
6053            Some("keep me")
6054        );
6055        config.save(&path).unwrap();
6056        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6057        assert_eq!(reloaded.initiatives["i-1"].extensions, *extensions);
6058    }
6059
6060    #[test]
6061    fn initiative_validation_refuses_blank_names_bad_dates_and_bad_references() {
6062        for (body, needle) in [
6063            ("name = \"  \"", "initiatives.i-1.name"),
6064            ("name = \" One \"", "leading or trailing whitespace"),
6065            (
6066                "name = \"One\"\ntarget_date = \"2026-13-01\"",
6067                "initiatives.i-1.target_date",
6068            ),
6069            (
6070                "name = \"One\"\ntarget_date = \"not a date\"",
6071                "initiatives.i-1.target_date",
6072            ),
6073            (
6074                "name = \"One\"\nprojects = [\"p.1\"]",
6075                "initiatives.i-1.projects",
6076            ),
6077            ("name = \"One\"\nepics = [\"e.1\"]", "initiatives.i-1.epics"),
6078            (
6079                "name = \"One\"\nmilestones = [\"\"]",
6080                "initiatives.i-1.milestones",
6081            ),
6082            (
6083                "name = \"One\"\nlabels = [\"  \"]",
6084                "initiatives.i-1.labels",
6085            ),
6086            ("name = \"One\"\nowner = \"\"", "initiatives.i-1.owner"),
6087        ] {
6088            let tmp = TempDir::new().unwrap();
6089            let path = write_toml(
6090                tmp.path(),
6091                &format!(
6092                    "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6093                     [initiatives.\"i-1\"]\n{body}\n"
6094                ),
6095            );
6096            let err = Config::load_with_warning(&path, |_| {})
6097                .unwrap_err()
6098                .to_string();
6099            assert!(err.contains(needle), "{body}: got {err}");
6100        }
6101    }
6102
6103    #[test]
6104    fn duplicate_initiative_names_are_rejected_case_folded() {
6105        for second in ["Same", "same"] {
6106            let tmp = TempDir::new().unwrap();
6107            let path = write_toml(
6108                tmp.path(),
6109                &format!(
6110                    "issues_dir = \"docs/issues\"\n\n\
6111                     [initiatives.\"i-1\"]\nname = \"Same\"\n\n\
6112                     [initiatives.\"i-2\"]\nname = \"{second}\"\n"
6113                ),
6114            );
6115            let err = Config::load_with_warning(&path, |_| {})
6116                .unwrap_err()
6117                .to_string();
6118            assert!(err.contains("must be unique"), "got {err}");
6119        }
6120    }
6121
6122    /// Shape is this layer's business; existence never is: a dangling reference must
6123    /// stay loadable and repairable (C-005).
6124    #[test]
6125    fn initiative_references_are_shape_checked_but_never_resolved() {
6126        let tmp = TempDir::new().unwrap();
6127        let path = write_toml(
6128            tmp.path(),
6129            "issues_dir = \"docs/issues\"\n\n\
6130             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6131             [initiatives.\"i-1\"]\nname = \"One\"\n\
6132             projects = [\"p-missing\"]\nepics = [\"e-missing\"]\nmilestones = [\"2026-Q4\"]\n",
6133        );
6134        let config = Config::load_with_warning(&path, |_| {})
6135            .expect("a dangling reference must stay loadable");
6136        let initiative = &config.initiatives["i-1"];
6137        assert_eq!(initiative.projects, ["p-missing"]);
6138        assert_eq!(initiative.epics, ["e-missing"]);
6139        assert_eq!(initiative.milestones, ["2026-Q4"]);
6140    }
6141
6142    /// The three stable-ID registries keep declaration order through save/reload
6143    /// (the map is an `IndexMap` and the session restores the header order the TOML
6144    /// parser loses).
6145    #[test]
6146    fn initiative_declaration_order_survives_a_save_and_reload() {
6147        let tmp = TempDir::new().unwrap();
6148        let path = write_toml(
6149            tmp.path(),
6150            "issues_dir = \"docs/issues\"\n\n\
6151             [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6152             [initiatives.\"i-zulu\"]\nname = \"Zulu\"\n\n\
6153             [initiatives.\"i-alpha\"]\nname = \"Alpha\"\n",
6154        );
6155        let config = Config::load_with_warning(&path, |_| {}).unwrap();
6156        assert_eq!(
6157            config.initiatives.keys().collect::<Vec<_>>(),
6158            ["i-zulu", "i-alpha"]
6159        );
6160        config.save(&path).unwrap();
6161        let written = std::fs::read_to_string(&path).unwrap();
6162        assert!(
6163            written.find("[initiatives.i-zulu]").unwrap()
6164                < written.find("[initiatives.i-alpha]").unwrap(),
6165            "declaration order is preserved on write:\n{written}"
6166        );
6167        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6168        assert_eq!(
6169            reloaded.initiatives.keys().collect::<Vec<_>>(),
6170            ["i-zulu", "i-alpha"]
6171        );
6172    }
6173
6174    /// `priority` reuses the shared [`crate::model::Priority`]: a known spelling is
6175    /// normalized by the same rule issues use, and an unknown value round-trips
6176    /// verbatim.
6177    #[test]
6178    fn an_initiative_priority_uses_the_shared_shape() {
6179        let tmp = TempDir::new().unwrap();
6180        let path = write_toml(
6181            tmp.path(),
6182            "issues_dir = \"docs/issues\"\n\n\
6183             [initiatives.\"i-1\"]\nname = \"Known\"\npriority = \"HIGH\"\n\n\
6184             [initiatives.\"i-2\"]\nname = \"Unknown\"\npriority = \"urgent-ish\"\n",
6185        );
6186        let config = Config::load_with_warning(&path, |_| {}).unwrap();
6187        assert_eq!(
6188            config.initiatives["i-1"].priority,
6189            Some(crate::model::Priority::High)
6190        );
6191        assert_eq!(
6192            config.initiatives["i-2"].priority,
6193            Some(crate::model::Priority::Other("urgent-ish".into()))
6194        );
6195        config.save(&path).unwrap();
6196        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6197        let written = std::fs::read_to_string(&path).unwrap();
6198        assert!(written.contains("priority = \"high\""), "{written}");
6199        assert!(written.contains("priority = \"urgent-ish\""), "{written}");
6200        assert_eq!(reloaded.initiatives, config.initiatives);
6201    }
6202
6203    /// The preserved-extension map must not shadow a built-in field: `toml` would
6204    /// write the key twice with no duplicate detection, so an "accepted" write would
6205    /// make the next load fail. The check runs inside `validate_initiatives`, which
6206    /// every write path already goes through.
6207    #[test]
6208    fn a_shadowing_extension_key_is_refused_by_validation() {
6209        for key in INITIATIVE_KNOWN_FIELDS {
6210            let mut extensions = crate::roadmap::registry::InitiativeExtensions::default();
6211            extensions.insert(*key, toml::Value::String("evil".into()));
6212            let mut config = Config::default();
6213            config.initiatives.insert(
6214                "i-1".into(),
6215                InitiativeConfiguration {
6216                    name: "One".into(),
6217                    extensions,
6218                    ..InitiativeConfiguration::default()
6219                },
6220            );
6221            let err = config.validate().unwrap_err().to_string();
6222            assert!(
6223                err.contains("collides with a built-in field"),
6224                "{key}: got {err}"
6225            );
6226        }
6227
6228        // The list is the record's own field set, kept in step by this check: a
6229        // field missing from it would be shadowable, and an extra name would refuse
6230        // a harmless extension.
6231        let mut config = Config::default();
6232        config.initiatives.insert(
6233            "i-1".into(),
6234            InitiativeConfiguration {
6235                name: "One".into(),
6236                ..InitiativeConfiguration::default()
6237            },
6238        );
6239        config.validate().unwrap();
6240    }
6241}