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::path::{Path, PathBuf};
13
14use indexmap::IndexMap;
15use serde::ser::SerializeStruct;
16use serde::{Deserialize, Serialize, Serializer};
17
18use crate::board_store::BoardStore;
19use crate::cycle_store::{CycleStore, is_implicit_default};
20use crate::error::{Result, TixError};
21use crate::keybindings::{EffectiveBindings, KeyBindings};
22use crate::records::RecordStore;
23
24/// Remote tracker credentials/targets embedded in `tix.toml`.
25///
26/// `token` values are read-only: they are never serialized back out and are
27/// never echoed in logs or errors. `base_url` may override the default
28/// endpoint for self-hosted GitLab instances.
29#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(default, deny_unknown_fields)]
31pub struct RemoteConfig {
32    /// Optional cleartext token; prefer the `GITLAB_TOKEN`/`LINEAR_TOKEN`
33    /// env vars. Never serialized back to disk.
34    pub token: Option<String>,
35    /// Optional base URL (e.g. self-hosted GitLab).
36    pub base_url: Option<String>,
37    /// GitLab project path (e.g. `acme/tix`) passed to `glab`.
38    pub project: Option<String>,
39    /// Linear team name passed to `linear-cli`.
40    pub team: Option<String>,
41}
42
43/// The `[export]` section: per-target remote configuration.
44#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct ExportConfig {
47    /// GitLab remote target.
48    pub gitlab: Option<RemoteConfig>,
49    /// Linear remote target.
50    pub linear: Option<RemoteConfig>,
51}
52
53impl ExportConfig {
54    /// Whether no remote target is configured, for `skip_serializing_if`.
55    ///
56    /// An empty `[export]` table is never written, so a legacy `tix.toml` gains
57    /// no section on save (#1415).
58    pub fn is_empty(&self) -> bool {
59        self.gitlab.is_none() && self.linear.is_none()
60    }
61}
62
63/// A named team (`[teams.<label>]` in `tix.toml`). The label lives as the
64/// map key; `members` are free-form identities feeding the assignee/reviewer
65/// pickers only (never constraining stored values, DM-01M311MRK517KRHR426QPNP6P7).
66///
67/// An absent/empty `[teams]` section means teams are disabled (today's
68/// behavior), and an empty `[teams]` table is never written back out.
69#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(default, deny_unknown_fields)]
71pub struct Team {
72    /// Non-empty per team; unique within the team.
73    pub members: Vec<String>,
74}
75
76/// A named cycle (`[cycles.<name>]` in `tix.toml`). The name lives as the
77/// map key; `start_date`/`due_date` are optional `YYYY-MM-DD` strings with
78/// `due_date >= start_date` when both are present.
79#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
80#[serde(default, deny_unknown_fields)]
81pub struct PlanningCycle {
82    /// Optional human description.
83    pub description: Option<String>,
84    /// Optional start date (`YYYY-MM-DD`).
85    pub start_date: Option<String>,
86    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
87    pub due_date: Option<String>,
88}
89
90/// The `[planning]` section: the monotonic auto-cycle counter plus the
91/// picker presets written by `tix init` (`contracts/tix-toml-planning.md`).
92///
93/// `cycle_number` is the next auto "Cycle N" number: it starts at 1, is bumped
94/// on every unnamed cycle create, and never decrements (FR-003,
95/// DM-01M311BJKMN3PPCR9GR9Y7ESD2). Contrary to the legacy `[state]` counter,
96/// this section **is** serialized once mutated.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(default, deny_unknown_fields)]
99pub struct PlanningConfig {
100    /// Monotonic next auto-cycle number; `>= 1`. Default `1`.
101    pub cycle_number: u32,
102    /// Optional picker preset naming a cycle.
103    pub default_cycle: Option<String>,
104    /// Optional picker preset naming a milestone.
105    pub default_milestone: Option<String>,
106    /// Optional picker preset naming a team.
107    pub default_team: Option<String>,
108}
109
110impl PlanningConfig {
111    /// Whether this section is untouched (all values at their defaults); such
112    /// a section is not written to disk so legacy `tix.toml` files keep
113    /// round-tripping byte-identically (NFR-003).
114    pub fn is_default(&self) -> bool {
115        self == &Self::default()
116    }
117}
118
119impl Default for PlanningConfig {
120    fn default() -> Self {
121        Self {
122            cycle_number: 1,
123            default_cycle: None,
124            default_milestone: None,
125            default_team: None,
126        }
127    }
128}
129
130/// A named new-issue template (`[templates.<name>]` in `tix.toml`).
131///
132/// The F4 New Issue mode (`tui-new-issue-mode`) lists these by name and, on
133/// selection, pre-fills the form's rows from the fields the template sets.
134/// Every field is optional, the section is absent by default, and an empty
135/// `[templates]` table writes no key — so a project that never writes one pays
136/// nothing and its `tix.toml` keeps round-tripping unchanged.
137///
138/// A template carries **content**, not identity: `number`, `created`,
139/// `filename` and `History` are creation outputs rather than inputs, so they are
140/// not fields here. `body` and `labels` are the two the mode's spec requires;
141/// the rest exist because the form already has a row for them, and a template
142/// that could not reach a row would only half-fill the form.
143///
144/// ```toml
145/// [templates.bug]
146/// labels = ["bug"]
147/// body = "## Steps to reproduce\n\n"
148/// ```
149#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct IssueTemplate {
152    /// Pre-filled title. Optional: a template that sets none leaves the title to
153    /// the user, and the title stays required at submit either way.
154    pub title: Option<String>,
155    /// Pre-filled issue body (Markdown).
156    pub body: Option<String>,
157    /// Pre-filled labels.
158    pub labels: Vec<String>,
159    /// Pre-filled status column.
160    pub status: Option<String>,
161    /// Pre-filled priority.
162    pub priority: Option<String>,
163    /// Pre-filled assignee.
164    pub assignee: Option<String>,
165    /// Pre-filled estimate.
166    pub estimate: Option<String>,
167    /// Pre-filled reviewer.
168    pub reviewer: Option<String>,
169    /// Pre-filled cycles.
170    pub cycles: Vec<String>,
171    /// Pre-filled milestones.
172    pub milestones: Vec<String>,
173    /// Pre-filled start date (`YYYY-MM-DD`).
174    pub start_date: Option<String>,
175    /// Pre-filled due date (`YYYY-MM-DD`).
176    pub due_date: Option<String>,
177}
178
179/// The `[state]` section — deprecated runtime bookkeeping that is still
180/// tolerated on load for legacy `tix.toml` files but is never serialized.
181///
182/// The monotonic `next_number` counter this held was removed in issue #52:
183/// issue numbers are now resolved at creation time by scanning the live
184/// issue set, so [`Config::save`] writes no `[state]` section at all.
185#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
186#[serde(default, deny_unknown_fields)]
187pub struct StateConfig {
188    /// Legacy monotonic issue-number counter (C-007). Read-only — kept so
189    /// old `tix.toml` files with `[state].next_number` still load, and
190    /// never persisted once saved again.
191    #[serde(skip_serializing)]
192    pub next_number: u32,
193}
194
195/// Card ordering for the rendered board (`[kanban.<name>].order_by`).
196///
197/// Applied per column in the board builders; the store's underlying order is
198/// untouched so the CLI keeps its own sequencing (board-display FR-02).
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
200pub enum OrderBy {
201    /// Most recently created issues first (the default).
202    #[default]
203    #[serde(rename = "created_at_desc")]
204    CreatedAtDesc,
205    /// Oldest issues first.
206    #[serde(rename = "created_at_asc")]
207    CreatedAtAsc,
208    /// Preserve the underlying store (filename/created-date ascending) order.
209    #[serde(rename = "none")]
210    None,
211}
212
213impl OrderBy {
214    /// The TOML spelling of this variant.
215    pub fn as_str(self) -> &'static str {
216        match self {
217            Self::CreatedAtDesc => "created_at_desc",
218            Self::CreatedAtAsc => "created_at_asc",
219            Self::None => "none",
220        }
221    }
222
223    /// The next variant when cycling the setting in the TUI settings modal.
224    pub fn next(self) -> Self {
225        match self {
226            Self::CreatedAtDesc => Self::CreatedAtAsc,
227            Self::CreatedAtAsc => Self::None,
228            Self::None => Self::CreatedAtDesc,
229        }
230    }
231}
232
233/// The fixed, global category vocabulary (C-002, contract §1).
234///
235/// One list, referenced by the config validator, the per-board mapping and the
236/// service WP02 builds. A vocabulary spelled out in three places is a vocabulary
237/// that disagrees with itself the first time one of them changes.
238///
239/// `TERMINAL` is the subset that ends the workflow. `canceled` is terminal but
240/// **not** complete — `COMPLETE` is deliberately a separate one-element list rather
241/// than `!TERMINAL.contains(...)`, because "terminal" and "complete" are different
242/// claims and the contract makes conflating them an escalation.
243pub const CATEGORY_KEYS: &[&str] = &[
244    "backlog",
245    "unstarted",
246    "active",
247    "blocked",
248    "completed",
249    "canceled",
250];
251
252/// Categories that end the workflow.
253pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
254
255/// The **only** category that means done. `canceled` is terminal without being
256/// complete, so this is a one-element list and not "everything terminal".
257pub const COMPLETE_CATEGORY: &str = "completed";
258
259/// The documented safe default for a column with no mapping (contract §4).
260///
261/// Non-terminal and non-complete, so an unmapped column is never silently counted
262/// as done. A default that resolved to `completed` would change completion
263/// meaning by accident, which the contract makes an escalation rather than a
264/// convenience — [`validate_categories`] refuses one.
265pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
266
267/// `[categories]`: the global vocabulary and the safe default.
268///
269/// Omitted from disk when untouched, so a legacy `tix.toml` round-trips
270/// byte-identically and gains no new keys (NFR-003, C-007).
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(default, deny_unknown_fields)]
273pub struct CategoryConfig {
274    /// The vocabulary, in the project's preferred order for presentation.
275    ///
276    /// Defaults to [`CATEGORY_KEYS`]. A configured list is still validated
277    /// against that vocabulary: a board maps columns *into* the global
278    /// vocabulary, it does not define its own (C-002), so adding a key here is
279    /// an `unknown_category` error rather than a new category.
280    pub order: Vec<String>,
281    /// The category an unmapped column resolves to. Must be in `order`, and must
282    /// not be [`COMPLETE_CATEGORY`].
283    pub default: String,
284}
285
286impl Default for CategoryConfig {
287    fn default() -> Self {
288        Self {
289            order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
290            default: SAFE_DEFAULT_CATEGORY.to_string(),
291        }
292    }
293}
294
295impl CategoryConfig {
296    /// Whether this section is untouched, and so is not written to disk.
297    pub fn is_default(&self) -> bool {
298        self == &Self::default()
299    }
300
301    /// Whether `key` is in the configured vocabulary.
302    pub fn contains(&self, key: &str) -> bool {
303        self.order.iter().any(|k| k == key)
304    }
305}
306
307/// `[transitions]`: the workflow policy.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(default, deny_unknown_fields)]
310pub struct TransitionConfig {
311    /// Whether an issue in a terminal category refuses an ordinary move.
312    ///
313    /// Default `true`. "Ordinary" is load-bearing: the escape hatch WP02 settles is
314    /// a *reasoned* transition out of a terminal category, not a silent
315    /// reassignment, so this switch governs the second and not the first.
316    pub terminal_locked: bool,
317}
318
319impl Default for TransitionConfig {
320    fn default() -> Self {
321        Self {
322            terminal_locked: true,
323        }
324    }
325}
326
327impl TransitionConfig {
328    pub fn is_default(&self) -> bool {
329        self == &Self::default()
330    }
331}
332
333/// The default number of lines a wrapped card title may occupy
334/// ([`BoardConfiguration::title_lines`]). Named so the manual `Serialize` can
335/// recognise it without repeating the literal (#1415).
336const DEFAULT_TITLE_LINES: usize = 3;
337
338/// The `done_column` a board gets when the key is absent.
339///
340/// `BoardConfiguration` is `#[serde(default)]` at the container level, so a
341/// missing `done_column` is filled from [`BoardConfiguration::default`] — this
342/// value, not an inference from `columns`. Named so the manual `Serialize` can
343/// omit a `done_column` that already equals it (#1415).
344const DEFAULT_DONE_COLUMN: &str = "done";
345
346/// A named board configuration (`[kanban.<name>]` in `tix.toml`).
347///
348/// Carries the ordered column list plus per-configuration metadata: the done
349/// column, columns hidden from the rendered board, and whether the computed
350/// "Mismatched" pseudo-column is shown (C-002 / DM-01M2MH4D84NHG0JVWEPR6ZH4VR).
351///
352/// `Serialize` is written by hand (below) so every field equal to the value the
353/// loader would supply is omitted on save, keeping a legacy `tix.toml`
354/// byte-identical; `Deserialize` stays derived.
355#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
356#[serde(default, deny_unknown_fields)]
357pub struct BoardConfiguration {
358    /// Ordered, case-sensitive, non-empty, unique column names.
359    pub columns: Vec<String>,
360    /// The column treated as "done" (commit prompting). Defaults to `"done"`
361    /// when that name is present in `columns`, else the last configured
362    /// column (contracts/tix-toml.md field rules).
363    ///
364    /// **Its meaning is unchanged by this mission.** It remains the commit-prompt
365    /// trigger and the legacy completion signal. WP02 reads the *category* for the
366    /// completion decision; this field is not reinterpreted here, and a
367    /// `category_map` does not change what it does.
368    pub done_column: Option<String>,
369    /// Column name → category key (contract §2). Omitted when empty, so a board
370    /// without a mapping gains no key on save.
371    ///
372    /// A board with **no** map is a legacy board: its `done_column` resolves to
373    /// `completed` and every other column to the safe default. See
374    /// [`BoardConfiguration::category_for`], which is the single place that
375    /// fallback lives — duplicating it in the service is how a board ends up with
376    /// two notions of its own default.
377    pub category_map: IndexMap<String, String>,
378    /// Columns hidden from the rendered board. Must be a subset of `columns`;
379    /// their cards stay in board state and remain reachable via filters/search.
380    pub hidden_columns: Vec<String>,
381    /// Whether the computed "Mismatched" pseudo-column is rendered. Default
382    /// `false`. Never a configured column.
383    pub show_mismatch: bool,
384    /// Whether long card titles wrap onto multiple lines. Default `true`.
385    pub wrap_titles: bool,
386    /// The maximum number of lines a wrapped card may occupy. Default `3`;
387    /// validated `>= 1` in [`validate_board`]. Ignored when `wrap_titles`
388    /// is `false`.
389    pub title_lines: usize,
390    /// Sparse manual card order persisted by drag-and-drop reordering. When
391    /// non-empty, this order takes precedence over [`order_by`] for the cards
392    /// whose positions differ from the automatic ordering.
393    pub card_order: Vec<String>,
394    /// Ordering of cards within each rendered column. Default
395    /// `created_at_desc` (most recently created first).
396    pub order_by: OrderBy,
397}
398
399impl Default for BoardConfiguration {
400    fn default() -> Self {
401        Self {
402            columns: ["todo", "in progress", "in review", "done"]
403                .into_iter()
404                .map(String::from)
405                .collect(),
406            done_column: Some(DEFAULT_DONE_COLUMN.to_string()),
407            category_map: IndexMap::new(),
408            hidden_columns: Vec::new(),
409            show_mismatch: false,
410            wrap_titles: true,
411            title_lines: DEFAULT_TITLE_LINES,
412            card_order: Vec::new(),
413            order_by: OrderBy::default(),
414        }
415    }
416}
417
418impl Serialize for BoardConfiguration {
419    /// Serialize a board as a `[kanban.<name>]` table, omitting **every field
420    /// that already equals the value the loader would supply** (#1415).
421    ///
422    /// `columns` is always written — a board without columns is not a board —
423    /// and `category_map`/`card_order` were already omitted when empty. The
424    /// display and ordering keys (`hidden_columns`, `show_mismatch`,
425    /// `wrap_titles`, `title_lines`, `order_by`) are omitted at their defaults,
426    /// and `done_column` when it already equals [`DEFAULT_DONE_COLUMN`] — the
427    /// value the loader fills in when the key is absent, since the container is
428    /// `#[serde(default)]`. `Deserialize` stays derived, so the round trip is a
429    /// fixed point: omitting a default writes nothing and loading a missing key
430    /// restores the same value.
431    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
432    where
433        S: Serializer,
434    {
435        let done = self
436            .done_column
437            .as_deref()
438            .filter(|done| *done != DEFAULT_DONE_COLUMN);
439        let order_by = (self.order_by != OrderBy::default()).then_some(self.order_by);
440
441        let mut len = 1; // `columns` is always written.
442        len += usize::from(done.is_some());
443        len += usize::from(!self.category_map.is_empty());
444        len += usize::from(!self.hidden_columns.is_empty());
445        len += usize::from(self.show_mismatch);
446        len += usize::from(!self.wrap_titles);
447        len += usize::from(self.title_lines != DEFAULT_TITLE_LINES);
448        len += usize::from(!self.card_order.is_empty());
449        len += usize::from(order_by.is_some());
450
451        let mut board = serializer.serialize_struct("BoardConfiguration", len)?;
452        board.serialize_field("columns", &self.columns)?;
453        if let Some(done) = done {
454            board.serialize_field("done_column", done)?;
455        }
456        if !self.category_map.is_empty() {
457            board.serialize_field("category_map", &self.category_map)?;
458        }
459        if !self.hidden_columns.is_empty() {
460            board.serialize_field("hidden_columns", &self.hidden_columns)?;
461        }
462        if self.show_mismatch {
463            board.serialize_field("show_mismatch", &self.show_mismatch)?;
464        }
465        if !self.wrap_titles {
466            board.serialize_field("wrap_titles", &self.wrap_titles)?;
467        }
468        if self.title_lines != DEFAULT_TITLE_LINES {
469            board.serialize_field("title_lines", &self.title_lines)?;
470        }
471        if !self.card_order.is_empty() {
472            board.serialize_field("card_order", &self.card_order)?;
473        }
474        if let Some(order_by) = order_by {
475            board.serialize_field("order_by", &order_by)?;
476        }
477        board.end()
478    }
479}
480
481/// The board a **new repository** starts with (`tix init`), rather than the bare
482/// [`BoardConfiguration::default`] a config-less or partial file falls back to.
483///
484/// It carries the two columns the triage inbox needs: `triage` (where unreviewed work
485/// lands — `[triage].entry_status`) and `wontfix` (where a decline goes; it has to
486/// exist, because `declined_status` must resolve to the `canceled` category).
487///
488/// **Only those two are mapped**, and that is deliberate. The map is what makes a
489/// board `explicit_policy`, and an explicit policy brings *adjacency*: a category may
490/// move to itself or forward through the declared order, never backwards. Mapping the
491/// working columns would have to call them `unstarted` or `active`, and then
492/// `in progress -> todo` — a move people make every day when a review finds a problem
493/// — would be refused outright, with no `--reason` to override it. Leaving them
494/// unmapped resolves them all to the same safe default, so movement among them stays
495/// free while `done`/`wontfix` are terminal, which is the discipline a new repository
496/// can *opt into* later (`tix lint` names the unmapped columns and how to map them).
497pub fn starter_board() -> BoardConfiguration {
498    BoardConfiguration {
499        columns: [
500            "triage",
501            "todo",
502            "in progress",
503            "in review",
504            "done",
505            "wontfix",
506        ]
507        .into_iter()
508        .map(String::from)
509        .collect(),
510        category_map: IndexMap::from([
511            ("done".to_string(), "completed".to_string()),
512            ("wontfix".to_string(), "canceled".to_string()),
513        ]),
514        ..BoardConfiguration::default()
515    }
516}
517
518impl BoardConfiguration {
519    /// The category `column` resolves to, given the global configuration.
520    ///
521    /// The single place the fallback lives, and the seam WP02's service builds
522    /// on. Three cases, in order:
523    ///
524    /// 1. An explicit `category_map` entry wins.
525    /// 2. Otherwise, on a board with **no** map at all, `done_column` is
526    ///    `completed` and every other column is the safe default.
527    /// 3. Otherwise (a map exists but omits this column), the safe default.
528    ///
529    /// Case 2 is a reconciliation, and the contract's two rules collide without
530    /// it: *"a board without a `category_map` inherits `[categories].default` for
531    /// every column"* (§2) plus *"no column maps to `completed` is a configuration
532    /// error"* (§3) would reject every legacy `tix.toml`, while §2 also requires
533    /// that one to load unchanged (C-007). `done_column` is the resolution: it is
534    /// always a configured column, it already means "this column completes work",
535    /// and using it keeps a legacy board's completion behaviour exactly what it
536    /// was. A board that *does* declare a map gets no such help — a map that
537    /// forgot `completed` is a real mistake and is reported as one.
538    pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
539        if let Some(mapped) = self.category_map.get(column) {
540            return mapped;
541        }
542        if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
543            return COMPLETE_CATEGORY;
544        }
545        &categories.default
546    }
547
548    /// The resolved done column: the explicit `done_column`, else the one
549    /// [`BoardConfiguration::inferred_done_column`] derives from `columns`.
550    pub fn effective_done_column(&self) -> Option<&str> {
551        self.done_column
552            .as_deref()
553            .or_else(|| self.inferred_done_column())
554    }
555
556    /// The `done_column` inferred from `columns` alone, for a board built in
557    /// code rather than read from a file: `"done"` when that name is present,
558    /// else the last configured column. `None` only for empty `columns` (which
559    /// validation rejects).
560    ///
561    /// Split out of [`BoardConfiguration::effective_done_column`] so the
562    /// inference lives in one place.
563    fn inferred_done_column(&self) -> Option<&str> {
564        if self.columns.iter().any(|c| c == DEFAULT_DONE_COLUMN) {
565            Some(DEFAULT_DONE_COLUMN)
566        } else {
567            self.columns.last().map(String::as_str)
568        }
569    }
570}
571
572/// The `[kanban]` section: named configurations in declaration order.
573///
574/// `#[serde(transparent)]` maps the TOML table `[kanban.<name>]` straight
575/// onto the [`IndexMap`]. The `toml` parser sorts table keys, so
576/// [`Config::load_with_warning`] re-orders the map from the source
577/// document's `[kanban.<name>]` header order via
578/// [`restore_declaration_order`] (D-02: declaration order = tab order,
579/// validated by the round-trip test). The map is never empty after load —
580/// [`Config::load_with_warning`] synthesizes the shipped `default`
581/// configuration when `tix.toml` omits the section.
582#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
583#[serde(default, transparent)]
584pub struct KanbanSection {
585    /// Named configurations, keyed by name, in declaration order.
586    pub configurations: IndexMap<String, BoardConfiguration>,
587}
588
589impl KanbanSection {
590    /// Whether no board is configured. An empty section writes no `[kanban]`
591    /// table, which is how a board-file project keeps its `tix.toml` free of
592    /// board records.
593    pub fn is_empty(&self) -> bool {
594        self.configurations.is_empty()
595    }
596}
597
598/// The project configuration file (`tix.toml`).
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(default, deny_unknown_fields)]
601pub struct Config {
602    /// Named board configurations (`[kanban]`). Always ≥1 after load.
603    #[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
604    pub kanban: KanbanSection,
605    /// Directory holding issue files, relative to the repo root.
606    pub issues_dir: PathBuf,
607    /// Directory holding milestone markdown files, relative to the repo
608    /// root. `None` → the default `docs/milestones`.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub milestones_dir: Option<PathBuf>,
611    /// Named teams (`[teams.<label>]`), in declaration order.
612    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
613    pub teams: IndexMap<String, Team>,
614    /// Named cycles (`[cycles.<name>]`), in declaration order.
615    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
616    pub cycles: IndexMap<String, PlanningCycle>,
617    /// Named new-issue templates (`[templates.<name>]`), in declaration order.
618    ///
619    /// Optional and absent by default: a project with no templates writes no
620    /// section, and the F4 New Issue mode simply offers none.
621    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
622    pub templates: IndexMap<String, IssueTemplate>,
623    /// Planning presets and the auto-cycle counter (`[planning]`).
624    #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
625    pub planning: PlanningConfig,
626    /// `[categories]`: the global vocabulary and safe default. Omitted when
627    /// untouched, so a legacy file gains no key on save.
628    #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
629    pub categories: CategoryConfig,
630    /// `[transitions]`: the workflow policy. Omitted when untouched.
631    #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
632    pub transitions: TransitionConfig,
633    /// `[triage]`: the normal columns that make up the triage inbox.
634    ///
635    /// Mission `triage-inbox-review-flow-01M3C600`. The inbox is opt-in: `None` means
636    /// no section, so no inbox, and every `tix triage` command refuses by naming the
637    /// missing section. **`tix init` writes one**, with the board columns it needs, so
638    /// a new repository starts with the inbox on; a legacy `tix.toml` gains no key on
639    /// save. See [`crate::triage::config`].
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub triage: Option<crate::triage::TriageConfig>,
642    /// Optional `$EDITOR` override for full-file editing.
643    pub editor: Option<String>,
644    /// Read-only viewer for the TUI's `v` key (tix #1507).
645    ///
646    /// `None` and `Some("auto")` both mean the built-in probe chain — `mdterm`,
647    /// then `batcat`, `most`, `less`, `more` — and every other value names the
648    /// program to run directly. Never written when unset, so a legacy file gains
649    /// no key on save.
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub viewer: Option<String>,
652    /// Configured AI agent command (opencode, claude, etc.) launched with
653    /// the issue body as its specification. Mirrors the `editor` resolution
654    /// chain (explicit → `$TIX_AGENT` → `$AGENT` env → PATH fallback).
655    pub agent: Option<String>,
656    /// Persisted runtime state (`[state]`). Tolerated on load for legacy
657    /// files, never written back to disk (issue #52).
658    #[serde(skip_serializing)]
659    pub state: StateConfig,
660    /// External tracker targets (`[export]`). Omitted when empty, so a legacy
661    /// file gains no section on save (#1415).
662    #[serde(default, skip_serializing_if = "ExportConfig::is_empty")]
663    pub export: ExportConfig,
664    /// Sparse TUI keybinding overrides (`[keybindings]`).
665    #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
666    pub keybindings: KeyBindings,
667    /// Top-level frontmatter keys this project uses on purpose (`[custom_attributes]`).
668    ///
669    /// #1361. `tix` preserves a frontmatter key it does not recognise and warns
670    /// about it, because an unrecognised key is usually a typo (`titel:`) and a
671    /// typo that round-trips silently is worse than one that is dropped. A project
672    /// that *does* use a custom key declares it here once and stops being warned.
673    ///
674    /// **Top-level names only**, not dotted paths — the thing being declared is a
675    /// top-level key, and accepting a path syntax here would imply nested
676    /// declarations are checked, which they are not.
677    #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
678    pub custom_attributes: CustomAttributes,
679    /// Whether `tix lint` also lints the foreign repositories reachable from
680    /// this store's `related_issues` path references and merges their warnings.
681    ///
682    /// Default `true`. Skipped when `true` so a `tix.toml` that does not set it
683    /// round-trips byte-identically; only an explicit `false` is written.
684    #[serde(default = "default_true", skip_serializing_if = "is_true")]
685    pub lint_foreign_repositories: bool,
686    /// `[layout]`: configurable on-disk issue layout.
687    #[serde(default, skip_serializing_if = "LayoutConfig::is_default")]
688    pub layout: LayoutConfig,
689    /// Allowed issue `type:` values (tix #1530 renamed the key from
690    /// `custom_types`; the old name is still accepted on read).
691    ///
692    /// `tix init` writes the default set (see [`DEFAULT_TYPES`]); `tix lint`
693    /// accepts an issue's `type:` only from this list. `None` falls back to
694    /// [`DEFAULT_TYPES`], so a repository that never configured it still gets a
695    /// meaningful check.
696    #[serde(
697        default,
698        skip_serializing_if = "Option::is_none",
699        alias = "custom_types"
700    )]
701    pub types: Option<Vec<String>>,
702    /// Per-type accent colors (`[type_colors]`, tix #1530).
703    ///
704    /// Maps a type name to a color name. A type with no entry uses the shipped
705    /// [`DEFAULT_TYPE_COLORS`]. Omitted when empty, so an untouched file gains
706    /// no table on save.
707    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
708    pub type_colors: IndexMap<String, String>,
709    /// Directory holding tag definition files (`tags_dir`, tix #1530).
710    ///
711    /// Each `*.md` there names a tag and its `color:`; a tag colors the issue
712    /// label of the same name in the TUI and the CLI. `None` → the default
713    /// `docs/tags`. Absent by default, so a file gains no key on save.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub tags_dir: Option<PathBuf>,
716    /// Child repositories this project coordinates (`[repos]`, tix #1529).
717    ///
718    /// A **parent-only** map: a project-management repository names the
719    /// repositories it manages, one level deep, and each value is that child's
720    /// project root (the directory holding its `tix.toml`), relative to this
721    /// file. Absent by default, so a file gains no section on save. The TUI's
722    /// Repos mode lists these and runs `tix` inside the chosen one.
723    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
724    pub repos: IndexMap<String, PathBuf>,
725    /// The parent repository, when this project is itself a managed child
726    /// (tix #1529).
727    ///
728    /// Set here, the TUI's Repos mode offers a limb that runs `tix` in the
729    /// parent. The parent also sets `TIX_PARENT_REPO` when it spawns a child, so
730    /// the jump back works even without this key.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub parent_repo: Option<PathBuf>,
733}
734
735/// The `[custom_attributes]` section: top-level frontmatter keys this project
736/// carries on purpose (#1361).
737///
738/// A **section** rather than a bare top-level array so there is somewhere to put
739/// the next option, and so the declaration reads as a named decision rather than as
740/// one more anonymous list in a file that is mostly named sections.
741///
742/// ```toml
743/// [custom_attributes]
744/// keys = ["build_id", "reviewed_by"]
745/// ```
746#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
747#[serde(default, deny_unknown_fields)]
748pub struct CustomAttributes {
749    /// Top-level frontmatter key names. Names only — no dotted paths; the thing
750    /// being declared is a top-level key, and a path syntax here would imply nested
751    /// declarations are checked when they are not.
752    pub keys: Vec<String>,
753}
754
755impl CustomAttributes {
756    /// Whether anything is declared, for `skip_serializing_if`.
757    pub fn is_empty(&self) -> bool {
758        self.keys.is_empty()
759    }
760}
761
762/// Serde default for a boolean that defaults to `true`.
763fn default_true() -> bool {
764    true
765}
766/// `skip_serializing_if` for a boolean whose default is `true`: only an explicit
767/// `false` is written to disk.
768#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&bool) -> bool`
769fn is_true(value: &bool) -> bool {
770    *value
771}
772
773/// The default issue types: what `tix init` writes into `types`, and what
774/// `tix lint` accepts when a repository carries no `types`.
775///
776/// `default` is the value an issue without a `type:` falls back to (in `[layout]`
777/// and as an explicit option). `bug`, `open` and `duplicate` are the requested
778/// baseline, with the usual companions.
779pub const DEFAULT_TYPES: &[&str] = &[
780    "default",
781    "bug",
782    "feature",
783    "task",
784    "improvement",
785    "documentation",
786    "question",
787    "duplicate",
788    "chore",
789    "open",
790];
791
792/// A shipped accent color per default type (tix #1530).
793///
794/// The color names are the ones [`crate::ui::color::parse`] understands. A type
795/// absent here — and absent from `[type_colors]` — renders uncolored. `open` and
796/// `default` are deliberately neutral (gray/white) so a type chip does not
797/// compete with the priority and blocked markers beside it.
798pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
799    ("default", "gray"),
800    ("bug", "red"),
801    ("feature", "green"),
802    ("task", "blue"),
803    ("improvement", "cyan"),
804    ("documentation", "yellow"),
805    ("question", "magenta"),
806    ("duplicate", "dark_gray"),
807    ("chore", "dark_gray"),
808    ("open", "white"),
809];
810
811/// The `[layout]` section: configurable on-disk issue layout.
812///
813/// Tokens are validated at config load ([`crate::layout::validate`]). The
814/// defaults reproduce the documented layout — `{assignee}/{type}/{status}` for
815/// the directory, `{date}-{slug}.md` for the filename, moving files when a
816/// templated value changes.
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[serde(default, deny_unknown_fields)]
819pub struct LayoutConfig {
820    /// Directory template, relative to `issues_dir`.
821    pub directory: String,
822    /// Filename template; must end in `.md`.
823    pub filename: String,
824    /// Relocate an issue when a templated value changes (status/assignee/type).
825    pub move_on_change: bool,
826}
827
828impl Default for LayoutConfig {
829    fn default() -> Self {
830        LayoutConfig {
831            directory: "{assignee}/{type}/{status}".to_string(),
832            filename: "{date}-{slug}.md".to_string(),
833            move_on_change: true,
834        }
835    }
836}
837
838impl LayoutConfig {
839    /// Whether this is the default, for `skip_serializing_if`.
840    #[must_use]
841    pub fn is_default(&self) -> bool {
842        *self == Self::default()
843    }
844}
845
846impl Default for Config {
847    fn default() -> Self {
848        Config {
849            kanban: KanbanSection {
850                configurations: IndexMap::from([(
851                    "default".to_string(),
852                    BoardConfiguration::default(),
853                )]),
854            },
855            issues_dir: PathBuf::from("docs/issues"),
856            milestones_dir: None,
857            teams: IndexMap::new(),
858            cycles: IndexMap::new(),
859            templates: IndexMap::new(),
860            planning: PlanningConfig::default(),
861            categories: CategoryConfig::default(),
862            transitions: TransitionConfig::default(),
863            // The bare default has no inbox, and that stays true: the mechanism is
864            // opt-in, so a config with no `[triage]` section has no inbox and every
865            // `tix triage` command refuses, naming the section. What changed is the
866            // *starting point*: `tix init` writes the section (and the board columns it
867            // needs), so a new repository begins with an inbox rather than with a
868            // refusal. See [`crate::commands::init`].
869            triage: None,
870            editor: None,
871            viewer: None,
872            state: StateConfig::default(),
873            export: ExportConfig::default(),
874            agent: None,
875            keybindings: KeyBindings::default(),
876            custom_attributes: CustomAttributes::default(),
877            lint_foreign_repositories: true,
878            layout: LayoutConfig::default(),
879            types: None,
880            type_colors: IndexMap::new(),
881            tags_dir: None,
882            repos: IndexMap::new(),
883            parent_repo: None,
884        }
885    }
886}
887
888impl Config {
889    /// The configuration a **new repository** starts with — what `tix init` writes.
890    ///
891    /// Deliberately not [`Default`]: that is the fallback for a missing or partial
892    /// `tix.toml` (and the base hundreds of tests build fixtures on), and it stays
893    /// minimal. The *starting point* is a product decision: a new repository gets the
894    /// board [`starter_board`] lays out and a working `[triage]` section, so
895    /// `tix triage list` works on a fresh checkout instead of refusing and asking for
896    /// configuration first.
897    ///
898    /// The three keys match the board: unreviewed work sits in `triage`, an accept
899    /// lands in `todo`, a decline in `wontfix`. `deferred_status` is left unset
900    /// because it defaults to `entry_status`, and a default that is never written is a
901    /// default that cannot drift from the meaning it stands for.
902    #[must_use]
903    pub fn starter() -> Config {
904        let mut config = Config::default();
905        config
906            .kanban
907            .configurations
908            .insert("default".to_string(), starter_board());
909        config.triage = Some(crate::triage::TriageConfig {
910            entry_status: Some("triage".to_string()),
911            accepted_status: Some("todo".to_string()),
912            declined_status: Some("wontfix".to_string()),
913            deferred_status: None,
914        });
915        // `tix init` writes the default issue types explicitly, so a new project
916        // sees and can edit them; a repository without the key falls back to the
917        // same list at lint time.
918        config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
919        // Ship the tag directory explicitly (tix #1530), so a new project has
920        // somewhere to drop `docs/tags/*.md` without having to know the key.
921        config.tags_dir = Some(PathBuf::from("docs/tags"));
922        config
923    }
924
925    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
926    /// the `Config` error variant with a human message. Errors name the
927    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
928    pub(crate) fn validate(&self) -> Result<()> {
929        if self.issues_dir.as_os_str().is_empty() {
930            return Err(TixError::Config("`issues_dir` must not be blank".into()));
931        }
932        if self.milestones_dir().as_os_str().is_empty() {
933            return Err(TixError::Config(
934                "`milestones_dir` must not be blank".into(),
935            ));
936        }
937        if self.tags_dir().as_os_str().is_empty() {
938            return Err(TixError::Config("`tags_dir` must not be blank".into()));
939        }
940        validate_repos(&self.repos)?;
941        if self
942            .parent_repo
943            .as_deref()
944            .is_some_and(|p| p.as_os_str().is_empty())
945        {
946            return Err(TixError::Config("`parent_repo` must not be blank".into()));
947        }
948        // A blank `viewer` is a mistake with a clear spelling: the way to ask for
949        // the probe chain is `auto` (or omitting the key). Rejected here rather
950        // than silently treated as auto, so a typo cannot quietly change the
951        // meaning of a saved file.
952        if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
953            return Err(TixError::Config(
954                "`viewer` must not be blank; use `auto` for the default chain".into(),
955            ));
956        }
957        validate_planning(&self.planning)?;
958        crate::layout::validate(&self.layout)?;
959        validate_categories(&self.categories)?;
960        self.effective_keybindings()?;
961        if self.kanban.configurations.is_empty() {
962            return Err(TixError::Config(
963                "`[kanban]` must define at least one configuration".into(),
964            ));
965        }
966        for (name, board) in &self.kanban.configurations {
967            validate_board(name, board)?;
968            validate_category_map(name, board, &self.categories)?;
969        }
970        for (label, team) in &self.teams {
971            validate_team(label, team)?;
972        }
973        for (name, cycle) in &self.cycles {
974            validate_cycle(name, cycle)?;
975        }
976        for (name, template) in &self.templates {
977            validate_template(name, template)?;
978        }
979        // Last, deliberately. `[triage]` names columns from the active board
980        // *and* from that board's `category_map`, so it can only be judged once
981        // both are; running it earlier would hand `workflow::resolution` an
982        // unvalidated vocabulary, which is the `unreachable!` it documents
983        // (C-002).
984        crate::triage::config::validate(self)?;
985        Ok(())
986    }
987
988    /// Resolve sparse user overrides over the built-in TUI registry.
989    pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
990        KeyBindings::resolve(&self.keybindings)
991    }
992
993    /// Restore the canonical declaration order of named configurations,
994    /// which the TOML parser loses (it sorts table keys). The order comes
995    /// from `[kanban.<name>]` section headers in the source document.
996    fn restore_declaration_order(&mut self, contents: &str) {
997        crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
998        crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
999        crate::config::restore_section_order(&mut self.teams, contents, "teams");
1000        crate::config::restore_section_order(&mut self.templates, contents, "templates");
1001    }
1002
1003    /// Declared custom top-level frontmatter keys, lowercased for comparison.
1004    ///
1005    /// Lowercased because YAML keys are case-sensitive but nobody has ever
1006    /// intended `Titel` and `titel` to be different keys, and a suppression list
1007    /// that silently fails to match is worse than no list at all.
1008    pub fn custom_attributes(&self) -> Vec<String> {
1009        self.custom_attributes
1010            .keys
1011            .iter()
1012            .map(|k| k.trim().to_ascii_lowercase())
1013            .collect()
1014    }
1015
1016    /// The allowed issue types: the configured `types`, or [`DEFAULT_TYPES`]
1017    /// when unset. Compared case-insensitively by callers.
1018    #[must_use]
1019    pub fn types(&self) -> Vec<String> {
1020        self.types
1021            .clone()
1022            .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
1023    }
1024
1025    /// The pre-#1530 name for [`Self::types`], kept so existing call sites and
1026    /// tests read the same list under the old spelling.
1027    #[must_use]
1028    pub fn custom_types(&self) -> Vec<String> {
1029        self.types()
1030    }
1031
1032    /// The color name for issue `type`, from `[type_colors]` when set for it, else
1033    /// the shipped [`DEFAULT_TYPE_COLORS`] entry. `None` for a type with no color
1034    /// anywhere (an unknown type). Compared case-insensitively (tix #1530).
1035    #[must_use]
1036    pub fn type_color(&self, type_name: &str) -> Option<String> {
1037        let needle = type_name.trim().to_ascii_lowercase();
1038        self.type_colors
1039            .iter()
1040            .find(|(name, _)| name.to_ascii_lowercase() == needle)
1041            .map(|(_, color)| color.clone())
1042            .or_else(|| {
1043                DEFAULT_TYPE_COLORS
1044                    .iter()
1045                    .find(|(name, _)| *name == needle)
1046                    .map(|(_, color)| (*color).to_string())
1047            })
1048    }
1049
1050    /// The attribute list shown and edited in the Config view: the declared
1051    /// `[custom_attributes].keys`, or the built-in attributes tix models when
1052    /// none are declared. Mirrors [`Self::custom_types`] (`configured-or-defaults`).
1053    #[must_use]
1054    pub fn attributes(&self) -> Vec<String> {
1055        if self.custom_attributes.keys.is_empty() {
1056            crate::model::KNOWN_FRONTMATTER_KEYS
1057                .iter()
1058                .filter(|key| **key != "links")
1059                .map(|key| (*key).to_string())
1060                .collect()
1061        } else {
1062            self.custom_attributes.keys.clone()
1063        }
1064    }
1065
1066    /// The resolved milestones directory: the configured value, or the
1067    /// contract default `docs/milestones` when unset.
1068    pub fn milestones_dir(&self) -> &Path {
1069        self.milestones_dir
1070            .as_deref()
1071            .unwrap_or(Path::new("docs/milestones"))
1072    }
1073
1074    /// The resolved tags directory: the configured `tags_dir`, or the contract
1075    /// default `docs/tags` when unset (tix #1530).
1076    pub fn tags_dir(&self) -> &Path {
1077        self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
1078    }
1079
1080    /// The child repositories this project coordinates (`[repos]`, tix #1529).
1081    pub fn repos(&self) -> &IndexMap<String, PathBuf> {
1082        &self.repos
1083    }
1084
1085    /// The parent repository, when this project is a managed child (tix #1529).
1086    pub fn parent_repo(&self) -> Option<&Path> {
1087        self.parent_repo.as_deref()
1088    }
1089
1090    /// Absolutize the paths that are resolved against the project root:
1091    /// an explicitly configured `tags_dir`, every `[repos]` entry, and
1092    /// `parent_repo` (tix #1529/#1530).
1093    ///
1094    /// Only a **relative** configured value is rewritten, so an unset key stays
1095    /// `None` (and is not materialized on save) and an absolute one is left
1096    /// alone. `issues_dir`/`milestones_dir` are resolved elsewhere; this covers
1097    /// the newer keys so the TUI can spawn `tix` in a child from any working
1098    /// directory.
1099    pub fn resolve_repo_paths(&mut self, base: &Path) {
1100        if let Some(path) = self.tags_dir.as_ref() {
1101            if path.is_relative() {
1102                self.tags_dir = Some(base.join(path));
1103            }
1104        }
1105        for path in self.repos.values_mut() {
1106            if path.is_relative() {
1107                *path = base.join(&*path);
1108            }
1109        }
1110        if let Some(path) = self.parent_repo.clone() {
1111            if path.is_relative() {
1112                self.parent_repo = Some(base.join(path));
1113            }
1114        }
1115    }
1116
1117    /// The configuration used by the CLI and other single-config surfaces:
1118    /// the one named `default` if present, else the first declared.
1119    pub fn default_config(&self) -> &BoardConfiguration {
1120        self.kanban
1121            .configurations
1122            .get("default")
1123            .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
1124            .expect("validate() guarantees at least one configuration")
1125    }
1126
1127    /// Named configurations in declaration order (tab order = declaration
1128    /// order, D-02).
1129    pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
1130        self.kanban
1131            .configurations
1132            .iter()
1133            .map(|(name, board)| (name.as_str(), board))
1134    }
1135
1136    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
1137    /// every cleartext token read from the file.
1138    ///
1139    /// Parse and validation failures surface as [`TixError::Config`] with
1140    /// the file's path and (where the TOML parser reports it) line.
1141    pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
1142        let contents = std::fs::read_to_string(path)
1143            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1144        let mut config: Config = toml::from_str(&contents)
1145            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1146        config.restore_declaration_order(&contents);
1147        // File stores win over the legacy tables whenever their directory
1148        // exists. Absent directories mean an existing project: its
1149        // `[kanban.*]`/`[cycles.*]` tables load exactly as before.
1150        let root = path
1151            .parent()
1152            .filter(|p| !p.as_os_str().is_empty())
1153            .unwrap_or_else(|| Path::new("."));
1154        let board_store = BoardStore::new(root);
1155        if board_store.exists() {
1156            let boards = board_store
1157                .list()
1158                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1159            if !boards.is_empty() {
1160                config.kanban = KanbanSection {
1161                    configurations: boards.into_iter().collect(),
1162                };
1163            }
1164        }
1165        let cycle_store = CycleStore::new(root);
1166        if cycle_store.exists() {
1167            let cycles = cycle_store
1168                .list()
1169                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1170            config.cycles = cycles
1171                .into_iter()
1172                .map(|record| (record.name.clone(), record.into()))
1173                .collect();
1174        }
1175        if config.kanban.configurations.is_empty() {
1176            config.kanban = KanbanSection {
1177                configurations: IndexMap::from([(
1178                    "default".to_string(),
1179                    BoardConfiguration::default(),
1180                )]),
1181            };
1182        }
1183        config.validate().map_err(|e| match e {
1184            TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
1185            other => other,
1186        })?;
1187        for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
1188            .into_iter()
1189            .flatten()
1190        {
1191            if remote.token.is_some() {
1192                sink(
1193                    "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
1194                );
1195            }
1196        }
1197        Ok(config)
1198    }
1199
1200    /// Load the config for a project root: `tix.toml` at the root if
1201    /// present, otherwise the defaults. An absent file is *not* an error —
1202    /// the CLI layer decides whether to nag about `tix init`.
1203    pub fn load(project_root: &Path) -> Result<Config> {
1204        let path = project_root.join("tix.toml");
1205        if !path.is_file() {
1206            return Ok(Config::default());
1207        }
1208        Self::load_with_warning(&path, |w| eprintln!("{w}"))
1209    }
1210
1211    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
1212    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
1213    pub fn project_root(cwd: &Path) -> Option<PathBuf> {
1214        let mut dir = Some(cwd.to_path_buf());
1215        while let Some(current) = dir {
1216            if current.join("tix.toml").is_file() {
1217                return Some(current);
1218            }
1219            dir = current.parent().map(Path::to_path_buf);
1220        }
1221        None
1222    }
1223
1224    /// A copy of this config with every token stripped, for writing.
1225    fn sanitized_for_disk(&self) -> Config {
1226        let mut copy = self.clone();
1227        if let Some(gitlab) = copy.export.gitlab.as_mut() {
1228            gitlab.token = None;
1229        }
1230        if let Some(linear) = copy.export.linear.as_mut() {
1231            linear.token = None;
1232        }
1233        copy
1234    }
1235
1236    /// Write `tix.toml` atomically (temp file + rename in the same
1237    /// directory, with an `fsync` before the rename — NFR-006).
1238    ///
1239    /// Tokens are never written to disk, so a round-trip `load` → `save`
1240    /// drops any cleartext token (expected: prefer env vars).
1241    pub fn save(&self, path: &Path) -> Result<()> {
1242        let dir = match path.parent() {
1243            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
1244            _ => PathBuf::from("."),
1245        };
1246        std::fs::create_dir_all(&dir)?;
1247
1248        // A file store that exists is authoritative for its records: write
1249        // them there and leave them out of `tix.toml`. Every other setting
1250        // still round-trips through the TOML file.
1251        let board_store = BoardStore::new(&dir);
1252        let board_store_active = board_store.exists();
1253        if board_store_active {
1254            for (name, board) in &self.kanban.configurations {
1255                let filename = format!("{}.md", crate::slug::slugify(name));
1256                let body = RecordStore::new(board_store.dir()).body_of(&filename);
1257                board_store.save(name, board, body.as_deref())?;
1258            }
1259        }
1260        let cycle_store = CycleStore::new(&dir);
1261        let cycle_store_active = cycle_store.exists();
1262        if cycle_store_active {
1263            for (name, cycle) in &self.cycles {
1264                if is_implicit_default(name, cycle) {
1265                    continue;
1266                }
1267                let filename = format!("{}.md", crate::slug::slugify(name));
1268                let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
1269                cycle_store.save(name, cycle, body.as_deref())?;
1270            }
1271        }
1272
1273        let mut disk = self.sanitized_for_disk();
1274        if board_store_active {
1275            disk.kanban = KanbanSection::default();
1276        }
1277        if cycle_store_active {
1278            disk.cycles.clear();
1279        }
1280        let contents = toml::to_string_pretty(&disk)
1281            .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
1282
1283        let file_name = path
1284            .file_name()
1285            .and_then(|n| n.to_str())
1286            .unwrap_or("tix.toml");
1287        let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
1288
1289        let write_result = (|| -> std::io::Result<()> {
1290            let mut f = std::fs::File::create(&tmp)?;
1291            use std::io::Write;
1292            f.write_all(contents.as_bytes())?;
1293            f.sync_all()?;
1294            Ok(())
1295        })();
1296        if let Err(e) = write_result {
1297            let _ = std::fs::remove_file(&tmp);
1298            return Err(TixError::Io(e));
1299        }
1300
1301        if let Err(e) = std::fs::rename(&tmp, path) {
1302            let _ = std::fs::remove_file(&tmp);
1303            return Err(TixError::Io(e));
1304        }
1305        Ok(())
1306    }
1307}
1308
1309/// Reorder a named-config `IndexMap` to the declaration order of its
1310/// `[<section>.<name>]` section headers in the source document (D-02:
1311/// declaration order = tab order). The `toml` crate sorts table keys on
1312/// deserialize, so the parser's order is not trustworthy; this returns to the
1313/// document's byte order. Applies to `[kanban.*]`, `[cycles.*]` and
1314/// `[teams.*]`. Names are unquoted when the header used a quoted key (e.g.
1315/// `[cycles."Cycle 1"]`).
1316fn restore_section_order<V: Clone>(map: &mut IndexMap<String, V>, contents: &str, section: &str) {
1317    let mut order: Vec<String> = Vec::new();
1318    for line in contents.lines() {
1319        let line = line.trim();
1320        if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
1321            && let Some((head, key)) = rest.split_once('.')
1322            && head.trim() == section
1323            && !key.trim().contains('.')
1324            && let Some(name) = unquote_key(key.trim())
1325            && !order.iter().any(|n| n == name)
1326        {
1327            order.push(name.to_string());
1328        }
1329    }
1330    if order.is_empty() {
1331        return;
1332    }
1333    let mut ordered = IndexMap::new();
1334    for name in &order {
1335        if let Some(value) = map.shift_remove(name) {
1336            ordered.insert(name.clone(), value);
1337        }
1338    }
1339    ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
1340    *map = ordered;
1341}
1342
1343/// Strip a single pair of surrounding single or double quotes from a TOML
1344/// table-header key, when present.
1345fn unquote_key(key: &str) -> Option<&str> {
1346    if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
1347        Some(inner)
1348    } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
1349        Some(inner)
1350    } else {
1351        Some(key)
1352    }
1353}
1354
1355/// Validation for the `[planning]` section (NFR-003): the counter must be
1356/// positive once the section is present.
1357fn validate_planning(planning: &PlanningConfig) -> Result<()> {
1358    if planning.cycle_number == 0 {
1359        return Err(TixError::Config(
1360            "`planning.cycle_number` must be at least 1".into(),
1361        ));
1362    }
1363    Ok(())
1364}
1365
1366/// `[repos]`: a name and a path, neither blank (tix #1529).
1367///
1368/// The depth rule is structural rather than a check here: a parent lists only
1369/// its own direct children, and a child reaches the parent through
1370/// `parent_repo`, so a two-level chain is two processes, never a nested walk.
1371fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
1372    for (name, path) in repos {
1373        if name.trim().is_empty() {
1374            return Err(TixError::Config(
1375                "`[repos]` entry names must not be blank".into(),
1376            ));
1377        }
1378        if path.as_os_str().is_empty() {
1379            return Err(TixError::Config(format!(
1380                "`repos.{name}` must name the repository path"
1381            )));
1382        }
1383    }
1384    Ok(())
1385}
1386
1387/// Per-team validation (NFR-003): non-blank label, non-empty members, unique
1388/// member identities.
1389fn validate_team(label: &str, team: &Team) -> Result<()> {
1390    if label.trim().is_empty() {
1391        return Err(TixError::Config("`teams.<label>` must not be blank".into()));
1392    }
1393    if team.members.is_empty() {
1394        return Err(TixError::Config(format!(
1395            "`teams.{label}.members` must not be empty"
1396        )));
1397    }
1398    let mut seen: HashSet<&str> = HashSet::new();
1399    for member in &team.members {
1400        if !seen.insert(member.as_str()) {
1401            return Err(TixError::Config(format!(
1402                "`teams.{label}.members` must be unique; found duplicate `{member}`"
1403            )));
1404        }
1405    }
1406    Ok(())
1407}
1408
1409/// Per-cycle validation (NFR-003): non-blank name, parseable dates, and
1410/// `due_date >= start_date` when both are present.
1411fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
1412    if name.trim().is_empty() {
1413        return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
1414    }
1415    if let Some(start) = &cycle.start_date {
1416        if !crate::model::validate_date(start) {
1417            return Err(TixError::Config(format!(
1418                "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
1419            )));
1420        }
1421    }
1422    if let Some(due) = &cycle.due_date {
1423        if !crate::model::validate_date(due) {
1424            return Err(TixError::Config(format!(
1425                "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
1426            )));
1427        }
1428        if let Some(start) = &cycle.start_date
1429            && due.as_str() < start.as_str()
1430        {
1431            return Err(TixError::Config(format!(
1432                "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
1433            )));
1434        }
1435    }
1436    Ok(())
1437}
1438
1439/// Per-template validation for `[templates.<name>]` (`tui-new-issue-mode`).
1440///
1441/// The rules exist because of how a template reaches the form: `labels`,
1442/// `cycles` and `milestones` are joined with `,` into their single text rows and
1443/// split again at submit, so a value that itself contains a comma would silently
1444/// become two values the user never typed. A blank label is refused for the same
1445/// reason the form filters blanks — a template that "sets" an empty label sets
1446/// nothing. Dates follow the cycle rules verbatim.
1447fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
1448    if name.trim().is_empty() {
1449        return Err(TixError::Config(
1450            "`templates` must not contain a blank name".into(),
1451        ));
1452    }
1453    for (field, values) in [
1454        ("labels", &template.labels),
1455        ("cycles", &template.cycles),
1456        ("milestones", &template.milestones),
1457    ] {
1458        for value in values {
1459            if value.trim().is_empty() {
1460                return Err(TixError::Config(format!(
1461                    "`templates.{name}.{field}` must not contain a blank value"
1462                )));
1463            }
1464            if value.contains(',') {
1465                return Err(TixError::Config(format!(
1466                    "`templates.{name}.{field}` value `{value}` must not contain a comma \
1467                     — the form joins these with `,`"
1468                )));
1469            }
1470        }
1471    }
1472    if let Some(start) = &template.start_date
1473        && !crate::model::validate_date(start)
1474    {
1475        return Err(TixError::Config(format!(
1476            "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
1477        )));
1478    }
1479    if let Some(due) = &template.due_date {
1480        if !crate::model::validate_date(due) {
1481            return Err(TixError::Config(format!(
1482                "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
1483            )));
1484        }
1485        if let Some(start) = &template.start_date
1486            && due.as_str() < start.as_str()
1487        {
1488            return Err(TixError::Config(format!(
1489                "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
1490            )));
1491        }
1492    }
1493    Ok(())
1494}
1495
1496/// Per-configuration validation (NFR-003): non-empty unique non-blank
1497/// columns, `done_column` ⊆ columns, `hidden_columns` ⊆ columns.
1498/// The `[categories]` section's own rules (contract §2, §4).
1499///
1500/// Both are refusals of a *default*, which is the escalation the contract names:
1501/// a default naming a category outside the vocabulary is a typo, and a default
1502/// that resolves to `completed` would silently mark every unmapped column done.
1503fn validate_categories(categories: &CategoryConfig) -> Result<()> {
1504    if categories.order.is_empty() {
1505        return Err(TixError::Config(
1506            "`categories.order` must not be empty — it is the global vocabulary".into(),
1507        ));
1508    }
1509    for key in &categories.order {
1510        if !CATEGORY_KEYS.contains(&key.as_str()) {
1511            return Err(TixError::Config(format!(
1512                "unknown_category: `categories.order` names `{key}`, which is not in the \
1513                 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
1514                 board maps columns into it and does not define it.",
1515                CATEGORY_KEYS.join(", ")
1516            )));
1517        }
1518    }
1519    if !categories.contains(&categories.default) {
1520        return Err(TixError::Config(format!(
1521            "unknown_category: `categories.default` is `{}`, which is not in \
1522             `categories.order`",
1523            categories.default
1524        )));
1525    }
1526    if categories.default == COMPLETE_CATEGORY {
1527        return Err(TixError::Config(format!(
1528            "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
1529             without an explicit mapping would resolve to complete, which changes \
1530             completion meaning by accident. Map the completing column explicitly \
1531             instead."
1532        )));
1533    }
1534    Ok(())
1535}
1536
1537/// Per-board mapping validation (contract §3).
1538///
1539/// A board either has a completion category or is an explicit configuration
1540/// error; there is no silent "nothing completes". A *missing* mapping is never an
1541/// error — it resolves by the safe default — because erroring would reject every
1542/// existing custom configuration (NFR-003, C-007).
1543fn validate_category_map(
1544    name: &str,
1545    board: &BoardConfiguration,
1546    categories: &CategoryConfig,
1547) -> Result<()> {
1548    for (column, key) in &board.category_map {
1549        if !board.columns.iter().any(|c| c == column) {
1550            return Err(TixError::Config(format!(
1551                "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
1552                 `{column}`, which is not one of the board's columns ({})",
1553                board.columns.join(", ")
1554            )));
1555        }
1556        if !categories.contains(key) {
1557            return Err(TixError::Config(format!(
1558                "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
1559                 `{key}`, which is not in the vocabulary ({})",
1560                categories.order.join(", ")
1561            )));
1562        }
1563    }
1564    // Only a board that *declares* a map is held to naming a completion column. A
1565    // board with no map gets `done_column` -> `completed` in `category_for`, so
1566    // counting completions here would reject every legacy config.
1567    if board.category_map.is_empty() {
1568        return Ok(());
1569    }
1570    let completers: Vec<&String> = board
1571        .category_map
1572        .iter()
1573        .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
1574        .map(|(column, _)| column)
1575        .collect();
1576    match completers.as_slice() {
1577        [] => Err(TixError::Config(format!(
1578            "missing_completion_category: `kanban.{name}.category_map` maps no column \
1579             to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
1580             is ever complete — map exactly one column to it.",
1581            board.category_map.len()
1582        ))),
1583        [_only] => Ok(()),
1584        many => Err(TixError::Config(format!(
1585            "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
1586             ({}); exactly one column may complete work",
1587            many.len(),
1588            many.iter()
1589                .map(|c| c.as_str())
1590                .collect::<Vec<_>>()
1591                .join(", ")
1592        ))),
1593    }
1594}
1595
1596fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
1597    if board.columns.is_empty() {
1598        return Err(TixError::Config(format!(
1599            "`kanban.{name}.columns` must not be empty"
1600        )));
1601    }
1602    if board.columns.iter().any(|c| c.trim().is_empty()) {
1603        return Err(TixError::Config(format!(
1604            "`kanban.{name}.columns` must not contain blank names"
1605        )));
1606    }
1607    let mut seen: HashSet<&str> = HashSet::new();
1608    for column in &board.columns {
1609        if !seen.insert(column.as_str()) {
1610            return Err(TixError::Config(format!(
1611                "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
1612            )));
1613        }
1614    }
1615    if let Some(done) = &board.done_column
1616        && !board.columns.contains(done)
1617    {
1618        return Err(TixError::Config(format!(
1619            "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
1620        )));
1621    }
1622    for hidden in &board.hidden_columns {
1623        if !board.columns.contains(hidden) {
1624            return Err(TixError::Config(format!(
1625                "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
1626            )));
1627        }
1628    }
1629    if board.title_lines == 0 {
1630        return Err(TixError::Config(format!(
1631            "`kanban.{name}.title_lines` must be at least 1"
1632        )));
1633    }
1634    Ok(())
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639    use super::*;
1640    use std::path::PathBuf;
1641    use tempfile::TempDir;
1642
1643    /// The canonical legacy `tix.toml`: `toml`'s pretty form, with no
1644    /// board field that already equals the loader's value and no empty
1645    /// `[export]`. A save must reproduce it byte-for-byte (#1415).
1646    const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
1647
1648[kanban.default]
1649columns = [
1650    "todo",
1651    "in progress",
1652    "in review",
1653    "done",
1654]
1655"#;
1656
1657    fn write_toml(dir: &Path, contents: &str) -> PathBuf {
1658        let path = dir.join("tix.toml");
1659        std::fs::write(&path, contents).unwrap();
1660        path
1661    }
1662
1663    #[test]
1664    fn starter_config_carries_a_working_inbox() {
1665        let config = Config::starter();
1666        config
1667            .validate()
1668            .expect("the config `tix init` writes must load");
1669
1670        let board = config.default_config();
1671        for column in [
1672            "triage",
1673            "todo",
1674            "in progress",
1675            "in review",
1676            "done",
1677            "wontfix",
1678        ] {
1679            assert!(
1680                board.columns.iter().any(|c| c == column),
1681                "the starter board is missing `{column}`: {:?}",
1682                board.columns
1683            );
1684        }
1685
1686        let triage = config
1687            .triage
1688            .as_ref()
1689            .expect("the starter config has an inbox");
1690        assert_eq!(triage.entry_status.as_deref(), Some("triage"));
1691        assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
1692        assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
1693        // Left unset: the default *is* the entry column, and a default that is never
1694        // written cannot drift from the meaning it stands for.
1695        assert_eq!(triage.effective_deferred_status(), Some("triage"));
1696
1697        // The one mapping triage requires, plus the completion column.
1698        assert_eq!(
1699            board.category_map.get("wontfix").map(String::as_str),
1700            Some("canceled")
1701        );
1702        assert_eq!(
1703            board.category_map.get("done").map(String::as_str),
1704            Some("completed")
1705        );
1706        // The working columns stay **unmapped** on purpose: declaring a map makes the
1707        // board an explicit policy, and adjacency then refuses a *backwards* move with
1708        // no override to offer — `in progress -> todo` is a move people make daily.
1709        // Left out, they all resolve to the same safe default, so movement among them
1710        // stays free while `done`/`wontfix` remain terminal facts.
1711        for column in ["triage", "todo", "in progress", "in review"] {
1712            assert!(
1713                !board.category_map.contains_key(column),
1714                "`{column}` must stay unmapped, or adjacency will refuse work moving back"
1715            );
1716        }
1717    }
1718
1719    #[test]
1720    fn repos_and_parent_repo_load_round_trip_and_resolve() {
1721        let tmp = TempDir::new().unwrap();
1722        // `parent_repo` must precede the `[repos]` table, or TOML would file it
1723        // inside that table.
1724        let raw = "issues_dir = \"docs/issues\"\n\
1725                   parent_repo = \"../parent\"\n\n\
1726                   [repos]\n\
1727                   tix = \"../tix\"\n\
1728                   website = \"child/website\"\n";
1729        let path = write_toml(tmp.path(), raw);
1730        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1731        assert_eq!(cfg.repos.len(), 2);
1732        assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
1733        assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
1734        assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
1735
1736        // A save keeps both, and they re-load identically.
1737        cfg.save(&path).unwrap();
1738        let reread = Config::load_with_warning(&path, |_| {}).unwrap();
1739        assert_eq!(reread.repos, cfg.repos, "repos round-trip");
1740        assert_eq!(
1741            reread.parent_repo(),
1742            cfg.parent_repo(),
1743            "parent round-trips"
1744        );
1745
1746        // Relative paths resolve against a base, absolute ones are left alone.
1747        let mut resolved = cfg.clone();
1748        resolved.resolve_repo_paths(Path::new("/root"));
1749        assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
1750        assert_eq!(
1751            resolved.repos["website"],
1752            PathBuf::from("/root/child/website")
1753        );
1754        assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
1755        let mut absolute = Config::default();
1756        absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
1757        absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
1758        absolute.resolve_repo_paths(Path::new("/root"));
1759        assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
1760        assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
1761    }
1762
1763    #[test]
1764    fn types_rename_and_type_colors() {
1765        let tmp = TempDir::new().unwrap();
1766        // The pre-#1530 key still loads (serde alias) but serializes as `types`.
1767        let path = write_toml(
1768            tmp.path(),
1769            "issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
1770        );
1771        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1772        assert_eq!(cfg.types(), vec!["bug", "epic"]);
1773        cfg.save(&path).unwrap();
1774        let after = std::fs::read_to_string(&path).unwrap();
1775        assert!(after.contains("types = ["), "{after}");
1776        assert!(!after.contains("custom_types"), "{after}");
1777
1778        // Every shipped type has a default color; `[type_colors]` overrides it,
1779        // matched case-insensitively. An unknown type has none.
1780        let defaults = Config::default();
1781        assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
1782        assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
1783        assert_eq!(defaults.type_color("nonsense"), None);
1784        let mut overridden = Config::default();
1785        overridden.type_colors.insert("bug".into(), "cyan".into());
1786        assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
1787    }
1788
1789    #[test]
1790    fn a_blank_repo_path_is_rejected() {
1791        let tmp = TempDir::new().unwrap();
1792        let path = write_toml(
1793            tmp.path(),
1794            "issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
1795        );
1796        let err = Config::load_with_warning(&path, |_| {})
1797            .unwrap_err()
1798            .to_string();
1799        assert!(err.contains("repos.tix"), "got {err}");
1800    }
1801
1802    #[test]
1803    fn an_empty_repos_section_is_not_written() {
1804        let tmp = TempDir::new().unwrap();
1805        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1806        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1807        cfg.save(&path).unwrap();
1808        let after = std::fs::read_to_string(&path).unwrap();
1809        assert!(
1810            !after.contains("[repos]") && !after.contains("parent_repo"),
1811            "an untouched config gains no repos keys on save: {after}"
1812        );
1813    }
1814
1815    #[test]
1816    fn defaults_match_contract() {
1817        let c = Config::default();
1818        let board = c.default_config();
1819        assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
1820        assert_eq!(board.done_column.as_deref(), Some("done"));
1821        assert_eq!(board.hidden_columns, Vec::<String>::new());
1822        assert!(!board.show_mismatch);
1823        assert!(board.wrap_titles);
1824        assert_eq!(board.title_lines, 3);
1825        assert!(board.card_order.is_empty());
1826        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1827        assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
1828        assert_eq!(c.editor, None);
1829        assert_eq!(c.state.next_number, 0);
1830        assert_eq!(c.export.gitlab, None);
1831        assert_eq!(c.export.linear, None);
1832    }
1833
1834    #[test]
1835    fn load_returns_defaults_when_file_absent() {
1836        let tmp = TempDir::new().unwrap();
1837        let cfg = Config::load(tmp.path()).unwrap();
1838        assert_eq!(cfg.state.next_number, 0);
1839        assert_eq!(cfg.default_config().columns.len(), 4);
1840    }
1841
1842    #[test]
1843    fn load_synthesizes_default_config_when_kanban_section_missing() {
1844        let tmp = TempDir::new().unwrap();
1845        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1846        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1847        assert_eq!(cfg.kanban.configurations.len(), 1);
1848        assert!(cfg.kanban.configurations.contains_key("default"));
1849        assert_eq!(cfg.default_config().columns.len(), 4);
1850    }
1851
1852    #[test]
1853    fn load_parses_state_and_keeps_editor_and_remote_targets() {
1854        let tmp = TempDir::new().unwrap();
1855        let path = write_toml(
1856            tmp.path(),
1857            r#"issues_dir = "tickets"
1858editor = "nvim"
1859
1860[kanban.default]
1861columns = ["todo", "done"]
1862done_column = "done"
1863
1864[state]
1865next_number = 17
1866
1867[export.gitlab]
1868project = "acme/tix"
1869"#,
1870        );
1871        let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
1872        assert_eq!(
1873            cfg.kanban.configurations.get("default").unwrap().columns,
1874            ["todo", "done"]
1875        );
1876        assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
1877        assert_eq!(cfg.editor.as_deref(), Some("nvim"));
1878        assert_eq!(cfg.state.next_number, 17);
1879        assert_eq!(
1880            cfg.export.gitlab.unwrap().project.as_deref(),
1881            Some("acme/tix")
1882        );
1883        assert_eq!(cfg.export.linear, None);
1884    }
1885
1886    #[test]
1887    fn viewer_is_parsed_and_blank_is_rejected() {
1888        let tmp = TempDir::new().unwrap();
1889        let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
1890        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1891        assert_eq!(cfg.viewer.as_deref(), Some("less"));
1892
1893        let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"  \"\n");
1894        let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
1895        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1896        assert!(err.to_string().contains("viewer"), "{err}");
1897    }
1898
1899    #[test]
1900    fn viewer_is_omitted_from_the_file_when_unset() {
1901        let dir = TempDir::new().unwrap();
1902        let path = dir.path().join("tix.toml");
1903        let mut config = Config::default();
1904        config.save(&path).unwrap();
1905        assert!(
1906            !std::fs::read_to_string(&path).unwrap().contains("viewer"),
1907            "an unset viewer writes no key"
1908        );
1909
1910        config.viewer = Some("less".to_string());
1911        config.save(&path).unwrap();
1912        assert!(
1913            std::fs::read_to_string(&path)
1914                .unwrap()
1915                .contains(r#"viewer = "less""#),
1916            "a set viewer round-trips"
1917        );
1918    }
1919
1920    #[test]
1921    fn empty_columns_rejected_as_config_error() {
1922        let tmp = TempDir::new().unwrap();
1923        let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
1924        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1925        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1926        assert!(err.to_string().contains("kanban.scratch.columns"));
1927    }
1928
1929    #[test]
1930    fn duplicate_columns_rejected_as_config_error() {
1931        let tmp = TempDir::new().unwrap();
1932        let path = write_toml(
1933            tmp.path(),
1934            "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
1935        );
1936        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1937        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1938        assert!(err.to_string().contains("duplicate"));
1939    }
1940
1941    #[test]
1942    fn blank_column_name_rejected() {
1943        let tmp = TempDir::new().unwrap();
1944        let path = write_toml(
1945            tmp.path(),
1946            "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
1947        );
1948        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1949        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1950    }
1951
1952    #[test]
1953    fn done_column_must_be_in_columns() {
1954        let tmp = TempDir::new().unwrap();
1955        let path = write_toml(
1956            tmp.path(),
1957            "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
1958        );
1959        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1960        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1961        assert!(err.to_string().contains("kanban.scratch.done_column"));
1962    }
1963
1964    #[test]
1965    fn hidden_columns_must_be_subset_of_columns() {
1966        let tmp = TempDir::new().unwrap();
1967        let path = write_toml(
1968            tmp.path(),
1969            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
1970        );
1971        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1972        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1973        assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
1974    }
1975
1976    #[test]
1977    fn hidden_done_column_loads_fine() {
1978        let tmp = TempDir::new().unwrap();
1979        let path = write_toml(
1980            tmp.path(),
1981            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
1982        );
1983        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1984        let board = cfg.kanban.configurations.get("scratch").unwrap();
1985        assert_eq!(board.done_column.as_deref(), Some("done"));
1986        assert_eq!(board.hidden_columns, ["done"]);
1987    }
1988
1989    #[test]
1990    fn legacy_top_level_keys_rejected() {
1991        let tmp = TempDir::new().unwrap();
1992        let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
1993        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1994        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1995        assert!(err.to_string().contains("tix.toml"));
1996        assert!(err.to_string().contains("columns"));
1997    }
1998
1999    #[test]
2000    fn token_warning_fires_once_per_remote_with_token() {
2001        let tmp = TempDir::new().unwrap();
2002        let path = write_toml(
2003            tmp.path(),
2004            r#"[export.gitlab]
2005project = "a/b"
2006token = "gl-secret"
2007
2008[export.linear]
2009team = "Eng"
2010token = "lin-secret"
2011"#,
2012        );
2013        let mut warnings: Vec<String> = Vec::new();
2014        let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
2015        assert_eq!(warnings.len(), 2);
2016        assert!(warnings[0].contains("GITLAB_TOKEN"));
2017        assert!(warnings[1].contains("LINEAR_TOKEN"));
2018        // token values must never leak into the warning
2019        for w in &warnings {
2020            assert!(!w.contains("secret"));
2021        }
2022        assert_eq!(
2023            cfg.export.gitlab.unwrap().token.as_deref(),
2024            Some("gl-secret")
2025        );
2026    }
2027
2028    #[test]
2029    fn no_token_means_no_warning() {
2030        let tmp = TempDir::new().unwrap();
2031        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
2032        let mut fired = false;
2033        Config::load_with_warning(&path, |_| fired = true).unwrap();
2034        assert!(!fired);
2035    }
2036
2037    #[test]
2038    fn save_never_writes_the_state_section() {
2039        let tmp = TempDir::new().unwrap();
2040        let path = tmp.path().join("tix.toml");
2041        // Simulate a legacy load: `[state].next_number` is kept in memory
2042        // but must never be written back out (issue #52).
2043        let mut cfg = Config::default();
2044        cfg.state.next_number = 42;
2045        cfg.save(&path).unwrap();
2046
2047        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2048        assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
2049        assert_eq!(
2050            reloaded.default_config().columns,
2051            cfg.default_config().columns
2052        );
2053
2054        let on_disk = std::fs::read_to_string(&path).unwrap();
2055        assert!(
2056            !on_disk.contains("next_number"),
2057            "next_number leaked into tix.toml:\n{on_disk}"
2058        );
2059        assert!(
2060            !on_disk.contains("[state]"),
2061            "state section written:\n{on_disk}"
2062        );
2063    }
2064
2065    #[test]
2066    fn multi_config_roundtrip_preserves_declaration_order() {
2067        let tmp = TempDir::new().unwrap();
2068        let path = write_toml(
2069            tmp.path(),
2070            r#"issues_dir = "docs/issues"
2071
2072[kanban.release]
2073columns = ["planned", "in progress", "done"]
2074done_column = "done"
2075show_mismatch = true
2076
2077[kanban.personal]
2078columns = ["todo", "done"]
2079done_column = "done"
2080hidden_columns = ["done"]
2081"#,
2082        );
2083        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2084        let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
2085        assert_eq!(names, ["release", "personal"]);
2086
2087        // default_config falls back to the first declared when no "default".
2088        assert_eq!(
2089            cfg.default_config().columns,
2090            ["planned", "in progress", "done"]
2091        );
2092
2093        cfg.save(&path).unwrap();
2094        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2095        assert_eq!(
2096            cfg, reloaded,
2097            "order and values survive save/load round trip"
2098        );
2099        let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
2100        assert_eq!(names2, ["release", "personal"]);
2101    }
2102
2103    #[test]
2104    fn save_never_serializes_tokens_back_out() {
2105        let tmp = TempDir::new().unwrap();
2106        let path = tmp.path().join("tix.toml");
2107        let mut cfg = Config::default();
2108        cfg.export.gitlab = Some(RemoteConfig {
2109            token: Some("secret".into()),
2110            project: Some("a/b".into()),
2111            ..RemoteConfig::default()
2112        });
2113        cfg.save(&path).unwrap();
2114
2115        let on_disk = std::fs::read_to_string(&path).unwrap();
2116        assert!(
2117            !on_disk.contains("secret"),
2118            "token leaked to disk:\n{on_disk}"
2119        );
2120        assert!(
2121            on_disk.contains("a/b"),
2122            "non-secret fields survive:\n{on_disk}"
2123        );
2124
2125        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2126        assert_eq!(reloaded.export.gitlab.unwrap().token, None);
2127    }
2128
2129    #[test]
2130    fn save_is_atomic_and_leaves_no_temp_file() {
2131        let tmp = TempDir::new().unwrap();
2132        let path = tmp.path().join("tix.toml");
2133        Config::default().save(&path).unwrap();
2134        let entries: Vec<_> = std::fs::read_dir(tmp.path())
2135            .unwrap()
2136            .filter_map(|e| e.ok())
2137            .collect();
2138        assert_eq!(entries.len(), 1);
2139        assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
2140    }
2141
2142    #[test]
2143    fn default_loadwrites_are_idempotent() {
2144        let tmp = TempDir::new().unwrap();
2145        let path = tmp.path().join("tix.toml");
2146        let cfg = Config::default();
2147        cfg.save(&path).unwrap();
2148        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2149        assert_eq!(cfg, reloaded);
2150    }
2151
2152    #[test]
2153    fn effective_done_column_falls_back_to_last() {
2154        let board = BoardConfiguration {
2155            columns: vec!["todo".into(), "later".into()],
2156            done_column: None,
2157            ..Default::default()
2158        };
2159        assert_eq!(board.effective_done_column(), Some("later"));
2160
2161        let with_done = BoardConfiguration {
2162            columns: vec!["todo".into(), "done".into(), "later".into()],
2163            done_column: None,
2164            ..Default::default()
2165        };
2166        assert_eq!(with_done.effective_done_column(), Some("done"));
2167    }
2168
2169    #[test]
2170    fn project_root_walks_up_the_tree() {
2171        let tmp = TempDir::new().unwrap();
2172        let nested = tmp.path().join("a").join("b");
2173        std::fs::create_dir_all(&nested).unwrap();
2174        std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
2175
2176        assert_eq!(
2177            Config::project_root(&nested),
2178            Some(tmp.path().to_path_buf())
2179        );
2180        assert_eq!(
2181            Config::project_root(tmp.path()),
2182            Some(tmp.path().to_path_buf())
2183        );
2184    }
2185
2186    #[test]
2187    fn project_root_is_none_when_no_toml_exists() {
2188        let tmp = TempDir::new().unwrap();
2189        assert_eq!(Config::project_root(tmp.path()), None);
2190    }
2191
2192    #[test]
2193    fn order_by_parses_all_names_and_defaults_to_desc() {
2194        let tmp = TempDir::new().unwrap();
2195        for name in ["created_at_desc", "created_at_asc", "none"] {
2196            let path = write_toml(
2197                tmp.path(),
2198                &format!(
2199                    "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
2200                ),
2201            );
2202            let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2203            let board = cfg.kanban.configurations.get("x").unwrap();
2204            assert_eq!(board.order_by.as_str(), name);
2205        }
2206        assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
2207        assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
2208        assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
2209        assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
2210    }
2211
2212    #[test]
2213    fn zero_title_lines_rejected_as_config_error() {
2214        let tmp = TempDir::new().unwrap();
2215        let path = write_toml(
2216            tmp.path(),
2217            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
2218        );
2219        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2220        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2221        assert!(
2222            err.to_string().contains("kanban.scratch.title_lines"),
2223            "error names the offending field: {err}"
2224        );
2225    }
2226
2227    #[test]
2228    fn unknown_order_by_rejected_as_config_error() {
2229        let tmp = TempDir::new().unwrap();
2230        let path = write_toml(
2231            tmp.path(),
2232            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
2233        );
2234        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2235        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2236    }
2237
2238    #[test]
2239    fn board_without_display_keys_loads_default_display_settings() {
2240        let tmp = TempDir::new().unwrap();
2241        let path = write_toml(
2242            tmp.path(),
2243            "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
2244        );
2245        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2246        let board = cfg.default_config();
2247        assert!(board.wrap_titles);
2248        assert_eq!(board.title_lines, 3);
2249        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
2250    }
2251
2252    #[test]
2253    fn display_settings_roundtrip_through_save() {
2254        let tmp = TempDir::new().unwrap();
2255        let path = tmp.path().join("tix.toml");
2256        let mut cfg = Config::default();
2257        {
2258            let board = cfg.kanban.configurations.get_mut("default").unwrap();
2259            board.wrap_titles = false;
2260            board.title_lines = 5;
2261            board.order_by = OrderBy::None;
2262        }
2263        cfg.save(&path).unwrap();
2264        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2265        assert_eq!(
2266            reloaded, cfg,
2267            "display settings survive save/load round trip"
2268        );
2269        let on_disk = std::fs::read_to_string(&path).unwrap();
2270        for key in ["wrap_titles", "title_lines", "order_by"] {
2271            assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
2272        }
2273    }
2274
2275    #[test]
2276    fn manual_card_order_roundtrips_through_save() {
2277        let tmp = TempDir::new().unwrap();
2278        let path = tmp.path().join("tix.toml");
2279        let mut cfg = Config::default();
2280        {
2281            let board = cfg.kanban.configurations.get_mut("default").unwrap();
2282            board.card_order = vec!["a.md".into(), "b.md".into()];
2283        }
2284        cfg.save(&path).unwrap();
2285        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2286        assert_eq!(
2287            reloaded
2288                .kanban
2289                .configurations
2290                .get("default")
2291                .unwrap()
2292                .card_order,
2293            vec!["a.md".to_string(), "b.md".to_string()]
2294        );
2295        let on_disk = std::fs::read_to_string(&path).unwrap();
2296        assert!(
2297            on_disk.contains("card_order"),
2298            "missing card_order in:\n{on_disk}"
2299        );
2300    }
2301
2302    #[test]
2303    fn planning_defaults_match_contract() {
2304        let c = Config::default();
2305        assert_eq!(c.planning.cycle_number, 1);
2306        assert_eq!(c.planning.default_cycle, None);
2307        assert_eq!(c.planning.default_milestone, None);
2308        assert_eq!(c.planning.default_team, None);
2309        assert!(c.teams.is_empty());
2310        assert!(c.cycles.is_empty());
2311        assert_eq!(c.milestones_dir, None);
2312        assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
2313    }
2314
2315    #[test]
2316    fn planning_sections_roundtrip_and_preserve_declaration_order() {
2317        let tmp = TempDir::new().unwrap();
2318        let path = write_toml(
2319            tmp.path(),
2320            r#"issues_dir = "docs/issues"
2321milestones_dir = "docs/milestones"
2322
2323[planning]
2324cycle_number = 1
2325default_cycle = "Cycle 1"
2326default_milestone = "Backlog"
2327default_team = "default"
2328
2329[cycles."Cycle 1"]
2330
2331[cycles."Cycle 2"]
2332start_date = "2026-09-21"
2333due_date = "2026-09-30"
2334
2335[teams.default]
2336members = ["Herb Mason <herb@example.com>", "@alice"]
2337
2338[teams.platform]
2339members = ["bob"]
2340"#,
2341        );
2342        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2343        assert_eq!(cfg.planning.cycle_number, 1);
2344        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
2345        assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
2346        assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
2347        assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
2348
2349        // Names are map keys; declaration order is restored from headers.
2350        let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
2351        assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
2352        let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
2353        assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
2354        assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
2355        assert_eq!(cycle2.description, None);
2356
2357        let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
2358        assert_eq!(teams, ["default", "platform"]);
2359        assert_eq!(
2360            cfg.teams.get("default").unwrap().members,
2361            ["Herb Mason <herb@example.com>", "@alice"]
2362        );
2363
2364        // Save then reload: order + values survive the round trip (NFR-002).
2365        cfg.save(&path).unwrap();
2366        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2367        assert_eq!(cfg, reloaded);
2368        assert_eq!(
2369            reloaded
2370                .cycles
2371                .keys()
2372                .map(String::as_str)
2373                .collect::<Vec<_>>(),
2374            ["Cycle 1", "Cycle 2"]
2375        );
2376        assert_eq!(
2377            reloaded
2378                .teams
2379                .keys()
2380                .map(String::as_str)
2381                .collect::<Vec<_>>(),
2382            ["default", "platform"]
2383        );
2384    }
2385
2386    // ---------------------------------------------------------------------
2387    // WP01 / T001-T005 — the category vocabulary and mapping
2388    // ---------------------------------------------------------------------
2389
2390    const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
2391
2392[categories]
2393order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
2394default = "unstarted"
2395
2396[transitions]
2397terminal_locked = true
2398
2399[kanban.default]
2400columns = ["triage", "wip", "review", "shipped"]
2401done_column = "shipped"
2402category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
2403
2404[export]
2405"#;
2406
2407    #[test]
2408    fn category_sections_parse_and_default_sensibly() {
2409        let tmp = TempDir::new().unwrap();
2410        let path = write_toml(tmp.path(), FULL_CATEGORIES);
2411        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2412        assert_eq!(cfg.categories.order, CATEGORY_KEYS);
2413        assert_eq!(cfg.categories.default, "unstarted");
2414        assert!(cfg.transitions.terminal_locked);
2415
2416        let board = cfg.default_config();
2417        assert_eq!(
2418            board.category_map.get("shipped").map(String::as_str),
2419            Some("completed")
2420        );
2421        assert_eq!(
2422            board.category_map.get("triage").map(String::as_str),
2423            Some("backlog")
2424        );
2425    }
2426
2427    #[test]
2428    fn category_sections_default_when_absent() {
2429        let tmp = TempDir::new().unwrap();
2430        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
2431        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2432        // A config omitting `[categories]` still works, off the built-in
2433        // vocabulary — T001's "sensible defaults".
2434        assert_eq!(cfg.categories, CategoryConfig::default());
2435        assert_eq!(cfg.transitions, TransitionConfig::default());
2436        assert!(cfg.categories.is_default(), "and is not written back");
2437        assert!(cfg.transitions.is_default());
2438    }
2439
2440    #[test]
2441    fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
2442        let tmp = TempDir::new().unwrap();
2443        let bad_categories = write_toml(
2444            tmp.path(),
2445            "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
2446        );
2447        let err = Config::load_with_warning(&bad_categories, |_| {})
2448            .expect_err("an unknown key in [categories] must be refused");
2449        assert!(err.to_string().contains("unknown field"), "got: {err}");
2450
2451        let tmp2 = TempDir::new().unwrap();
2452        let bad_transitions = write_toml(
2453            tmp2.path(),
2454            "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
2455        );
2456        let err = Config::load_with_warning(&bad_transitions, |_| {})
2457            .expect_err("an unknown key in [transitions] must be refused");
2458        assert!(err.to_string().contains("unknown field"), "got: {err}");
2459    }
2460
2461    #[test]
2462    fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
2463        let tmp = TempDir::new().unwrap();
2464        let path = write_toml(
2465            tmp.path(),
2466            "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
2467        );
2468        let err = Config::load_with_warning(&path, |_| {})
2469            .expect_err("the vocabulary is global and fixed; a new key is not a default");
2470        assert!(err.to_string().contains("unknown_category"), "got: {err}");
2471    }
2472
2473    /// The escalation the contract names: a default that resolves to `completed`
2474    /// would mark every unmapped column done.
2475    #[test]
2476    fn a_default_of_completed_is_refused() {
2477        let tmp = TempDir::new().unwrap();
2478        let path = write_toml(
2479            tmp.path(),
2480            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
2481        );
2482        let err = Config::load_with_warning(&path, |_| {})
2483            .expect_err("a completion-changing default must be an escalation");
2484        let msg = err.to_string();
2485        assert!(msg.contains("may not be `completed`"), "got: {msg}");
2486    }
2487
2488    #[test]
2489    fn a_default_outside_the_vocabulary_is_refused() {
2490        let tmp = TempDir::new().unwrap();
2491        let path = write_toml(
2492            tmp.path(),
2493            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
2494        );
2495        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2496        assert!(err.to_string().contains("unknown_category"), "got: {err}");
2497    }
2498
2499    /// The contract's central invariant: a board either has a completion category
2500    /// or is an explicit configuration error. There is no silent "nothing
2501    /// completes".
2502    #[test]
2503    fn a_mapped_board_with_no_completion_column_is_refused() {
2504        let tmp = TempDir::new().unwrap();
2505        let path = write_toml(
2506            tmp.path(),
2507            r#"issues_dir = "docs/issues"
2508
2509[kanban.default]
2510columns = ["triage", "wip", "shipped"]
2511done_column = "shipped"
2512category_map = { triage = "backlog", wip = "active" }
2513"#,
2514        );
2515        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2516        assert!(
2517            err.to_string().contains("missing_completion_category"),
2518            "got: {err}"
2519        );
2520    }
2521
2522    #[test]
2523    fn a_board_with_two_completion_columns_is_refused() {
2524        let tmp = TempDir::new().unwrap();
2525        let path = write_toml(
2526            tmp.path(),
2527            r#"issues_dir = "docs/issues"
2528
2529[kanban.default]
2530columns = ["shipped", "cancelled"]
2531done_column = "shipped"
2532category_map = { shipped = "completed", cancelled = "completed" }
2533"#,
2534        );
2535        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2536        let msg = err.to_string();
2537        assert!(
2538            msg.contains("exactly one column may complete"),
2539            "got: {msg}"
2540        );
2541    }
2542
2543    #[test]
2544    fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
2545        let tmp = TempDir::new().unwrap();
2546        let path = write_toml(
2547            tmp.path(),
2548            r#"issues_dir = "docs/issues"
2549
2550[kanban.default]
2551columns = ["triage", "shipped"]
2552done_column = "shipped"
2553category_map = { triage = "backlog", shipped = "completed", nope = "active" }
2554"#,
2555        );
2556        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2557        assert!(
2558            err.to_string().contains("mapping_for_unknown_column"),
2559            "got: {err}"
2560        );
2561    }
2562
2563    #[test]
2564    fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
2565        let tmp = TempDir::new().unwrap();
2566        let path = write_toml(
2567            tmp.path(),
2568            r#"issues_dir = "docs/issues"
2569
2570[kanban.default]
2571columns = ["triage", "shipped"]
2572done_column = "shipped"
2573category_map = { triage = "wontfix", shipped = "completed" }
2574"#,
2575        );
2576        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2577        assert!(err.to_string().contains("unknown_category"), "got: {err}");
2578    }
2579
2580    /// T003 step 5, and the boundary that keeps NFR-003 satisfiable: a configured
2581    /// column with **no** mapping is valid. It resolves by the safe default, and
2582    /// erroring here would reject every existing custom configuration.
2583    #[test]
2584    fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
2585        let tmp = TempDir::new().unwrap();
2586        let path = write_toml(
2587            tmp.path(),
2588            r#"issues_dir = "docs/issues"
2589
2590[kanban.default]
2591columns = ["triage", "wip", "shipped"]
2592done_column = "shipped"
2593category_map = { shipped = "completed" }
2594"#,
2595        );
2596        let cfg =
2597            Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
2598        let board = cfg.default_config();
2599        assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
2600        assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
2601        assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
2602    }
2603
2604    /// The legacy reconciliation, and the reason `missing_completion_category`
2605    /// cannot fire for a board with no map. Without it, a board with no
2606    /// `category_map` would map nothing to `completed` and be rejected — which
2607    /// would reject every legacy `tix.toml` the contract requires to load
2608    /// unchanged.
2609    #[test]
2610    fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
2611        let tmp = TempDir::new().unwrap();
2612        let path = write_toml(
2613            tmp.path(),
2614            r#"issues_dir = "docs/issues"
2615
2616[kanban.default]
2617columns = ["todo", "in progress", "in review", "done"]
2618done_column = "done"
2619"#,
2620        );
2621        let cfg = Config::load_with_warning(&path, |_| {})
2622            .expect("a legacy board must load, and it does");
2623        let board = cfg.default_config();
2624        assert!(board.category_map.is_empty());
2625        assert_eq!(board.category_for("done", &cfg.categories), "completed");
2626        assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
2627        assert_eq!(
2628            board.category_for("in review", &cfg.categories),
2629            "unstarted",
2630            "and nothing else is silently complete"
2631        );
2632    }
2633
2634    /// NFR-003 / C-007: the strongest guard. A defaulted configuration must not
2635    /// materialize — otherwise a default that writes new keys is exactly the
2636    /// "silent change" the requirement forbids.
2637    #[test]
2638    fn a_defaulted_config_gains_no_new_keys_on_save() {
2639        let tmp = TempDir::new().unwrap();
2640        let legacy = LEGACY_PRETTY_BOARD;
2641        let path = write_toml(tmp.path(), legacy);
2642        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2643        cfg.save(&path).unwrap();
2644        let after = std::fs::read_to_string(&path).unwrap();
2645        assert_eq!(
2646            after, legacy,
2647            "no `[categories]`, no `[transitions]`, no `category_map` may appear"
2648        );
2649    }
2650
2651    /// An explicitly-defaulted board is **normalized**: every field equal to the
2652    /// loader's value is dropped on save, so the file converges on the canonical
2653    /// legacy form (#1415) rather than carrying redundant keys forever.
2654    #[test]
2655    fn explicit_default_board_keys_are_normalized_away_by_save() {
2656        let tmp = TempDir::new().unwrap();
2657        let explicit = r#"issues_dir = "docs/issues"
2658
2659[kanban.default]
2660columns = [
2661    "todo",
2662    "in progress",
2663    "in review",
2664    "done",
2665]
2666done_column = "done"
2667hidden_columns = []
2668show_mismatch = false
2669wrap_titles = true
2670title_lines = 3
2671order_by = "created_at_desc"
2672
2673[export]
2674"#;
2675        let path = write_toml(tmp.path(), explicit);
2676        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2677        cfg.save(&path).unwrap();
2678        let after = std::fs::read_to_string(&path).unwrap();
2679        assert_eq!(
2680            after, LEGACY_PRETTY_BOARD,
2681            "a redundant explicit default collapses to the canonical form"
2682        );
2683        for key in [
2684            "done_column",
2685            "hidden_columns",
2686            "show_mismatch",
2687            "wrap_titles",
2688            "title_lines",
2689            "order_by",
2690            "[export]",
2691        ] {
2692            assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
2693        }
2694        assert_eq!(
2695            cfg,
2696            Config::load_with_warning(&path, |_| {}).unwrap(),
2697            "and the collapsed form reloads to the same config"
2698        );
2699    }
2700
2701    /// A board mapping with **no** `[categories]` section loads on the built-in
2702    /// vocabulary, and saving it back does not materialize the section.
2703    #[test]
2704    fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
2705        let tmp = TempDir::new().unwrap();
2706        let path = write_toml(
2707            tmp.path(),
2708            r#"issues_dir = "docs/issues"
2709
2710[kanban.default]
2711columns = ["triage", "shipped"]
2712done_column = "shipped"
2713category_map = { triage = "backlog", shipped = "completed" }
2714
2715[export]
2716"#,
2717        );
2718        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2719        assert_eq!(cfg.categories, CategoryConfig::default());
2720        cfg.save(&path).unwrap();
2721        let after = std::fs::read_to_string(&path).unwrap();
2722        assert!(
2723            !after.contains("[categories]"),
2724            "the section must stay unmaterialized: {after}"
2725        );
2726        assert!(
2727            after.contains("category_map"),
2728            "while the board's own mapping survives: {after}"
2729        );
2730    }
2731
2732    /// What round-trips is the **values** and the `order` vector, not the raw
2733    /// bytes: `category_map` serializes as a `[kanban.<name>.category_map]`
2734    /// sub-table with its keys emitted in whatever order the TOML writer chooses,
2735    /// so the authored inline form
2736    /// `category_map = { triage = "backlog", ... }` is *not* what comes back.
2737    ///
2738    /// That is a contract/code mismatch, and the WP's rule is to fix the
2739    /// contract in the same change rather than let the two disagree — so the
2740    /// contract's example is corrected alongside this.
2741    ///
2742    /// The order that *does* matter, `[categories].order`, is a `Vec` and
2743    /// round-trips exactly. The mapping's key order has no semantic effect: it
2744    /// maps a column to a category, and the columns' presentation order comes
2745    /// from `columns`.
2746    #[test]
2747    fn a_full_category_config_round_trips_with_its_values_and_order() {
2748        let tmp = TempDir::new().unwrap();
2749        // A **non-default** vocabulary, so `[categories]` is written at all.
2750        // `FULL_CATEGORIES` spells out the built-in vocabulary verbatim, which
2751        // makes `CategoryConfig::is_default()` true and the section is then
2752        // *correctly* omitted on save — which is the property the other test
2753        // asserts. Using it here would have tested nothing about the round trip.
2754        let authored = r#"issues_dir = "docs/issues"
2755
2756[categories]
2757order = ["backlog", "active", "completed", "canceled"]
2758default = "backlog"
2759
2760[transitions]
2761terminal_locked = false
2762
2763[kanban.default]
2764columns = ["triage", "wip", "shipped"]
2765done_column = "shipped"
2766category_map = { triage = "backlog", wip = "active", shipped = "completed" }
2767
2768[export]
2769"#;
2770        let path = write_toml(tmp.path(), authored);
2771        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2772        cfg.save(&path).unwrap();
2773        let after = std::fs::read_to_string(&path).unwrap();
2774        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2775
2776        assert_eq!(
2777            reloaded.categories, cfg.categories,
2778            "the vocabulary and its default survive"
2779        );
2780        assert_eq!(
2781            reloaded.categories.order,
2782            vec!["backlog", "active", "completed", "canceled"],
2783            "and `order` is a Vec, so the declared order is preserved exactly"
2784        );
2785
2786        assert_eq!(
2787            reloaded.default_config().category_map,
2788            cfg.default_config().category_map,
2789            "the mapping's contents survive, in whatever order they are written"
2790        );
2791        // Both new sections are written, since neither is at its default.
2792        // Both new sections are written — and only because neither is at its
2793        // default. The first version of this fixture set `terminal_locked = true`,
2794        // which **is** the default, so `[transitions]` was correctly omitted and
2795        // the round trip had nothing to prove. Omitted-when-defaulted is the
2796        // NFR-003 trade this WP makes deliberately, so a fixture that pins the
2797        // default is a fixture that tests nothing.
2798        assert!(after.contains("[categories]"), "after: {after}");
2799        assert!(after.contains("[transitions]"), "after: {after}");
2800        assert!(
2801            !reloaded.transitions.terminal_locked,
2802            "and the value survives"
2803        );
2804
2805        // **Save is idempotent.** The top-level section order is the serializer's
2806        // (struct field) order, not the authored order — the first version of this
2807        // assertion expected the authored order and failed on a correctly-saved
2808        // file. What actually has to hold is that a second save changes nothing,
2809        // which is the property a diff and a merge depend on. The authored order
2810        // that *is* load-bearing is preserved by the two assertions above.
2811        let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
2812        reloaded_again.save(&path).unwrap();
2813        assert_eq!(
2814            std::fs::read_to_string(&path).unwrap(),
2815            after,
2816            "a second save must be a no-op"
2817        );
2818    }
2819
2820    /// Two boards with different column names, one mapped and one not — the
2821    /// intake's first acceptance scenario.
2822    #[test]
2823    fn two_boards_with_different_columns_map_to_the_same_categories() {
2824        let tmp = TempDir::new().unwrap();
2825        let path = write_toml(
2826            tmp.path(),
2827            r#"issues_dir = "docs/issues"
2828
2829[kanban.alpha]
2830columns = ["triage", "wip", "review", "shipped"]
2831done_column = "shipped"
2832category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
2833
2834[kanban.beta]
2835columns = ["new", "doing", "merged"]
2836done_column = "merged"
2837
2838[export]
2839"#,
2840        );
2841        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2842        let alpha = &cfg.kanban.configurations["alpha"];
2843        let beta = &cfg.kanban.configurations["beta"];
2844        // Different column names, same categories: `shipped` and `merged` both
2845        // complete, and both boards' working columns are `active`/`unstarted`.
2846        assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
2847        assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
2848        assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
2849        assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
2850        assert!(beta.category_map.is_empty());
2851    }
2852
2853    /// The vocabulary's load-bearing distinction, asserted directly: `canceled` is
2854    /// terminal and **not** complete. Treating it as complete would change
2855    /// completion meaning, which the contract makes an escalation.
2856    #[test]
2857    fn canceled_is_terminal_but_not_complete() {
2858        assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
2859        assert!(TERMINAL_CATEGORIES.contains(&"completed"));
2860        assert_eq!(
2861            COMPLETE_CATEGORY, "completed",
2862            "completion is defined by this category alone"
2863        );
2864        assert_ne!(COMPLETE_CATEGORY, "canceled");
2865        // And a board may map a column to `canceled` — the category exists and is
2866        // mappable, it simply is not the completing one.
2867        let tmp = TempDir::new().unwrap();
2868        let path = write_toml(
2869            tmp.path(),
2870            r#"issues_dir = "docs/issues"
2871
2872[kanban.default]
2873columns = ["triage", "dropped", "shipped"]
2874done_column = "shipped"
2875category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
2876"#,
2877        );
2878        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2879        let board = cfg.default_config();
2880        assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
2881        assert_ne!(
2882            board.category_for("dropped", &cfg.categories),
2883            COMPLETE_CATEGORY
2884        );
2885    }
2886
2887    /// `blocked` as a category is a workflow column position, not the derived
2888    /// blocked state owned by issue 169's mission. Asserted as distinctness, since
2889    /// conflating them is a named rejected alternative.
2890    #[test]
2891    fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
2892        assert!(CATEGORY_KEYS.contains(&"blocked"));
2893        let tmp = TempDir::new().unwrap();
2894        let path = write_toml(
2895            tmp.path(),
2896            r#"issues_dir = "docs/issues"
2897
2898[kanban.default]
2899columns = ["wip", "on hold", "shipped"]
2900done_column = "shipped"
2901category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
2902"#,
2903        );
2904        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2905        let board = cfg.default_config();
2906        assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
2907        // The derived blocked state lives in the relationship graph, not here.
2908        assert!(
2909            !cfg.default_config()
2910                .category_map
2911                .values()
2912                .any(|v| v.contains("state")),
2913            "a category is a position, never a derived state"
2914        );
2915    }
2916
2917    #[test]
2918    fn legacy_file_roundtrips_byte_identical() {
2919        let tmp = TempDir::new().unwrap();
2920        // A pre-mission tix.toml in the canonical toml pretty form. The save
2921        // path must not add, drop, or reorder any pre-existing key (NFR-003):
2922        // maximum already-pretty formatting proves a true byte-stable save.
2923        let legacy = LEGACY_PRETTY_BOARD;
2924        let path = write_toml(tmp.path(), legacy);
2925        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2926        assert_eq!(cfg.planning, PlanningConfig::default());
2927        assert!(cfg.teams.is_empty());
2928        assert!(cfg.cycles.is_empty());
2929        assert_eq!(cfg.milestones_dir, None);
2930        // Behavior the omitted keys would have carried is unchanged: the
2931        // `done_column` is still the loader's `"done"` and ordering keeps its
2932        // default (#1415).
2933        assert_eq!(
2934            cfg.default_config().columns,
2935            ["todo", "in progress", "in review", "done"]
2936        );
2937        assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
2938        assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
2939        assert!(cfg.default_config().hidden_columns.is_empty());
2940        assert!(!cfg.default_config().show_mismatch);
2941        assert!(cfg.default_config().wrap_titles);
2942        assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
2943        assert!(!cfg.default_config().category_map.contains_key("done"));
2944
2945        cfg.save(&path).unwrap();
2946        let on_disk = std::fs::read_to_string(&path).unwrap();
2947        assert_eq!(
2948            on_disk, legacy,
2949            "legacy tix.toml must save byte-identical (NFR-003)"
2950        );
2951        assert!(
2952            !on_disk.contains("[export]"),
2953            "an empty `[export]` table must not be materialized:\n{on_disk}"
2954        );
2955        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2956        assert_eq!(cfg, reloaded);
2957    }
2958
2959    #[test]
2960    fn legacy_file_in_any_layout_never_gains_planning_keys() {
2961        let tmp = TempDir::new().unwrap();
2962        // Hand-written single-line arrays (typical of real repos) parse fine;
2963        // the pretty re-serializer may reformat them, but must never add a
2964        // planning/teams/cycles/milestones key.
2965        let legacy = r#"issues_dir = "docs/issues"
2966[kanban.default]
2967columns = ["todo", "in progress", "in review", "done"]
2968done_column = "done"
2969"#;
2970        let path = write_toml(tmp.path(), legacy);
2971        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2972        cfg.save(&path).unwrap();
2973        let on_disk = std::fs::read_to_string(&path).unwrap();
2974        assert!(!on_disk.contains("milestones_dir"));
2975        assert!(!on_disk.contains("[planning]"));
2976        assert!(!on_disk.contains("[teams]"));
2977        assert!(!on_disk.contains("[cycles"));
2978    }
2979
2980    #[test]
2981    fn planning_default_section_with_partial_keys_loads() {
2982        let tmp = TempDir::new().unwrap();
2983        let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
2984        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2985        assert_eq!(
2986            cfg.planning.cycle_number, 1,
2987            "counter defaults when omitted"
2988        );
2989        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
2990        assert_eq!(cfg.planning.default_milestone, None);
2991        assert_eq!(cfg.planning.default_team, None);
2992    }
2993
2994    #[test]
2995    fn planning_rejects_unknown_keys() {
2996        let tmp = TempDir::new().unwrap();
2997        let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
2998        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2999        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3000        assert!(err.to_string().contains("counter"));
3001    }
3002
3003    #[test]
3004    fn planning_rejects_zero_cycle_number() {
3005        let tmp = TempDir::new().unwrap();
3006        let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
3007        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3008        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3009        assert!(err.to_string().contains("cycle_number"));
3010    }
3011
3012    #[test]
3013    fn cycle_dates_are_validated_and_compared() {
3014        let tmp = TempDir::new().unwrap();
3015        // parsing failure
3016        let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
3017        let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
3018        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3019        assert!(err.to_string().contains("start_date"));
3020
3021        // ordering failure
3022        let ordered = write_toml(
3023            tmp.path(),
3024            "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
3025        );
3026        let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
3027        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3028        assert!(err.to_string().contains("due_date"));
3029        assert!(err.to_string().contains("start_date"));
3030    }
3031
3032    #[test]
3033    fn cycles_reject_unknown_keys() {
3034        let tmp = TempDir::new().unwrap();
3035        let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
3036        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3037        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3038        assert!(err.to_string().contains("bogus"));
3039    }
3040
3041    #[test]
3042    fn cycle_without_dates_loads_fine() {
3043        let tmp = TempDir::new().unwrap();
3044        let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
3045        let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
3046        assert_eq!(
3047            cfg.cycles.get("Alpha").unwrap().description.as_deref(),
3048            Some("a")
3049        );
3050    }
3051
3052    #[test]
3053    fn teams_validate_members_nonempty_and_unique() {
3054        let tmp = TempDir::new().unwrap();
3055        let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
3056        let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
3057        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3058        assert!(err.to_string().contains("members"));
3059
3060        let dup = write_toml(
3061            tmp.path(),
3062            "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
3063        );
3064        let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
3065        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3066        assert!(err.to_string().contains("unique"));
3067    }
3068
3069    #[test]
3070    fn empty_teams_table_is_disabled_not_error() {
3071        let tmp = TempDir::new().unwrap();
3072        let path = write_toml(tmp.path(), "[teams]\n");
3073        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3074        assert!(cfg.teams.is_empty());
3075        // An empty [teams] is never written back out.
3076        cfg.save(&path).unwrap();
3077        let on_disk = std::fs::read_to_string(&path).unwrap();
3078        assert!(!on_disk.contains("[teams]"));
3079    }
3080
3081    #[test]
3082    fn blank_milestones_dir_rejected() {
3083        let tmp = TempDir::new().unwrap();
3084        let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
3085        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3086        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3087        assert!(err.to_string().contains("milestones_dir"));
3088    }
3089
3090    #[test]
3091    fn sparse_keybindings_roundtrip_without_defaults() {
3092        let tmp = TempDir::new().unwrap();
3093        let path = write_toml(
3094            tmp.path(),
3095            "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
3096        );
3097        let config = Config::load_with_warning(&path, |_| {}).unwrap();
3098        config.save(&path).unwrap();
3099        let contents = std::fs::read_to_string(&path).unwrap();
3100        assert!(contents.contains("[keybindings.board]"));
3101        assert!(!contents.contains("move_left"));
3102        assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
3103    }
3104
3105    #[test]
3106    fn omitted_keybindings_retain_effective_defaults() {
3107        let tmp = TempDir::new().unwrap();
3108        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3109        let config = Config::load_with_warning(&path, |_| {}).unwrap();
3110        let bindings = config.effective_keybindings().unwrap();
3111        assert_eq!(
3112            bindings.label(
3113                crate::keybindings::InputContext::Board,
3114                crate::keybindings::TuiAction::NewIssue
3115            ),
3116            "n"
3117        );
3118    }
3119
3120    /// `[keybindings.edit_conflict]` is a real table (tix #1510): an override lands
3121    /// on the conflict prompt's decisions and nothing else moves. Without this the
3122    /// `edit_conflict` rows in `docs/key-bindings.md` would be documentation for a
3123    /// setting that cannot be written.
3124    #[test]
3125    fn edit_conflict_keybindings_override_the_prompt() {
3126        use crate::keybindings::{InputContext, TuiAction};
3127        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3128
3129        let tmp = TempDir::new().unwrap();
3130        let path = write_toml(
3131            tmp.path(),
3132            "[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
3133        );
3134        let config = Config::load_with_warning(&path, |_| {}).unwrap();
3135        let bindings = config.effective_keybindings().unwrap();
3136
3137        assert_eq!(
3138            bindings.label(InputContext::EditConflict, TuiAction::Confirm),
3139            "Ctrl+k"
3140        );
3141        assert_eq!(
3142            bindings.action(
3143                InputContext::EditConflict,
3144                KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
3145            ),
3146            Some(TuiAction::Confirm)
3147        );
3148        assert_eq!(
3149            bindings.action(
3150                InputContext::EditConflict,
3151                KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
3152            ),
3153            None,
3154            "an override replaces the action's whole alias list"
3155        );
3156        // The actions that were not overridden keep their defaults.
3157        assert_eq!(
3158            bindings.action(
3159                InputContext::EditConflict,
3160                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
3161            ),
3162            Some(TuiAction::Reedit)
3163        );
3164        assert_eq!(
3165            bindings.action(
3166                InputContext::EditConflict,
3167                KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
3168            ),
3169            Some(TuiAction::Ignore)
3170        );
3171    }
3172
3173    #[test]
3174    fn keybindings_reject_unknown_fields_and_empty_actions() {
3175        let tmp = TempDir::new().unwrap();
3176        let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
3177        assert!(
3178            Config::load_with_warning(&unknown, |_| {})
3179                .unwrap_err()
3180                .to_string()
3181                .contains("bogus")
3182        );
3183        let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
3184        assert!(
3185            Config::load_with_warning(&empty, |_| {})
3186                .unwrap_err()
3187                .to_string()
3188                .contains("new_issue")
3189        );
3190    }
3191
3192    /// The mode switches are configurable under `[keybindings.shell]`: an
3193    /// override moves the key *and* the label the rail renders, an unknown action
3194    /// name is refused, and an unparsable chord names the key it came from.
3195    #[test]
3196    fn shell_mode_switches_are_configurable() {
3197        use crate::keybindings::{InputContext, TuiAction};
3198        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3199
3200        let tmp = TempDir::new().unwrap();
3201        let path = write_toml(
3202            tmp.path(),
3203            "[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
3204        );
3205        let config = Config::load_with_warning(&path, |_| {}).unwrap();
3206        let bindings = config.effective_keybindings().unwrap();
3207
3208        // The label is what the rail renders (`nav::render` reads it back).
3209        assert_eq!(
3210            bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
3211            "g"
3212        );
3213        assert_eq!(
3214            bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
3215            "F3 / b"
3216        );
3217        // The key resolves, and the default F5 no longer does.
3218        assert_eq!(
3219            bindings.action(
3220                InputContext::Shell,
3221                KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
3222            ),
3223            Some(TuiAction::SwitchToMilestones)
3224        );
3225        assert_eq!(
3226            bindings.action(
3227                InputContext::Shell,
3228                KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
3229            ),
3230            None,
3231            "an override replaces the action's whole alias list"
3232        );
3233        assert_eq!(
3234            bindings.action(
3235                InputContext::Shell,
3236                KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
3237            ),
3238            Some(TuiAction::SwitchToBoard),
3239            "a second alias keeps working"
3240        );
3241
3242        // An unknown action name is a load error naming the key.
3243        let unknown = write_toml(
3244            tmp.path(),
3245            "[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
3246        );
3247        let error = Config::load_with_warning(&unknown, |_| {})
3248            .unwrap_err()
3249            .to_string();
3250        assert!(
3251            error.contains("switch_to_nowhere"),
3252            "the error names the offending key: {error:?}"
3253        );
3254
3255        // An unparsable chord is a load error naming the action and the chord.
3256        let bad_chord = write_toml(
3257            tmp.path(),
3258            "[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
3259        );
3260        let error = Config::load_with_warning(&bad_chord, |_| {})
3261            .unwrap_err()
3262            .to_string();
3263        assert!(
3264            error.contains("switch_to_milestones") && error.contains("nosuchkey"),
3265            "the error names the key and the chord: {error:?}"
3266        );
3267    }
3268
3269    // --- new-issue templates (tui-new-issue-mode, task 6.2) ---
3270
3271    #[test]
3272    fn templates_round_trip_through_save_and_load() {
3273        let tmp = TempDir::new().unwrap();
3274        let path = tmp.path().join("tix.toml");
3275        let mut config = Config::starter();
3276        config.templates.insert(
3277            "bug".into(),
3278            IssueTemplate {
3279                title: Some("Bug: ".into()),
3280                body: Some("## Steps\n".into()),
3281                labels: vec!["bug".into(), "urgent".into()],
3282                status: Some("todo".into()),
3283                cycles: vec!["Default".into()],
3284                start_date: Some("2026-01-01".into()),
3285                due_date: Some("2026-01-02".into()),
3286                ..IssueTemplate::default()
3287            },
3288        );
3289        config.validate().expect("the fixture is a valid config");
3290        config.save(&path).unwrap();
3291
3292        let raw = std::fs::read_to_string(&path).unwrap();
3293        assert!(raw.contains("[templates.bug]"), "{raw}");
3294        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3295        assert_eq!(reloaded.templates, config.templates);
3296        let bug = &reloaded.templates["bug"];
3297        assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
3298        assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
3299        assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
3300    }
3301
3302    #[test]
3303    fn a_config_without_templates_writes_no_section() {
3304        let tmp = TempDir::new().unwrap();
3305        let path = tmp.path().join("tix.toml");
3306        Config::default().save(&path).unwrap();
3307        let raw = std::fs::read_to_string(&path).unwrap();
3308        assert!(
3309            !raw.contains("templates"),
3310            "an absent section must not appear on disk:\n{raw}"
3311        );
3312        assert!(
3313            Config::load_with_warning(&path, |_| {})
3314                .unwrap()
3315                .templates
3316                .is_empty()
3317        );
3318    }
3319
3320    #[test]
3321    fn a_template_value_with_a_comma_is_rejected() {
3322        let tmp = TempDir::new().unwrap();
3323        let path = write_toml(
3324            tmp.path(),
3325            "issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
3326        );
3327        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3328        assert!(
3329            err.to_string().contains("must not contain a comma"),
3330            "got {err}"
3331        );
3332    }
3333
3334    #[test]
3335    fn a_template_with_a_backwards_date_range_is_rejected() {
3336        let tmp = TempDir::new().unwrap();
3337        let path = write_toml(
3338            tmp.path(),
3339            "issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
3340             start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
3341        );
3342        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3343        assert!(err.to_string().contains(">= start_date"), "got {err}");
3344    }
3345
3346    #[test]
3347    fn an_unknown_template_key_is_rejected() {
3348        let tmp = TempDir::new().unwrap();
3349        let path = write_toml(
3350            tmp.path(),
3351            "issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
3352        );
3353        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3354        assert!(err.to_string().contains("unknown field"), "got {err}");
3355    }
3356}