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