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::{Deserialize, Serialize};
16
17use crate::error::{Result, TixError};
18use crate::keybindings::{EffectiveBindings, KeyBindings};
19
20/// Remote tracker credentials/targets embedded in `tix.toml`.
21///
22/// `token` values are read-only: they are never serialized back out and are
23/// never echoed in logs or errors. `base_url` may override the default
24/// endpoint for self-hosted GitLab instances.
25#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26#[serde(default, deny_unknown_fields)]
27pub struct RemoteConfig {
28    /// Optional cleartext token; prefer the `GITLAB_TOKEN`/`LINEAR_TOKEN`
29    /// env vars. Never serialized back to disk.
30    pub token: Option<String>,
31    /// Optional base URL (e.g. self-hosted GitLab).
32    pub base_url: Option<String>,
33    /// GitLab project path (e.g. `acme/tix`) passed to `glab`.
34    pub project: Option<String>,
35    /// Linear team name passed to `linear-cli`.
36    pub team: Option<String>,
37}
38
39/// The `[export]` section: per-target remote configuration.
40#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
41#[serde(default, deny_unknown_fields)]
42pub struct ExportConfig {
43    /// GitLab remote target.
44    pub gitlab: Option<RemoteConfig>,
45    /// Linear remote target.
46    pub linear: Option<RemoteConfig>,
47}
48
49/// A named team (`[teams.<label>]` in `tix.toml`). The label lives as the
50/// map key; `members` are free-form identities feeding the assignee/reviewer
51/// pickers only (never constraining stored values, DM-01M311MRK517KRHR426QPNP6P7).
52///
53/// An absent/empty `[teams]` section means teams are disabled (today's
54/// behavior), and an empty `[teams]` table is never written back out.
55#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct Team {
58    /// Non-empty per team; unique within the team.
59    pub members: Vec<String>,
60}
61
62/// A named cycle (`[cycles.<name>]` in `tix.toml`). The name lives as the
63/// map key; `start_date`/`due_date` are optional `YYYY-MM-DD` strings with
64/// `due_date >= start_date` when both are present.
65#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct PlanningCycle {
68    /// Optional human description.
69    pub description: Option<String>,
70    /// Optional start date (`YYYY-MM-DD`).
71    pub start_date: Option<String>,
72    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
73    pub due_date: Option<String>,
74}
75
76/// The `[planning]` section: the monotonic auto-cycle counter plus the
77/// picker presets written by `tix init` (`contracts/tix-toml-planning.md`).
78///
79/// `cycle_number` is the next auto "Cycle N" number: it starts at 1, is bumped
80/// on every unnamed cycle create, and never decrements (FR-003,
81/// DM-01M311BJKMN3PPCR9GR9Y7ESD2). Contrary to the legacy `[state]` counter,
82/// this section **is** serialized once mutated.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(default, deny_unknown_fields)]
85pub struct PlanningConfig {
86    /// Monotonic next auto-cycle number; `>= 1`. Default `1`.
87    pub cycle_number: u32,
88    /// Optional picker preset naming a cycle.
89    pub default_cycle: Option<String>,
90    /// Optional picker preset naming a milestone.
91    pub default_milestone: Option<String>,
92    /// Optional picker preset naming a team.
93    pub default_team: Option<String>,
94}
95
96impl PlanningConfig {
97    /// Whether this section is untouched (all values at their defaults); such
98    /// a section is not written to disk so legacy `tix.toml` files keep
99    /// round-tripping byte-identically (NFR-003).
100    pub fn is_default(&self) -> bool {
101        self == &Self::default()
102    }
103}
104
105impl Default for PlanningConfig {
106    fn default() -> Self {
107        Self {
108            cycle_number: 1,
109            default_cycle: None,
110            default_milestone: None,
111            default_team: None,
112        }
113    }
114}
115
116/// The `[state]` section — deprecated runtime bookkeeping that is still
117/// tolerated on load for legacy `tix.toml` files but is never serialized.
118///
119/// The monotonic `next_number` counter this held was removed in issue #52:
120/// issue numbers are now resolved at creation time by scanning the live
121/// issue set, so [`Config::save`] writes no `[state]` section at all.
122#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
123#[serde(default, deny_unknown_fields)]
124pub struct StateConfig {
125    /// Legacy monotonic issue-number counter (C-007). Read-only — kept so
126    /// old `tix.toml` files with `[state].next_number` still load, and
127    /// never persisted once saved again.
128    #[serde(skip_serializing)]
129    pub next_number: u32,
130}
131
132/// Card ordering for the rendered board (`[kanban.<name>].order_by`).
133///
134/// Applied per column in the board builders; the store's underlying order is
135/// untouched so the CLI keeps its own sequencing (board-display FR-02).
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
137pub enum OrderBy {
138    /// Most recently created issues first (the default).
139    #[default]
140    #[serde(rename = "created_at_desc")]
141    CreatedAtDesc,
142    /// Oldest issues first.
143    #[serde(rename = "created_at_asc")]
144    CreatedAtAsc,
145    /// Preserve the underlying store (filename/created-date ascending) order.
146    #[serde(rename = "none")]
147    None,
148}
149
150impl OrderBy {
151    /// The TOML spelling of this variant.
152    pub fn as_str(self) -> &'static str {
153        match self {
154            Self::CreatedAtDesc => "created_at_desc",
155            Self::CreatedAtAsc => "created_at_asc",
156            Self::None => "none",
157        }
158    }
159
160    /// The next variant when cycling the setting in the TUI settings modal.
161    pub fn next(self) -> Self {
162        match self {
163            Self::CreatedAtDesc => Self::CreatedAtAsc,
164            Self::CreatedAtAsc => Self::None,
165            Self::None => Self::CreatedAtDesc,
166        }
167    }
168}
169
170/// The fixed, global category vocabulary (C-002, contract §1).
171///
172/// One list, referenced by the config validator, the per-board mapping and the
173/// service WP02 builds. A vocabulary spelled out in three places is a vocabulary
174/// that disagrees with itself the first time one of them changes.
175///
176/// `TERMINAL` is the subset that ends the workflow. `canceled` is terminal but
177/// **not** complete — `COMPLETE` is deliberately a separate one-element list rather
178/// than `!TERMINAL.contains(...)`, because "terminal" and "complete" are different
179/// claims and the contract makes conflating them an escalation.
180pub const CATEGORY_KEYS: &[&str] = &[
181    "backlog",
182    "unstarted",
183    "active",
184    "blocked",
185    "completed",
186    "canceled",
187];
188
189/// Categories that end the workflow.
190pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
191
192/// The **only** category that means done. `canceled` is terminal without being
193/// complete, so this is a one-element list and not "everything terminal".
194pub const COMPLETE_CATEGORY: &str = "completed";
195
196/// The documented safe default for a column with no mapping (contract §4).
197///
198/// Non-terminal and non-complete, so an unmapped column is never silently counted
199/// as done. A default that resolved to `completed` would change completion
200/// meaning by accident, which the contract makes an escalation rather than a
201/// convenience — [`validate_categories`] refuses one.
202pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
203
204/// `[categories]`: the global vocabulary and the safe default.
205///
206/// Omitted from disk when untouched, so a legacy `tix.toml` round-trips
207/// byte-identically and gains no new keys (NFR-003, C-007).
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210pub struct CategoryConfig {
211    /// The vocabulary, in the project's preferred order for presentation.
212    ///
213    /// Defaults to [`CATEGORY_KEYS`]. A configured list is still validated
214    /// against that vocabulary: a board maps columns *into* the global
215    /// vocabulary, it does not define its own (C-002), so adding a key here is
216    /// an `unknown_category` error rather than a new category.
217    pub order: Vec<String>,
218    /// The category an unmapped column resolves to. Must be in `order`, and must
219    /// not be [`COMPLETE_CATEGORY`].
220    pub default: String,
221}
222
223impl Default for CategoryConfig {
224    fn default() -> Self {
225        Self {
226            order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
227            default: SAFE_DEFAULT_CATEGORY.to_string(),
228        }
229    }
230}
231
232impl CategoryConfig {
233    /// Whether this section is untouched, and so is not written to disk.
234    pub fn is_default(&self) -> bool {
235        self == &Self::default()
236    }
237
238    /// Whether `key` is in the configured vocabulary.
239    pub fn contains(&self, key: &str) -> bool {
240        self.order.iter().any(|k| k == key)
241    }
242}
243
244/// `[transitions]`: the workflow policy.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct TransitionConfig {
248    /// Whether an issue in a terminal category refuses an ordinary move.
249    ///
250    /// Default `true`. "Ordinary" is load-bearing: the escape hatch WP02 settles is
251    /// a *reasoned* transition out of a terminal category, not a silent
252    /// reassignment, so this switch governs the second and not the first.
253    pub terminal_locked: bool,
254}
255
256impl Default for TransitionConfig {
257    fn default() -> Self {
258        Self {
259            terminal_locked: true,
260        }
261    }
262}
263
264impl TransitionConfig {
265    pub fn is_default(&self) -> bool {
266        self == &Self::default()
267    }
268}
269
270/// A named board configuration (`[kanban.<name>]` in `tix.toml`).
271///
272/// Carries the ordered column list plus per-configuration metadata: the done
273/// column, columns hidden from the rendered board, and whether the computed
274/// "Mismatched" pseudo-column is shown (C-002 / DM-01M2MH4D84NHG0JVWEPR6ZH4VR).
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(default, deny_unknown_fields)]
277pub struct BoardConfiguration {
278    /// Ordered, case-sensitive, non-empty, unique column names.
279    pub columns: Vec<String>,
280    /// The column treated as "done" (commit prompting). Defaults to `"done"`
281    /// when that name is present in `columns`, else the last configured
282    /// column (contracts/tix-toml.md field rules).
283    ///
284    /// **Its meaning is unchanged by this mission.** It remains the commit-prompt
285    /// trigger and the legacy completion signal. WP02 reads the *category* for the
286    /// completion decision; this field is not reinterpreted here, and a
287    /// `category_map` does not change what it does.
288    pub done_column: Option<String>,
289    /// Column name → category key (contract §2). Omitted when empty, so a board
290    /// without a mapping gains no key on save.
291    ///
292    /// A board with **no** map is a legacy board: its `done_column` resolves to
293    /// `completed` and every other column to the safe default. See
294    /// [`BoardConfiguration::category_for`], which is the single place that
295    /// fallback lives — duplicating it in the service is how a board ends up with
296    /// two notions of its own default.
297    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
298    pub category_map: IndexMap<String, String>,
299    /// Columns hidden from the rendered board. Must be a subset of `columns`;
300    /// their cards stay in board state and remain reachable via filters/search.
301    pub hidden_columns: Vec<String>,
302    /// Whether the computed "Mismatched" pseudo-column is rendered. Default
303    /// `false`. Never a configured column.
304    pub show_mismatch: bool,
305    /// Whether long card titles wrap onto multiple lines. Default `true`.
306    pub wrap_titles: bool,
307    /// The maximum number of lines a wrapped card may occupy. Default `3`;
308    /// validated `>= 1` in [`validate_board`]. Ignored when `wrap_titles`
309    /// is `false`.
310    pub title_lines: usize,
311    /// Sparse manual card order persisted by drag-and-drop reordering. When
312    /// non-empty, this order takes precedence over [`order_by`] for the cards
313    /// whose positions differ from the automatic ordering.
314    #[serde(skip_serializing_if = "Vec::is_empty")]
315    pub card_order: Vec<String>,
316    /// Ordering of cards within each rendered column. Default
317    /// `created_at_desc` (most recently created first).
318    pub order_by: OrderBy,
319}
320
321impl Default for BoardConfiguration {
322    fn default() -> Self {
323        Self {
324            columns: ["todo", "in progress", "in review", "done"]
325                .into_iter()
326                .map(String::from)
327                .collect(),
328            done_column: Some("done".to_string()),
329            category_map: IndexMap::new(),
330            hidden_columns: Vec::new(),
331            show_mismatch: false,
332            wrap_titles: true,
333            title_lines: 3,
334            card_order: Vec::new(),
335            order_by: OrderBy::default(),
336        }
337    }
338}
339
340/// The board a **new repository** starts with (`tix init`), rather than the bare
341/// [`BoardConfiguration::default`] a config-less or partial file falls back to.
342///
343/// It carries the two columns the triage inbox needs: `triage` (where unreviewed work
344/// lands — `[triage].entry_status`) and `wontfix` (where a decline goes; it has to
345/// exist, because `declined_status` must resolve to the `canceled` category).
346///
347/// **Only those two are mapped**, and that is deliberate. The map is what makes a
348/// board `explicit_policy`, and an explicit policy brings *adjacency*: a category may
349/// move to itself or forward through the declared order, never backwards. Mapping the
350/// working columns would have to call them `unstarted` or `active`, and then
351/// `in progress -> todo` — a move people make every day when a review finds a problem
352/// — would be refused outright, with no `--reason` to override it. Leaving them
353/// unmapped resolves them all to the same safe default, so movement among them stays
354/// free while `done`/`wontfix` are terminal, which is the discipline a new repository
355/// can *opt into* later (`tix lint` names the unmapped columns and how to map them).
356pub fn starter_board() -> BoardConfiguration {
357    BoardConfiguration {
358        columns: [
359            "triage",
360            "todo",
361            "in progress",
362            "in review",
363            "done",
364            "wontfix",
365        ]
366        .into_iter()
367        .map(String::from)
368        .collect(),
369        category_map: IndexMap::from([
370            ("done".to_string(), "completed".to_string()),
371            ("wontfix".to_string(), "canceled".to_string()),
372        ]),
373        ..BoardConfiguration::default()
374    }
375}
376
377impl BoardConfiguration {
378    /// The category `column` resolves to, given the global configuration.
379    ///
380    /// The single place the fallback lives, and the seam WP02's service builds
381    /// on. Three cases, in order:
382    ///
383    /// 1. An explicit `category_map` entry wins.
384    /// 2. Otherwise, on a board with **no** map at all, `done_column` is
385    ///    `completed` and every other column is the safe default.
386    /// 3. Otherwise (a map exists but omits this column), the safe default.
387    ///
388    /// Case 2 is a reconciliation, and the contract's two rules collide without
389    /// it: *"a board without a `category_map` inherits `[categories].default` for
390    /// every column"* (§2) plus *"no column maps to `completed` is a configuration
391    /// error"* (§3) would reject every legacy `tix.toml`, while §2 also requires
392    /// that one to load unchanged (C-007). `done_column` is the resolution: it is
393    /// always a configured column, it already means "this column completes work",
394    /// and using it keeps a legacy board's completion behaviour exactly what it
395    /// was. A board that *does* declare a map gets no such help — a map that
396    /// forgot `completed` is a real mistake and is reported as one.
397    pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
398        if let Some(mapped) = self.category_map.get(column) {
399            return mapped;
400        }
401        if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
402            return COMPLETE_CATEGORY;
403        }
404        &categories.default
405    }
406
407    /// The resolved done column: the explicit `done_column`, else `"done"`
408    /// when present in `columns`, else the last configured column.
409    pub fn effective_done_column(&self) -> Option<&str> {
410        if let Some(done) = self.done_column.as_deref() {
411            return Some(done);
412        }
413        if self.columns.iter().any(|c| c == "done") {
414            Some("done")
415        } else {
416            self.columns.last().map(String::as_str)
417        }
418    }
419}
420
421/// The `[kanban]` section: named configurations in declaration order.
422///
423/// `#[serde(transparent)]` maps the TOML table `[kanban.<name>]` straight
424/// onto the [`IndexMap`]. The `toml` parser sorts table keys, so
425/// [`Config::load_with_warning`] re-orders the map from the source
426/// document's `[kanban.<name>]` header order via
427/// [`restore_declaration_order`] (D-02: declaration order = tab order,
428/// validated by the round-trip test). The map is never empty after load —
429/// [`Config::load_with_warning`] synthesizes the shipped `default`
430/// configuration when `tix.toml` omits the section.
431#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
432#[serde(default, transparent)]
433pub struct KanbanSection {
434    /// Named configurations, keyed by name, in declaration order.
435    pub configurations: IndexMap<String, BoardConfiguration>,
436}
437
438/// The project configuration file (`tix.toml`).
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(default, deny_unknown_fields)]
441pub struct Config {
442    /// Named board configurations (`[kanban]`). Always ≥1 after load.
443    pub kanban: KanbanSection,
444    /// Directory holding issue files, relative to the repo root.
445    pub issues_dir: PathBuf,
446    /// Directory holding milestone markdown files, relative to the repo
447    /// root. `None` → the default `docs/milestones`.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub milestones_dir: Option<PathBuf>,
450    /// Named teams (`[teams.<label>]`), in declaration order.
451    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
452    pub teams: IndexMap<String, Team>,
453    /// Named cycles (`[cycles.<name>]`), in declaration order.
454    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
455    pub cycles: IndexMap<String, PlanningCycle>,
456    /// Planning presets and the auto-cycle counter (`[planning]`).
457    #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
458    pub planning: PlanningConfig,
459    /// `[categories]`: the global vocabulary and safe default. Omitted when
460    /// untouched, so a legacy file gains no key on save.
461    #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
462    pub categories: CategoryConfig,
463    /// `[transitions]`: the workflow policy. Omitted when untouched.
464    #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
465    pub transitions: TransitionConfig,
466    /// `[triage]`: the normal columns that make up the triage inbox.
467    ///
468    /// Mission `triage-inbox-review-flow-01M3C600`. The inbox is opt-in: `None` means
469    /// no section, so no inbox, and every `tix triage` command refuses by naming the
470    /// missing section. **`tix init` writes one**, with the board columns it needs, so
471    /// a new repository starts with the inbox on; a legacy `tix.toml` gains no key on
472    /// save. See [`crate::triage::config`].
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub triage: Option<crate::triage::TriageConfig>,
475    /// Optional `$EDITOR` override for full-file editing.
476    pub editor: Option<String>,
477    /// Configured AI agent command (opencode, claude, etc.) launched with
478    /// the issue body as its specification. Mirrors the `editor` resolution
479    /// chain (explicit → `$TIX_AGENT` → `$AGENT` env → PATH fallback).
480    pub agent: Option<String>,
481    /// Persisted runtime state (`[state]`). Tolerated on load for legacy
482    /// files, never written back to disk (issue #52).
483    #[serde(skip_serializing)]
484    pub state: StateConfig,
485    /// External tracker targets (`[export]`).
486    pub export: ExportConfig,
487    /// Sparse TUI keybinding overrides (`[keybindings]`).
488    #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
489    pub keybindings: KeyBindings,
490    /// Top-level frontmatter keys this project uses on purpose (`[custom_attributes]`).
491    ///
492    /// #1361. `tix` preserves a frontmatter key it does not recognise and warns
493    /// about it, because an unrecognised key is usually a typo (`titel:`) and a
494    /// typo that round-trips silently is worse than one that is dropped. A project
495    /// that *does* use a custom key declares it here once and stops being warned.
496    ///
497    /// **Top-level names only**, not dotted paths — the thing being declared is a
498    /// top-level key, and accepting a path syntax here would imply nested
499    /// declarations are checked, which they are not.
500    #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
501    pub custom_attributes: CustomAttributes,
502}
503
504/// The `[custom_attributes]` section: top-level frontmatter keys this project
505/// carries on purpose (#1361).
506///
507/// A **section** rather than a bare top-level array so there is somewhere to put
508/// the next option, and so the declaration reads as a named decision rather than as
509/// one more anonymous list in a file that is mostly named sections.
510///
511/// ```toml
512/// [custom_attributes]
513/// keys = ["build_id", "reviewed_by"]
514/// ```
515#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
516#[serde(default, deny_unknown_fields)]
517pub struct CustomAttributes {
518    /// Top-level frontmatter key names. Names only — no dotted paths; the thing
519    /// being declared is a top-level key, and a path syntax here would imply nested
520    /// declarations are checked when they are not.
521    pub keys: Vec<String>,
522}
523
524impl CustomAttributes {
525    /// Whether anything is declared, for `skip_serializing_if`.
526    pub fn is_empty(&self) -> bool {
527        self.keys.is_empty()
528    }
529}
530
531impl Default for Config {
532    fn default() -> Self {
533        Config {
534            kanban: KanbanSection {
535                configurations: IndexMap::from([(
536                    "default".to_string(),
537                    BoardConfiguration::default(),
538                )]),
539            },
540            issues_dir: PathBuf::from("docs/issues"),
541            milestones_dir: None,
542            teams: IndexMap::new(),
543            cycles: IndexMap::new(),
544            planning: PlanningConfig::default(),
545            categories: CategoryConfig::default(),
546            transitions: TransitionConfig::default(),
547            // The bare default has no inbox, and that stays true: the mechanism is
548            // opt-in, so a config with no `[triage]` section has no inbox and every
549            // `tix triage` command refuses, naming the section. What changed is the
550            // *starting point*: `tix init` writes the section (and the board columns it
551            // needs), so a new repository begins with an inbox rather than with a
552            // refusal. See [`crate::commands::init`].
553            triage: None,
554            editor: None,
555            state: StateConfig::default(),
556            export: ExportConfig::default(),
557            agent: None,
558            keybindings: KeyBindings::default(),
559            custom_attributes: CustomAttributes::default(),
560        }
561    }
562}
563
564impl Config {
565    /// The configuration a **new repository** starts with — what `tix init` writes.
566    ///
567    /// Deliberately not [`Default`]: that is the fallback for a missing or partial
568    /// `tix.toml` (and the base hundreds of tests build fixtures on), and it stays
569    /// minimal. The *starting point* is a product decision: a new repository gets the
570    /// board [`starter_board`] lays out and a working `[triage]` section, so
571    /// `tix triage list` works on a fresh checkout instead of refusing and asking for
572    /// configuration first.
573    ///
574    /// The three keys match the board: unreviewed work sits in `triage`, an accept
575    /// lands in `todo`, a decline in `wontfix`. `deferred_status` is left unset
576    /// because it defaults to `entry_status`, and a default that is never written is a
577    /// default that cannot drift from the meaning it stands for.
578    #[must_use]
579    pub fn starter() -> Config {
580        let mut config = Config::default();
581        config
582            .kanban
583            .configurations
584            .insert("default".to_string(), starter_board());
585        config.triage = Some(crate::triage::TriageConfig {
586            entry_status: Some("triage".to_string()),
587            accepted_status: Some("todo".to_string()),
588            declined_status: Some("wontfix".to_string()),
589            deferred_status: None,
590        });
591        config
592    }
593
594    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
595    /// the `Config` error variant with a human message. Errors name the
596    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
597    pub(crate) fn validate(&self) -> Result<()> {
598        if self.issues_dir.as_os_str().is_empty() {
599            return Err(TixError::Config("`issues_dir` must not be blank".into()));
600        }
601        if self.milestones_dir().as_os_str().is_empty() {
602            return Err(TixError::Config(
603                "`milestones_dir` must not be blank".into(),
604            ));
605        }
606        validate_planning(&self.planning)?;
607        validate_categories(&self.categories)?;
608        self.effective_keybindings()?;
609        if self.kanban.configurations.is_empty() {
610            return Err(TixError::Config(
611                "`[kanban]` must define at least one configuration".into(),
612            ));
613        }
614        for (name, board) in &self.kanban.configurations {
615            validate_board(name, board)?;
616            validate_category_map(name, board, &self.categories)?;
617        }
618        for (label, team) in &self.teams {
619            validate_team(label, team)?;
620        }
621        for (name, cycle) in &self.cycles {
622            validate_cycle(name, cycle)?;
623        }
624        // Last, deliberately. `[triage]` names columns from the active board
625        // *and* from that board's `category_map`, so it can only be judged once
626        // both are; running it earlier would hand `workflow::resolution` an
627        // unvalidated vocabulary, which is the `unreachable!` it documents
628        // (C-002).
629        crate::triage::config::validate(self)?;
630        Ok(())
631    }
632
633    /// Resolve sparse user overrides over the built-in TUI registry.
634    pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
635        KeyBindings::resolve(&self.keybindings)
636    }
637
638    /// Restore the canonical declaration order of named configurations,
639    /// which the TOML parser loses (it sorts table keys). The order comes
640    /// from `[kanban.<name>]` section headers in the source document.
641    fn restore_declaration_order(&mut self, contents: &str) {
642        crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
643        crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
644        crate::config::restore_section_order(&mut self.teams, contents, "teams");
645    }
646
647    /// Declared custom top-level frontmatter keys, lowercased for comparison.
648    ///
649    /// Lowercased because YAML keys are case-sensitive but nobody has ever
650    /// intended `Titel` and `titel` to be different keys, and a suppression list
651    /// that silently fails to match is worse than no list at all.
652    pub fn custom_attributes(&self) -> Vec<String> {
653        self.custom_attributes
654            .keys
655            .iter()
656            .map(|k| k.trim().to_ascii_lowercase())
657            .collect()
658    }
659
660    /// The resolved milestones directory: the configured value, or the
661    /// contract default `docs/milestones` when unset.
662    pub fn milestones_dir(&self) -> &Path {
663        self.milestones_dir
664            .as_deref()
665            .unwrap_or(Path::new("docs/milestones"))
666    }
667
668    /// The configuration used by the CLI and other single-config surfaces:
669    /// the one named `default` if present, else the first declared.
670    pub fn default_config(&self) -> &BoardConfiguration {
671        self.kanban
672            .configurations
673            .get("default")
674            .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
675            .expect("validate() guarantees at least one configuration")
676    }
677
678    /// Named configurations in declaration order (tab order = declaration
679    /// order, D-02).
680    pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
681        self.kanban
682            .configurations
683            .iter()
684            .map(|(name, board)| (name.as_str(), board))
685    }
686
687    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
688    /// every cleartext token read from the file.
689    ///
690    /// Parse and validation failures surface as [`TixError::Config`] with
691    /// the file's path and (where the TOML parser reports it) line.
692    pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
693        let contents = std::fs::read_to_string(path)
694            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
695        let mut config: Config = toml::from_str(&contents)
696            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
697        config.restore_declaration_order(&contents);
698        if config.kanban.configurations.is_empty() {
699            config.kanban = KanbanSection {
700                configurations: IndexMap::from([(
701                    "default".to_string(),
702                    BoardConfiguration::default(),
703                )]),
704            };
705        }
706        config.validate().map_err(|e| match e {
707            TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
708            other => other,
709        })?;
710        for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
711            .into_iter()
712            .flatten()
713        {
714            if remote.token.is_some() {
715                sink(
716                    "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
717                );
718            }
719        }
720        Ok(config)
721    }
722
723    /// Load the config for a project root: `tix.toml` at the root if
724    /// present, otherwise the defaults. An absent file is *not* an error —
725    /// the CLI layer decides whether to nag about `tix init`.
726    pub fn load(project_root: &Path) -> Result<Config> {
727        let path = project_root.join("tix.toml");
728        if !path.is_file() {
729            return Ok(Config::default());
730        }
731        Self::load_with_warning(&path, |w| eprintln!("{w}"))
732    }
733
734    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
735    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
736    pub fn project_root(cwd: &Path) -> Option<PathBuf> {
737        let mut dir = Some(cwd.to_path_buf());
738        while let Some(current) = dir {
739            if current.join("tix.toml").is_file() {
740                return Some(current);
741            }
742            dir = current.parent().map(Path::to_path_buf);
743        }
744        None
745    }
746
747    /// A copy of this config with every token stripped, for writing.
748    fn sanitized_for_disk(&self) -> Config {
749        let mut copy = self.clone();
750        if let Some(gitlab) = copy.export.gitlab.as_mut() {
751            gitlab.token = None;
752        }
753        if let Some(linear) = copy.export.linear.as_mut() {
754            linear.token = None;
755        }
756        copy
757    }
758
759    /// Write `tix.toml` atomically (temp file + rename in the same
760    /// directory, with an `fsync` before the rename — NFR-006).
761    ///
762    /// Tokens are never written to disk, so a round-trip `load` → `save`
763    /// drops any cleartext token (expected: prefer env vars).
764    pub fn save(&self, path: &Path) -> Result<()> {
765        let dir = match path.parent() {
766            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
767            _ => PathBuf::from("."),
768        };
769        std::fs::create_dir_all(&dir)?;
770
771        let disk = self.sanitized_for_disk();
772        let contents = toml::to_string_pretty(&disk)
773            .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
774
775        let file_name = path
776            .file_name()
777            .and_then(|n| n.to_str())
778            .unwrap_or("tix.toml");
779        let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
780
781        let write_result = (|| -> std::io::Result<()> {
782            let mut f = std::fs::File::create(&tmp)?;
783            use std::io::Write;
784            f.write_all(contents.as_bytes())?;
785            f.sync_all()?;
786            Ok(())
787        })();
788        if let Err(e) = write_result {
789            let _ = std::fs::remove_file(&tmp);
790            return Err(TixError::Io(e));
791        }
792
793        if let Err(e) = std::fs::rename(&tmp, path) {
794            let _ = std::fs::remove_file(&tmp);
795            return Err(TixError::Io(e));
796        }
797        Ok(())
798    }
799}
800
801/// Reorder a named-config `IndexMap` to the declaration order of its
802/// `[<section>.<name>]` section headers in the source document (D-02:
803/// declaration order = tab order). The `toml` crate sorts table keys on
804/// deserialize, so the parser's order is not trustworthy; this returns to the
805/// document's byte order. Applies to `[kanban.*]`, `[cycles.*]` and
806/// `[teams.*]`. Names are unquoted when the header used a quoted key (e.g.
807/// `[cycles."Cycle 1"]`).
808fn restore_section_order<V: Clone>(map: &mut IndexMap<String, V>, contents: &str, section: &str) {
809    let mut order: Vec<String> = Vec::new();
810    for line in contents.lines() {
811        let line = line.trim();
812        if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
813            && let Some((head, key)) = rest.split_once('.')
814            && head.trim() == section
815            && !key.trim().contains('.')
816            && let Some(name) = unquote_key(key.trim())
817            && !order.iter().any(|n| n == name)
818        {
819            order.push(name.to_string());
820        }
821    }
822    if order.is_empty() {
823        return;
824    }
825    let mut ordered = IndexMap::new();
826    for name in &order {
827        if let Some(value) = map.shift_remove(name) {
828            ordered.insert(name.clone(), value);
829        }
830    }
831    ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
832    *map = ordered;
833}
834
835/// Strip a single pair of surrounding single or double quotes from a TOML
836/// table-header key, when present.
837fn unquote_key(key: &str) -> Option<&str> {
838    if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
839        Some(inner)
840    } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
841        Some(inner)
842    } else {
843        Some(key)
844    }
845}
846
847/// Validation for the `[planning]` section (NFR-003): the counter must be
848/// positive once the section is present.
849fn validate_planning(planning: &PlanningConfig) -> Result<()> {
850    if planning.cycle_number == 0 {
851        return Err(TixError::Config(
852            "`planning.cycle_number` must be at least 1".into(),
853        ));
854    }
855    Ok(())
856}
857
858/// Per-team validation (NFR-003): non-blank label, non-empty members, unique
859/// member identities.
860fn validate_team(label: &str, team: &Team) -> Result<()> {
861    if label.trim().is_empty() {
862        return Err(TixError::Config("`teams.<label>` must not be blank".into()));
863    }
864    if team.members.is_empty() {
865        return Err(TixError::Config(format!(
866            "`teams.{label}.members` must not be empty"
867        )));
868    }
869    let mut seen: HashSet<&str> = HashSet::new();
870    for member in &team.members {
871        if !seen.insert(member.as_str()) {
872            return Err(TixError::Config(format!(
873                "`teams.{label}.members` must be unique; found duplicate `{member}`"
874            )));
875        }
876    }
877    Ok(())
878}
879
880/// Per-cycle validation (NFR-003): non-blank name, parseable dates, and
881/// `due_date >= start_date` when both are present.
882fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
883    if name.trim().is_empty() {
884        return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
885    }
886    if let Some(start) = &cycle.start_date {
887        if !crate::model::validate_date(start) {
888            return Err(TixError::Config(format!(
889                "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
890            )));
891        }
892    }
893    if let Some(due) = &cycle.due_date {
894        if !crate::model::validate_date(due) {
895            return Err(TixError::Config(format!(
896                "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
897            )));
898        }
899        if let Some(start) = &cycle.start_date
900            && due.as_str() < start.as_str()
901        {
902            return Err(TixError::Config(format!(
903                "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
904            )));
905        }
906    }
907    Ok(())
908}
909
910/// Per-configuration validation (NFR-003): non-empty unique non-blank
911/// columns, `done_column` ⊆ columns, `hidden_columns` ⊆ columns.
912/// The `[categories]` section's own rules (contract §2, §4).
913///
914/// Both are refusals of a *default*, which is the escalation the contract names:
915/// a default naming a category outside the vocabulary is a typo, and a default
916/// that resolves to `completed` would silently mark every unmapped column done.
917fn validate_categories(categories: &CategoryConfig) -> Result<()> {
918    if categories.order.is_empty() {
919        return Err(TixError::Config(
920            "`categories.order` must not be empty — it is the global vocabulary".into(),
921        ));
922    }
923    for key in &categories.order {
924        if !CATEGORY_KEYS.contains(&key.as_str()) {
925            return Err(TixError::Config(format!(
926                "unknown_category: `categories.order` names `{key}`, which is not in the \
927                 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
928                 board maps columns into it and does not define it.",
929                CATEGORY_KEYS.join(", ")
930            )));
931        }
932    }
933    if !categories.contains(&categories.default) {
934        return Err(TixError::Config(format!(
935            "unknown_category: `categories.default` is `{}`, which is not in \
936             `categories.order`",
937            categories.default
938        )));
939    }
940    if categories.default == COMPLETE_CATEGORY {
941        return Err(TixError::Config(format!(
942            "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
943             without an explicit mapping would resolve to complete, which changes \
944             completion meaning by accident. Map the completing column explicitly \
945             instead."
946        )));
947    }
948    Ok(())
949}
950
951/// Per-board mapping validation (contract §3).
952///
953/// A board either has a completion category or is an explicit configuration
954/// error; there is no silent "nothing completes". A *missing* mapping is never an
955/// error — it resolves by the safe default — because erroring would reject every
956/// existing custom configuration (NFR-003, C-007).
957fn validate_category_map(
958    name: &str,
959    board: &BoardConfiguration,
960    categories: &CategoryConfig,
961) -> Result<()> {
962    for (column, key) in &board.category_map {
963        if !board.columns.iter().any(|c| c == column) {
964            return Err(TixError::Config(format!(
965                "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
966                 `{column}`, which is not one of the board's columns ({})",
967                board.columns.join(", ")
968            )));
969        }
970        if !categories.contains(key) {
971            return Err(TixError::Config(format!(
972                "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
973                 `{key}`, which is not in the vocabulary ({})",
974                categories.order.join(", ")
975            )));
976        }
977    }
978    // Only a board that *declares* a map is held to naming a completion column. A
979    // board with no map gets `done_column` -> `completed` in `category_for`, so
980    // counting completions here would reject every legacy config.
981    if board.category_map.is_empty() {
982        return Ok(());
983    }
984    let completers: Vec<&String> = board
985        .category_map
986        .iter()
987        .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
988        .map(|(column, _)| column)
989        .collect();
990    match completers.as_slice() {
991        [] => Err(TixError::Config(format!(
992            "missing_completion_category: `kanban.{name}.category_map` maps no column \
993             to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
994             is ever complete — map exactly one column to it.",
995            board.category_map.len()
996        ))),
997        [_only] => Ok(()),
998        many => Err(TixError::Config(format!(
999            "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
1000             ({}); exactly one column may complete work",
1001            many.len(),
1002            many.iter()
1003                .map(|c| c.as_str())
1004                .collect::<Vec<_>>()
1005                .join(", ")
1006        ))),
1007    }
1008}
1009
1010fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
1011    if board.columns.is_empty() {
1012        return Err(TixError::Config(format!(
1013            "`kanban.{name}.columns` must not be empty"
1014        )));
1015    }
1016    if board.columns.iter().any(|c| c.trim().is_empty()) {
1017        return Err(TixError::Config(format!(
1018            "`kanban.{name}.columns` must not contain blank names"
1019        )));
1020    }
1021    let mut seen: HashSet<&str> = HashSet::new();
1022    for column in &board.columns {
1023        if !seen.insert(column.as_str()) {
1024            return Err(TixError::Config(format!(
1025                "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
1026            )));
1027        }
1028    }
1029    if let Some(done) = &board.done_column
1030        && !board.columns.contains(done)
1031    {
1032        return Err(TixError::Config(format!(
1033            "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
1034        )));
1035    }
1036    for hidden in &board.hidden_columns {
1037        if !board.columns.contains(hidden) {
1038            return Err(TixError::Config(format!(
1039                "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
1040            )));
1041        }
1042    }
1043    if board.title_lines == 0 {
1044        return Err(TixError::Config(format!(
1045            "`kanban.{name}.title_lines` must be at least 1"
1046        )));
1047    }
1048    Ok(())
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054    use std::path::PathBuf;
1055    use tempfile::TempDir;
1056
1057    fn write_toml(dir: &Path, contents: &str) -> PathBuf {
1058        let path = dir.join("tix.toml");
1059        std::fs::write(&path, contents).unwrap();
1060        path
1061    }
1062
1063    #[test]
1064    fn starter_config_carries_a_working_inbox() {
1065        let config = Config::starter();
1066        config
1067            .validate()
1068            .expect("the config `tix init` writes must load");
1069
1070        let board = config.default_config();
1071        for column in [
1072            "triage",
1073            "todo",
1074            "in progress",
1075            "in review",
1076            "done",
1077            "wontfix",
1078        ] {
1079            assert!(
1080                board.columns.iter().any(|c| c == column),
1081                "the starter board is missing `{column}`: {:?}",
1082                board.columns
1083            );
1084        }
1085
1086        let triage = config
1087            .triage
1088            .as_ref()
1089            .expect("the starter config has an inbox");
1090        assert_eq!(triage.entry_status.as_deref(), Some("triage"));
1091        assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
1092        assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
1093        // Left unset: the default *is* the entry column, and a default that is never
1094        // written cannot drift from the meaning it stands for.
1095        assert_eq!(triage.effective_deferred_status(), Some("triage"));
1096
1097        // The one mapping triage requires, plus the completion column.
1098        assert_eq!(
1099            board.category_map.get("wontfix").map(String::as_str),
1100            Some("canceled")
1101        );
1102        assert_eq!(
1103            board.category_map.get("done").map(String::as_str),
1104            Some("completed")
1105        );
1106        // The working columns stay **unmapped** on purpose: declaring a map makes the
1107        // board an explicit policy, and adjacency then refuses a *backwards* move with
1108        // no override to offer — `in progress -> todo` is a move people make daily.
1109        // Left out, they all resolve to the same safe default, so movement among them
1110        // stays free while `done`/`wontfix` remain terminal facts.
1111        for column in ["triage", "todo", "in progress", "in review"] {
1112            assert!(
1113                !board.category_map.contains_key(column),
1114                "`{column}` must stay unmapped, or adjacency will refuse work moving back"
1115            );
1116        }
1117    }
1118
1119    #[test]
1120    fn defaults_match_contract() {
1121        let c = Config::default();
1122        let board = c.default_config();
1123        assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
1124        assert_eq!(board.done_column.as_deref(), Some("done"));
1125        assert_eq!(board.hidden_columns, Vec::<String>::new());
1126        assert!(!board.show_mismatch);
1127        assert!(board.wrap_titles);
1128        assert_eq!(board.title_lines, 3);
1129        assert!(board.card_order.is_empty());
1130        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1131        assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
1132        assert_eq!(c.editor, None);
1133        assert_eq!(c.state.next_number, 0);
1134        assert_eq!(c.export.gitlab, None);
1135        assert_eq!(c.export.linear, None);
1136    }
1137
1138    #[test]
1139    fn load_returns_defaults_when_file_absent() {
1140        let tmp = TempDir::new().unwrap();
1141        let cfg = Config::load(tmp.path()).unwrap();
1142        assert_eq!(cfg.state.next_number, 0);
1143        assert_eq!(cfg.default_config().columns.len(), 4);
1144    }
1145
1146    #[test]
1147    fn load_synthesizes_default_config_when_kanban_section_missing() {
1148        let tmp = TempDir::new().unwrap();
1149        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1150        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1151        assert_eq!(cfg.kanban.configurations.len(), 1);
1152        assert!(cfg.kanban.configurations.contains_key("default"));
1153        assert_eq!(cfg.default_config().columns.len(), 4);
1154    }
1155
1156    #[test]
1157    fn load_parses_state_and_keeps_editor_and_remote_targets() {
1158        let tmp = TempDir::new().unwrap();
1159        let path = write_toml(
1160            tmp.path(),
1161            r#"issues_dir = "tickets"
1162editor = "nvim"
1163
1164[kanban.default]
1165columns = ["todo", "done"]
1166done_column = "done"
1167
1168[state]
1169next_number = 17
1170
1171[export.gitlab]
1172project = "acme/tix"
1173"#,
1174        );
1175        let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
1176        assert_eq!(
1177            cfg.kanban.configurations.get("default").unwrap().columns,
1178            ["todo", "done"]
1179        );
1180        assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
1181        assert_eq!(cfg.editor.as_deref(), Some("nvim"));
1182        assert_eq!(cfg.state.next_number, 17);
1183        assert_eq!(
1184            cfg.export.gitlab.unwrap().project.as_deref(),
1185            Some("acme/tix")
1186        );
1187        assert_eq!(cfg.export.linear, None);
1188    }
1189
1190    #[test]
1191    fn empty_columns_rejected_as_config_error() {
1192        let tmp = TempDir::new().unwrap();
1193        let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
1194        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1195        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1196        assert!(err.to_string().contains("kanban.scratch.columns"));
1197    }
1198
1199    #[test]
1200    fn duplicate_columns_rejected_as_config_error() {
1201        let tmp = TempDir::new().unwrap();
1202        let path = write_toml(
1203            tmp.path(),
1204            "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
1205        );
1206        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1207        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1208        assert!(err.to_string().contains("duplicate"));
1209    }
1210
1211    #[test]
1212    fn blank_column_name_rejected() {
1213        let tmp = TempDir::new().unwrap();
1214        let path = write_toml(
1215            tmp.path(),
1216            "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
1217        );
1218        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1219        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1220    }
1221
1222    #[test]
1223    fn done_column_must_be_in_columns() {
1224        let tmp = TempDir::new().unwrap();
1225        let path = write_toml(
1226            tmp.path(),
1227            "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
1228        );
1229        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1230        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1231        assert!(err.to_string().contains("kanban.scratch.done_column"));
1232    }
1233
1234    #[test]
1235    fn hidden_columns_must_be_subset_of_columns() {
1236        let tmp = TempDir::new().unwrap();
1237        let path = write_toml(
1238            tmp.path(),
1239            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
1240        );
1241        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1242        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1243        assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
1244    }
1245
1246    #[test]
1247    fn hidden_done_column_loads_fine() {
1248        let tmp = TempDir::new().unwrap();
1249        let path = write_toml(
1250            tmp.path(),
1251            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
1252        );
1253        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1254        let board = cfg.kanban.configurations.get("scratch").unwrap();
1255        assert_eq!(board.done_column.as_deref(), Some("done"));
1256        assert_eq!(board.hidden_columns, ["done"]);
1257    }
1258
1259    #[test]
1260    fn legacy_top_level_keys_rejected() {
1261        let tmp = TempDir::new().unwrap();
1262        let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
1263        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1264        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1265        assert!(err.to_string().contains("tix.toml"));
1266        assert!(err.to_string().contains("columns"));
1267    }
1268
1269    #[test]
1270    fn token_warning_fires_once_per_remote_with_token() {
1271        let tmp = TempDir::new().unwrap();
1272        let path = write_toml(
1273            tmp.path(),
1274            r#"[export.gitlab]
1275project = "a/b"
1276token = "gl-secret"
1277
1278[export.linear]
1279team = "Eng"
1280token = "lin-secret"
1281"#,
1282        );
1283        let mut warnings: Vec<String> = Vec::new();
1284        let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
1285        assert_eq!(warnings.len(), 2);
1286        assert!(warnings[0].contains("GITLAB_TOKEN"));
1287        assert!(warnings[1].contains("LINEAR_TOKEN"));
1288        // token values must never leak into the warning
1289        for w in &warnings {
1290            assert!(!w.contains("secret"));
1291        }
1292        assert_eq!(
1293            cfg.export.gitlab.unwrap().token.as_deref(),
1294            Some("gl-secret")
1295        );
1296    }
1297
1298    #[test]
1299    fn no_token_means_no_warning() {
1300        let tmp = TempDir::new().unwrap();
1301        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1302        let mut fired = false;
1303        Config::load_with_warning(&path, |_| fired = true).unwrap();
1304        assert!(!fired);
1305    }
1306
1307    #[test]
1308    fn save_never_writes_the_state_section() {
1309        let tmp = TempDir::new().unwrap();
1310        let path = tmp.path().join("tix.toml");
1311        // Simulate a legacy load: `[state].next_number` is kept in memory
1312        // but must never be written back out (issue #52).
1313        let mut cfg = Config::default();
1314        cfg.state.next_number = 42;
1315        cfg.save(&path).unwrap();
1316
1317        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1318        assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
1319        assert_eq!(
1320            reloaded.default_config().columns,
1321            cfg.default_config().columns
1322        );
1323
1324        let on_disk = std::fs::read_to_string(&path).unwrap();
1325        assert!(
1326            !on_disk.contains("next_number"),
1327            "next_number leaked into tix.toml:\n{on_disk}"
1328        );
1329        assert!(
1330            !on_disk.contains("[state]"),
1331            "state section written:\n{on_disk}"
1332        );
1333    }
1334
1335    #[test]
1336    fn multi_config_roundtrip_preserves_declaration_order() {
1337        let tmp = TempDir::new().unwrap();
1338        let path = write_toml(
1339            tmp.path(),
1340            r#"issues_dir = "docs/issues"
1341
1342[kanban.release]
1343columns = ["planned", "in progress", "done"]
1344done_column = "done"
1345show_mismatch = true
1346
1347[kanban.personal]
1348columns = ["todo", "done"]
1349done_column = "done"
1350hidden_columns = ["done"]
1351"#,
1352        );
1353        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1354        let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
1355        assert_eq!(names, ["release", "personal"]);
1356
1357        // default_config falls back to the first declared when no "default".
1358        assert_eq!(
1359            cfg.default_config().columns,
1360            ["planned", "in progress", "done"]
1361        );
1362
1363        cfg.save(&path).unwrap();
1364        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1365        assert_eq!(
1366            cfg, reloaded,
1367            "order and values survive save/load round trip"
1368        );
1369        let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
1370        assert_eq!(names2, ["release", "personal"]);
1371    }
1372
1373    #[test]
1374    fn save_never_serializes_tokens_back_out() {
1375        let tmp = TempDir::new().unwrap();
1376        let path = tmp.path().join("tix.toml");
1377        let mut cfg = Config::default();
1378        cfg.export.gitlab = Some(RemoteConfig {
1379            token: Some("secret".into()),
1380            project: Some("a/b".into()),
1381            ..RemoteConfig::default()
1382        });
1383        cfg.save(&path).unwrap();
1384
1385        let on_disk = std::fs::read_to_string(&path).unwrap();
1386        assert!(
1387            !on_disk.contains("secret"),
1388            "token leaked to disk:\n{on_disk}"
1389        );
1390        assert!(
1391            on_disk.contains("a/b"),
1392            "non-secret fields survive:\n{on_disk}"
1393        );
1394
1395        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1396        assert_eq!(reloaded.export.gitlab.unwrap().token, None);
1397    }
1398
1399    #[test]
1400    fn save_is_atomic_and_leaves_no_temp_file() {
1401        let tmp = TempDir::new().unwrap();
1402        let path = tmp.path().join("tix.toml");
1403        Config::default().save(&path).unwrap();
1404        let entries: Vec<_> = std::fs::read_dir(tmp.path())
1405            .unwrap()
1406            .filter_map(|e| e.ok())
1407            .collect();
1408        assert_eq!(entries.len(), 1);
1409        assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
1410    }
1411
1412    #[test]
1413    fn default_loadwrites_are_idempotent() {
1414        let tmp = TempDir::new().unwrap();
1415        let path = tmp.path().join("tix.toml");
1416        let cfg = Config::default();
1417        cfg.save(&path).unwrap();
1418        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1419        assert_eq!(cfg, reloaded);
1420    }
1421
1422    #[test]
1423    fn effective_done_column_falls_back_to_last() {
1424        let board = BoardConfiguration {
1425            columns: vec!["todo".into(), "later".into()],
1426            done_column: None,
1427            ..Default::default()
1428        };
1429        assert_eq!(board.effective_done_column(), Some("later"));
1430
1431        let with_done = BoardConfiguration {
1432            columns: vec!["todo".into(), "done".into(), "later".into()],
1433            done_column: None,
1434            ..Default::default()
1435        };
1436        assert_eq!(with_done.effective_done_column(), Some("done"));
1437    }
1438
1439    #[test]
1440    fn project_root_walks_up_the_tree() {
1441        let tmp = TempDir::new().unwrap();
1442        let nested = tmp.path().join("a").join("b");
1443        std::fs::create_dir_all(&nested).unwrap();
1444        std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
1445
1446        assert_eq!(
1447            Config::project_root(&nested),
1448            Some(tmp.path().to_path_buf())
1449        );
1450        assert_eq!(
1451            Config::project_root(tmp.path()),
1452            Some(tmp.path().to_path_buf())
1453        );
1454    }
1455
1456    #[test]
1457    fn project_root_is_none_when_no_toml_exists() {
1458        let tmp = TempDir::new().unwrap();
1459        assert_eq!(Config::project_root(tmp.path()), None);
1460    }
1461
1462    #[test]
1463    fn order_by_parses_all_names_and_defaults_to_desc() {
1464        let tmp = TempDir::new().unwrap();
1465        for name in ["created_at_desc", "created_at_asc", "none"] {
1466            let path = write_toml(
1467                tmp.path(),
1468                &format!(
1469                    "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
1470                ),
1471            );
1472            let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1473            let board = cfg.kanban.configurations.get("x").unwrap();
1474            assert_eq!(board.order_by.as_str(), name);
1475        }
1476        assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
1477        assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
1478        assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
1479        assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
1480    }
1481
1482    #[test]
1483    fn zero_title_lines_rejected_as_config_error() {
1484        let tmp = TempDir::new().unwrap();
1485        let path = write_toml(
1486            tmp.path(),
1487            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
1488        );
1489        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1490        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1491        assert!(
1492            err.to_string().contains("kanban.scratch.title_lines"),
1493            "error names the offending field: {err}"
1494        );
1495    }
1496
1497    #[test]
1498    fn unknown_order_by_rejected_as_config_error() {
1499        let tmp = TempDir::new().unwrap();
1500        let path = write_toml(
1501            tmp.path(),
1502            "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
1503        );
1504        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1505        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1506    }
1507
1508    #[test]
1509    fn board_without_display_keys_loads_default_display_settings() {
1510        let tmp = TempDir::new().unwrap();
1511        let path = write_toml(
1512            tmp.path(),
1513            "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
1514        );
1515        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1516        let board = cfg.default_config();
1517        assert!(board.wrap_titles);
1518        assert_eq!(board.title_lines, 3);
1519        assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1520    }
1521
1522    #[test]
1523    fn display_settings_roundtrip_through_save() {
1524        let tmp = TempDir::new().unwrap();
1525        let path = tmp.path().join("tix.toml");
1526        let mut cfg = Config::default();
1527        {
1528            let board = cfg.kanban.configurations.get_mut("default").unwrap();
1529            board.wrap_titles = false;
1530            board.title_lines = 5;
1531            board.order_by = OrderBy::None;
1532        }
1533        cfg.save(&path).unwrap();
1534        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1535        assert_eq!(
1536            reloaded, cfg,
1537            "display settings survive save/load round trip"
1538        );
1539        let on_disk = std::fs::read_to_string(&path).unwrap();
1540        for key in ["wrap_titles", "title_lines", "order_by"] {
1541            assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
1542        }
1543    }
1544
1545    #[test]
1546    fn manual_card_order_roundtrips_through_save() {
1547        let tmp = TempDir::new().unwrap();
1548        let path = tmp.path().join("tix.toml");
1549        let mut cfg = Config::default();
1550        {
1551            let board = cfg.kanban.configurations.get_mut("default").unwrap();
1552            board.card_order = vec!["a.md".into(), "b.md".into()];
1553        }
1554        cfg.save(&path).unwrap();
1555        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1556        assert_eq!(
1557            reloaded
1558                .kanban
1559                .configurations
1560                .get("default")
1561                .unwrap()
1562                .card_order,
1563            vec!["a.md".to_string(), "b.md".to_string()]
1564        );
1565        let on_disk = std::fs::read_to_string(&path).unwrap();
1566        assert!(
1567            on_disk.contains("card_order"),
1568            "missing card_order in:\n{on_disk}"
1569        );
1570    }
1571
1572    #[test]
1573    fn planning_defaults_match_contract() {
1574        let c = Config::default();
1575        assert_eq!(c.planning.cycle_number, 1);
1576        assert_eq!(c.planning.default_cycle, None);
1577        assert_eq!(c.planning.default_milestone, None);
1578        assert_eq!(c.planning.default_team, None);
1579        assert!(c.teams.is_empty());
1580        assert!(c.cycles.is_empty());
1581        assert_eq!(c.milestones_dir, None);
1582        assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
1583    }
1584
1585    #[test]
1586    fn planning_sections_roundtrip_and_preserve_declaration_order() {
1587        let tmp = TempDir::new().unwrap();
1588        let path = write_toml(
1589            tmp.path(),
1590            r#"issues_dir = "docs/issues"
1591milestones_dir = "docs/milestones"
1592
1593[planning]
1594cycle_number = 1
1595default_cycle = "Cycle 1"
1596default_milestone = "Backlog"
1597default_team = "default"
1598
1599[cycles."Cycle 1"]
1600
1601[cycles."Cycle 2"]
1602start_date = "2026-09-21"
1603due_date = "2026-09-30"
1604
1605[teams.default]
1606members = ["Herb Mason <herb@example.com>", "@alice"]
1607
1608[teams.platform]
1609members = ["bob"]
1610"#,
1611        );
1612        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1613        assert_eq!(cfg.planning.cycle_number, 1);
1614        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
1615        assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
1616        assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
1617        assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
1618
1619        // Names are map keys; declaration order is restored from headers.
1620        let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
1621        assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
1622        let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
1623        assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
1624        assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
1625        assert_eq!(cycle2.description, None);
1626
1627        let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
1628        assert_eq!(teams, ["default", "platform"]);
1629        assert_eq!(
1630            cfg.teams.get("default").unwrap().members,
1631            ["Herb Mason <herb@example.com>", "@alice"]
1632        );
1633
1634        // Save then reload: order + values survive the round trip (NFR-002).
1635        cfg.save(&path).unwrap();
1636        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1637        assert_eq!(cfg, reloaded);
1638        assert_eq!(
1639            reloaded
1640                .cycles
1641                .keys()
1642                .map(String::as_str)
1643                .collect::<Vec<_>>(),
1644            ["Cycle 1", "Cycle 2"]
1645        );
1646        assert_eq!(
1647            reloaded
1648                .teams
1649                .keys()
1650                .map(String::as_str)
1651                .collect::<Vec<_>>(),
1652            ["default", "platform"]
1653        );
1654    }
1655
1656    // ---------------------------------------------------------------------
1657    // WP01 / T001-T005 — the category vocabulary and mapping
1658    // ---------------------------------------------------------------------
1659
1660    const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
1661
1662[categories]
1663order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
1664default = "unstarted"
1665
1666[transitions]
1667terminal_locked = true
1668
1669[kanban.default]
1670columns = ["triage", "wip", "review", "shipped"]
1671done_column = "shipped"
1672category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
1673
1674[export]
1675"#;
1676
1677    #[test]
1678    fn category_sections_parse_and_default_sensibly() {
1679        let tmp = TempDir::new().unwrap();
1680        let path = write_toml(tmp.path(), FULL_CATEGORIES);
1681        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1682        assert_eq!(cfg.categories.order, CATEGORY_KEYS);
1683        assert_eq!(cfg.categories.default, "unstarted");
1684        assert!(cfg.transitions.terminal_locked);
1685
1686        let board = cfg.default_config();
1687        assert_eq!(
1688            board.category_map.get("shipped").map(String::as_str),
1689            Some("completed")
1690        );
1691        assert_eq!(
1692            board.category_map.get("triage").map(String::as_str),
1693            Some("backlog")
1694        );
1695    }
1696
1697    #[test]
1698    fn category_sections_default_when_absent() {
1699        let tmp = TempDir::new().unwrap();
1700        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1701        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1702        // A config omitting `[categories]` still works, off the built-in
1703        // vocabulary — T001's "sensible defaults".
1704        assert_eq!(cfg.categories, CategoryConfig::default());
1705        assert_eq!(cfg.transitions, TransitionConfig::default());
1706        assert!(cfg.categories.is_default(), "and is not written back");
1707        assert!(cfg.transitions.is_default());
1708    }
1709
1710    #[test]
1711    fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
1712        let tmp = TempDir::new().unwrap();
1713        let bad_categories = write_toml(
1714            tmp.path(),
1715            "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
1716        );
1717        let err = Config::load_with_warning(&bad_categories, |_| {})
1718            .expect_err("an unknown key in [categories] must be refused");
1719        assert!(err.to_string().contains("unknown field"), "got: {err}");
1720
1721        let tmp2 = TempDir::new().unwrap();
1722        let bad_transitions = write_toml(
1723            tmp2.path(),
1724            "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
1725        );
1726        let err = Config::load_with_warning(&bad_transitions, |_| {})
1727            .expect_err("an unknown key in [transitions] must be refused");
1728        assert!(err.to_string().contains("unknown field"), "got: {err}");
1729    }
1730
1731    #[test]
1732    fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
1733        let tmp = TempDir::new().unwrap();
1734        let path = write_toml(
1735            tmp.path(),
1736            "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
1737        );
1738        let err = Config::load_with_warning(&path, |_| {})
1739            .expect_err("the vocabulary is global and fixed; a new key is not a default");
1740        assert!(err.to_string().contains("unknown_category"), "got: {err}");
1741    }
1742
1743    /// The escalation the contract names: a default that resolves to `completed`
1744    /// would mark every unmapped column done.
1745    #[test]
1746    fn a_default_of_completed_is_refused() {
1747        let tmp = TempDir::new().unwrap();
1748        let path = write_toml(
1749            tmp.path(),
1750            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
1751        );
1752        let err = Config::load_with_warning(&path, |_| {})
1753            .expect_err("a completion-changing default must be an escalation");
1754        let msg = err.to_string();
1755        assert!(msg.contains("may not be `completed`"), "got: {msg}");
1756    }
1757
1758    #[test]
1759    fn a_default_outside_the_vocabulary_is_refused() {
1760        let tmp = TempDir::new().unwrap();
1761        let path = write_toml(
1762            tmp.path(),
1763            "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
1764        );
1765        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1766        assert!(err.to_string().contains("unknown_category"), "got: {err}");
1767    }
1768
1769    /// The contract's central invariant: a board either has a completion category
1770    /// or is an explicit configuration error. There is no silent "nothing
1771    /// completes".
1772    #[test]
1773    fn a_mapped_board_with_no_completion_column_is_refused() {
1774        let tmp = TempDir::new().unwrap();
1775        let path = write_toml(
1776            tmp.path(),
1777            r#"issues_dir = "docs/issues"
1778
1779[kanban.default]
1780columns = ["triage", "wip", "shipped"]
1781done_column = "shipped"
1782category_map = { triage = "backlog", wip = "active" }
1783"#,
1784        );
1785        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1786        assert!(
1787            err.to_string().contains("missing_completion_category"),
1788            "got: {err}"
1789        );
1790    }
1791
1792    #[test]
1793    fn a_board_with_two_completion_columns_is_refused() {
1794        let tmp = TempDir::new().unwrap();
1795        let path = write_toml(
1796            tmp.path(),
1797            r#"issues_dir = "docs/issues"
1798
1799[kanban.default]
1800columns = ["shipped", "cancelled"]
1801done_column = "shipped"
1802category_map = { shipped = "completed", cancelled = "completed" }
1803"#,
1804        );
1805        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1806        let msg = err.to_string();
1807        assert!(
1808            msg.contains("exactly one column may complete"),
1809            "got: {msg}"
1810        );
1811    }
1812
1813    #[test]
1814    fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
1815        let tmp = TempDir::new().unwrap();
1816        let path = write_toml(
1817            tmp.path(),
1818            r#"issues_dir = "docs/issues"
1819
1820[kanban.default]
1821columns = ["triage", "shipped"]
1822done_column = "shipped"
1823category_map = { triage = "backlog", shipped = "completed", nope = "active" }
1824"#,
1825        );
1826        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1827        assert!(
1828            err.to_string().contains("mapping_for_unknown_column"),
1829            "got: {err}"
1830        );
1831    }
1832
1833    #[test]
1834    fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
1835        let tmp = TempDir::new().unwrap();
1836        let path = write_toml(
1837            tmp.path(),
1838            r#"issues_dir = "docs/issues"
1839
1840[kanban.default]
1841columns = ["triage", "shipped"]
1842done_column = "shipped"
1843category_map = { triage = "wontfix", shipped = "completed" }
1844"#,
1845        );
1846        let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1847        assert!(err.to_string().contains("unknown_category"), "got: {err}");
1848    }
1849
1850    /// T003 step 5, and the boundary that keeps NFR-003 satisfiable: a configured
1851    /// column with **no** mapping is valid. It resolves by the safe default, and
1852    /// erroring here would reject every existing custom configuration.
1853    #[test]
1854    fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
1855        let tmp = TempDir::new().unwrap();
1856        let path = write_toml(
1857            tmp.path(),
1858            r#"issues_dir = "docs/issues"
1859
1860[kanban.default]
1861columns = ["triage", "wip", "shipped"]
1862done_column = "shipped"
1863category_map = { shipped = "completed" }
1864"#,
1865        );
1866        let cfg =
1867            Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
1868        let board = cfg.default_config();
1869        assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
1870        assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
1871        assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
1872    }
1873
1874    /// The legacy reconciliation, and the reason `missing_completion_category`
1875    /// cannot fire for a board with no map. Without it, a board with no
1876    /// `category_map` would map nothing to `completed` and be rejected — which
1877    /// would reject every legacy `tix.toml` the contract requires to load
1878    /// unchanged.
1879    #[test]
1880    fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
1881        let tmp = TempDir::new().unwrap();
1882        let path = write_toml(
1883            tmp.path(),
1884            r#"issues_dir = "docs/issues"
1885
1886[kanban.default]
1887columns = ["todo", "in progress", "in review", "done"]
1888done_column = "done"
1889"#,
1890        );
1891        let cfg = Config::load_with_warning(&path, |_| {})
1892            .expect("a legacy board must load, and it does");
1893        let board = cfg.default_config();
1894        assert!(board.category_map.is_empty());
1895        assert_eq!(board.category_for("done", &cfg.categories), "completed");
1896        assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
1897        assert_eq!(
1898            board.category_for("in review", &cfg.categories),
1899            "unstarted",
1900            "and nothing else is silently complete"
1901        );
1902    }
1903
1904    /// NFR-003 / C-007: the strongest guard. A defaulted configuration must not
1905    /// materialize — otherwise a default that writes new keys is exactly the
1906    /// "silent change" the requirement forbids.
1907    #[test]
1908    fn a_defaulted_config_gains_no_new_keys_on_save() {
1909        let tmp = TempDir::new().unwrap();
1910        let legacy = r#"issues_dir = "docs/issues"
1911
1912[kanban.default]
1913columns = [
1914    "todo",
1915    "in progress",
1916    "in review",
1917    "done",
1918]
1919done_column = "done"
1920hidden_columns = []
1921show_mismatch = false
1922wrap_titles = true
1923title_lines = 3
1924order_by = "created_at_desc"
1925
1926[export]
1927"#;
1928        let path = write_toml(tmp.path(), legacy);
1929        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1930        cfg.save(&path).unwrap();
1931        let after = std::fs::read_to_string(&path).unwrap();
1932        assert_eq!(
1933            after, legacy,
1934            "no `[categories]`, no `[transitions]`, no `category_map` may appear"
1935        );
1936    }
1937
1938    /// A board mapping with **no** `[categories]` section loads on the built-in
1939    /// vocabulary, and saving it back does not materialize the section.
1940    #[test]
1941    fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
1942        let tmp = TempDir::new().unwrap();
1943        let path = write_toml(
1944            tmp.path(),
1945            r#"issues_dir = "docs/issues"
1946
1947[kanban.default]
1948columns = ["triage", "shipped"]
1949done_column = "shipped"
1950category_map = { triage = "backlog", shipped = "completed" }
1951
1952[export]
1953"#,
1954        );
1955        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1956        assert_eq!(cfg.categories, CategoryConfig::default());
1957        cfg.save(&path).unwrap();
1958        let after = std::fs::read_to_string(&path).unwrap();
1959        assert!(
1960            !after.contains("[categories]"),
1961            "the section must stay unmaterialized: {after}"
1962        );
1963        assert!(
1964            after.contains("category_map"),
1965            "while the board's own mapping survives: {after}"
1966        );
1967    }
1968
1969    /// What round-trips is the **values** and the `order` vector, not the raw
1970    /// bytes: `category_map` serializes as a `[kanban.<name>.category_map]`
1971    /// sub-table with its keys emitted in whatever order the TOML writer chooses,
1972    /// so the authored inline form
1973    /// `category_map = { triage = "backlog", ... }` is *not* what comes back.
1974    ///
1975    /// That is a contract/code mismatch, and the WP's rule is to fix the
1976    /// contract in the same change rather than let the two disagree — so the
1977    /// contract's example is corrected alongside this.
1978    ///
1979    /// The order that *does* matter, `[categories].order`, is a `Vec` and
1980    /// round-trips exactly. The mapping's key order has no semantic effect: it
1981    /// maps a column to a category, and the columns' presentation order comes
1982    /// from `columns`.
1983    #[test]
1984    fn a_full_category_config_round_trips_with_its_values_and_order() {
1985        let tmp = TempDir::new().unwrap();
1986        // A **non-default** vocabulary, so `[categories]` is written at all.
1987        // `FULL_CATEGORIES` spells out the built-in vocabulary verbatim, which
1988        // makes `CategoryConfig::is_default()` true and the section is then
1989        // *correctly* omitted on save — which is the property the other test
1990        // asserts. Using it here would have tested nothing about the round trip.
1991        let authored = r#"issues_dir = "docs/issues"
1992
1993[categories]
1994order = ["backlog", "active", "completed", "canceled"]
1995default = "backlog"
1996
1997[transitions]
1998terminal_locked = false
1999
2000[kanban.default]
2001columns = ["triage", "wip", "shipped"]
2002done_column = "shipped"
2003category_map = { triage = "backlog", wip = "active", shipped = "completed" }
2004
2005[export]
2006"#;
2007        let path = write_toml(tmp.path(), authored);
2008        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2009        cfg.save(&path).unwrap();
2010        let after = std::fs::read_to_string(&path).unwrap();
2011        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2012
2013        assert_eq!(
2014            reloaded.categories, cfg.categories,
2015            "the vocabulary and its default survive"
2016        );
2017        assert_eq!(
2018            reloaded.categories.order,
2019            vec!["backlog", "active", "completed", "canceled"],
2020            "and `order` is a Vec, so the declared order is preserved exactly"
2021        );
2022
2023        assert_eq!(
2024            reloaded.default_config().category_map,
2025            cfg.default_config().category_map,
2026            "the mapping's contents survive, in whatever order they are written"
2027        );
2028        // Both new sections are written, since neither is at its default.
2029        // Both new sections are written — and only because neither is at its
2030        // default. The first version of this fixture set `terminal_locked = true`,
2031        // which **is** the default, so `[transitions]` was correctly omitted and
2032        // the round trip had nothing to prove. Omitted-when-defaulted is the
2033        // NFR-003 trade this WP makes deliberately, so a fixture that pins the
2034        // default is a fixture that tests nothing.
2035        assert!(after.contains("[categories]"), "after: {after}");
2036        assert!(after.contains("[transitions]"), "after: {after}");
2037        assert!(
2038            !reloaded.transitions.terminal_locked,
2039            "and the value survives"
2040        );
2041
2042        // **Save is idempotent.** The top-level section order is the serializer's
2043        // (struct field) order, not the authored order — the first version of this
2044        // assertion expected the authored order and failed on a correctly-saved
2045        // file. What actually has to hold is that a second save changes nothing,
2046        // which is the property a diff and a merge depend on. The authored order
2047        // that *is* load-bearing is preserved by the two assertions above.
2048        let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
2049        reloaded_again.save(&path).unwrap();
2050        assert_eq!(
2051            std::fs::read_to_string(&path).unwrap(),
2052            after,
2053            "a second save must be a no-op"
2054        );
2055    }
2056
2057    /// Two boards with different column names, one mapped and one not — the
2058    /// intake's first acceptance scenario.
2059    #[test]
2060    fn two_boards_with_different_columns_map_to_the_same_categories() {
2061        let tmp = TempDir::new().unwrap();
2062        let path = write_toml(
2063            tmp.path(),
2064            r#"issues_dir = "docs/issues"
2065
2066[kanban.alpha]
2067columns = ["triage", "wip", "review", "shipped"]
2068done_column = "shipped"
2069category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
2070
2071[kanban.beta]
2072columns = ["new", "doing", "merged"]
2073done_column = "merged"
2074
2075[export]
2076"#,
2077        );
2078        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2079        let alpha = &cfg.kanban.configurations["alpha"];
2080        let beta = &cfg.kanban.configurations["beta"];
2081        // Different column names, same categories: `shipped` and `merged` both
2082        // complete, and both boards' working columns are `active`/`unstarted`.
2083        assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
2084        assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
2085        assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
2086        assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
2087        assert!(beta.category_map.is_empty());
2088    }
2089
2090    /// The vocabulary's load-bearing distinction, asserted directly: `canceled` is
2091    /// terminal and **not** complete. Treating it as complete would change
2092    /// completion meaning, which the contract makes an escalation.
2093    #[test]
2094    fn canceled_is_terminal_but_not_complete() {
2095        assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
2096        assert!(TERMINAL_CATEGORIES.contains(&"completed"));
2097        assert_eq!(
2098            COMPLETE_CATEGORY, "completed",
2099            "completion is defined by this category alone"
2100        );
2101        assert_ne!(COMPLETE_CATEGORY, "canceled");
2102        // And a board may map a column to `canceled` — the category exists and is
2103        // mappable, it simply is not the completing one.
2104        let tmp = TempDir::new().unwrap();
2105        let path = write_toml(
2106            tmp.path(),
2107            r#"issues_dir = "docs/issues"
2108
2109[kanban.default]
2110columns = ["triage", "dropped", "shipped"]
2111done_column = "shipped"
2112category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
2113"#,
2114        );
2115        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2116        let board = cfg.default_config();
2117        assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
2118        assert_ne!(
2119            board.category_for("dropped", &cfg.categories),
2120            COMPLETE_CATEGORY
2121        );
2122    }
2123
2124    /// `blocked` as a category is a workflow column position, not the derived
2125    /// blocked state owned by issue 169's mission. Asserted as distinctness, since
2126    /// conflating them is a named rejected alternative.
2127    #[test]
2128    fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
2129        assert!(CATEGORY_KEYS.contains(&"blocked"));
2130        let tmp = TempDir::new().unwrap();
2131        let path = write_toml(
2132            tmp.path(),
2133            r#"issues_dir = "docs/issues"
2134
2135[kanban.default]
2136columns = ["wip", "on hold", "shipped"]
2137done_column = "shipped"
2138category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
2139"#,
2140        );
2141        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2142        let board = cfg.default_config();
2143        assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
2144        // The derived blocked state lives in the relationship graph, not here.
2145        assert!(
2146            !cfg.default_config()
2147                .category_map
2148                .values()
2149                .any(|v| v.contains("state")),
2150            "a category is a position, never a derived state"
2151        );
2152    }
2153
2154    #[test]
2155    fn legacy_file_roundtrips_byte_identical() {
2156        let tmp = TempDir::new().unwrap();
2157        // A pre-mission tix.toml in the canonical toml pretty form. The save
2158        // path must not add, drop, or reorder any pre-existing key (NFR-003):
2159        // maximum already-pretty formatting proves a true byte-stable save.
2160        let legacy = r#"issues_dir = "docs/issues"
2161
2162[kanban.default]
2163columns = [
2164    "todo",
2165    "in progress",
2166    "in review",
2167    "done",
2168]
2169done_column = "done"
2170hidden_columns = []
2171show_mismatch = false
2172wrap_titles = true
2173title_lines = 3
2174order_by = "created_at_desc"
2175
2176[export]
2177"#;
2178        let path = write_toml(tmp.path(), legacy);
2179        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2180        assert_eq!(cfg.planning, PlanningConfig::default());
2181        assert!(cfg.teams.is_empty());
2182        assert!(cfg.cycles.is_empty());
2183        assert_eq!(cfg.milestones_dir, None);
2184
2185        cfg.save(&path).unwrap();
2186        let on_disk = std::fs::read_to_string(&path).unwrap();
2187        assert_eq!(
2188            on_disk, legacy,
2189            "legacy tix.toml must save byte-identical (NFR-003)"
2190        );
2191        let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2192        assert_eq!(cfg, reloaded);
2193    }
2194
2195    #[test]
2196    fn legacy_file_in_any_layout_never_gains_planning_keys() {
2197        let tmp = TempDir::new().unwrap();
2198        // Hand-written single-line arrays (typical of real repos) parse fine;
2199        // the pretty re-serializer may reformat them, but must never add a
2200        // planning/teams/cycles/milestones key.
2201        let legacy = r#"issues_dir = "docs/issues"
2202[kanban.default]
2203columns = ["todo", "in progress", "in review", "done"]
2204done_column = "done"
2205"#;
2206        let path = write_toml(tmp.path(), legacy);
2207        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2208        cfg.save(&path).unwrap();
2209        let on_disk = std::fs::read_to_string(&path).unwrap();
2210        assert!(!on_disk.contains("milestones_dir"));
2211        assert!(!on_disk.contains("[planning]"));
2212        assert!(!on_disk.contains("[teams]"));
2213        assert!(!on_disk.contains("[cycles"));
2214    }
2215
2216    #[test]
2217    fn planning_default_section_with_partial_keys_loads() {
2218        let tmp = TempDir::new().unwrap();
2219        let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
2220        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2221        assert_eq!(
2222            cfg.planning.cycle_number, 1,
2223            "counter defaults when omitted"
2224        );
2225        assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
2226        assert_eq!(cfg.planning.default_milestone, None);
2227        assert_eq!(cfg.planning.default_team, None);
2228    }
2229
2230    #[test]
2231    fn planning_rejects_unknown_keys() {
2232        let tmp = TempDir::new().unwrap();
2233        let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
2234        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2235        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2236        assert!(err.to_string().contains("counter"));
2237    }
2238
2239    #[test]
2240    fn planning_rejects_zero_cycle_number() {
2241        let tmp = TempDir::new().unwrap();
2242        let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
2243        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2244        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2245        assert!(err.to_string().contains("cycle_number"));
2246    }
2247
2248    #[test]
2249    fn cycle_dates_are_validated_and_compared() {
2250        let tmp = TempDir::new().unwrap();
2251        // parsing failure
2252        let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
2253        let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
2254        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2255        assert!(err.to_string().contains("start_date"));
2256
2257        // ordering failure
2258        let ordered = write_toml(
2259            tmp.path(),
2260            "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
2261        );
2262        let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
2263        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2264        assert!(err.to_string().contains("due_date"));
2265        assert!(err.to_string().contains("start_date"));
2266    }
2267
2268    #[test]
2269    fn cycles_reject_unknown_keys() {
2270        let tmp = TempDir::new().unwrap();
2271        let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
2272        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2273        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2274        assert!(err.to_string().contains("bogus"));
2275    }
2276
2277    #[test]
2278    fn cycle_without_dates_loads_fine() {
2279        let tmp = TempDir::new().unwrap();
2280        let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
2281        let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
2282        assert_eq!(
2283            cfg.cycles.get("Alpha").unwrap().description.as_deref(),
2284            Some("a")
2285        );
2286    }
2287
2288    #[test]
2289    fn teams_validate_members_nonempty_and_unique() {
2290        let tmp = TempDir::new().unwrap();
2291        let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
2292        let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
2293        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2294        assert!(err.to_string().contains("members"));
2295
2296        let dup = write_toml(
2297            tmp.path(),
2298            "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
2299        );
2300        let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
2301        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2302        assert!(err.to_string().contains("unique"));
2303    }
2304
2305    #[test]
2306    fn empty_teams_table_is_disabled_not_error() {
2307        let tmp = TempDir::new().unwrap();
2308        let path = write_toml(tmp.path(), "[teams]\n");
2309        let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2310        assert!(cfg.teams.is_empty());
2311        // An empty [teams] is never written back out.
2312        cfg.save(&path).unwrap();
2313        let on_disk = std::fs::read_to_string(&path).unwrap();
2314        assert!(!on_disk.contains("[teams]"));
2315    }
2316
2317    #[test]
2318    fn blank_milestones_dir_rejected() {
2319        let tmp = TempDir::new().unwrap();
2320        let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
2321        let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2322        assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2323        assert!(err.to_string().contains("milestones_dir"));
2324    }
2325
2326    #[test]
2327    fn sparse_keybindings_roundtrip_without_defaults() {
2328        let tmp = TempDir::new().unwrap();
2329        let path = write_toml(
2330            tmp.path(),
2331            "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
2332        );
2333        let config = Config::load_with_warning(&path, |_| {}).unwrap();
2334        config.save(&path).unwrap();
2335        let contents = std::fs::read_to_string(&path).unwrap();
2336        assert!(contents.contains("[keybindings.board]"));
2337        assert!(!contents.contains("move_left"));
2338        assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
2339    }
2340
2341    #[test]
2342    fn omitted_keybindings_retain_effective_defaults() {
2343        let tmp = TempDir::new().unwrap();
2344        let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
2345        let config = Config::load_with_warning(&path, |_| {}).unwrap();
2346        let bindings = config.effective_keybindings().unwrap();
2347        assert_eq!(
2348            bindings.label(
2349                crate::keybindings::InputContext::Board,
2350                crate::keybindings::TuiAction::NewIssue
2351            ),
2352            "n"
2353        );
2354    }
2355
2356    #[test]
2357    fn keybindings_reject_unknown_fields_and_empty_actions() {
2358        let tmp = TempDir::new().unwrap();
2359        let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
2360        assert!(
2361            Config::load_with_warning(&unknown, |_| {})
2362                .unwrap_err()
2363                .to_string()
2364                .contains("bogus")
2365        );
2366        let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
2367        assert!(
2368            Config::load_with_warning(&empty, |_| {})
2369                .unwrap_err()
2370                .to_string()
2371                .contains("new_issue")
2372        );
2373    }
2374}