Skip to main content

tix/config/
models.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// Remote tracker credentials/targets embedded in `tix.toml`.
5///
6/// `token` values are read-only: they are never serialized back out and are
7/// never echoed in logs or errors. `base_url` may override the default
8/// endpoint for self-hosted GitLab instances.
9#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
10#[serde(default, deny_unknown_fields)]
11pub struct RemoteConfig {
12    /// Optional cleartext token; prefer the `GITLAB_TOKEN`/`LINEAR_TOKEN`
13    /// env vars. Never serialized back to disk.
14    pub token: Option<String>,
15    /// Optional base URL (e.g. self-hosted GitLab).
16    pub base_url: Option<String>,
17    /// GitLab project path (e.g. `acme/tix`) passed to `glab`.
18    pub project: Option<String>,
19    /// Linear team name passed to `linear-cli`.
20    pub team: Option<String>,
21}
22
23/// The `[export]` section: per-target remote configuration.
24#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
25#[serde(default, deny_unknown_fields)]
26pub struct ExportConfig {
27    /// GitLab remote target.
28    pub gitlab: Option<RemoteConfig>,
29    /// Linear remote target.
30    pub linear: Option<RemoteConfig>,
31}
32
33impl ExportConfig {
34    /// Whether no remote target is configured, for `skip_serializing_if`.
35    ///
36    /// An empty `[export]` table is never written, so a legacy `tix.toml` gains
37    /// no section on save (#1415).
38    pub fn is_empty(&self) -> bool {
39        self.gitlab.is_none() && self.linear.is_none()
40    }
41}
42
43/// A named team (`[teams.<label>]` in `tix.toml`). The label lives as the
44/// map key; `members` are free-form identities feeding the assignee/reviewer
45/// pickers only (never constraining stored values, DM-01M311MRK517KRHR426QPNP6P7).
46///
47/// An absent/empty `[teams]` section means teams are disabled (today's
48/// behavior), and an empty `[teams]` table is never written back out.
49#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(default, deny_unknown_fields)]
51pub struct Team {
52    /// Non-empty per team; unique within the team.
53    pub members: Vec<String>,
54}
55
56/// A named cycle (`[cycles.<name>]` in `tix.toml`). The name lives as the
57/// map key; `start_date`/`due_date` are optional `YYYY-MM-DD` strings with
58/// `due_date >= start_date` when both are present.
59#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(default, deny_unknown_fields)]
61pub struct PlanningCycle {
62    /// Optional human description.
63    pub description: Option<String>,
64    /// Optional start date (`YYYY-MM-DD`).
65    pub start_date: Option<String>,
66    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
67    pub due_date: Option<String>,
68}
69
70/// A project record (`[projects.<project-id>]` in `tix.toml`).
71///
72/// The stable [`ProjectId`] lives as the map key; `name` is a mutable display
73/// label and is **not** the identity (C-002). The section is omitted entirely when
74/// there are no projects, so a legacy `tix.toml` is byte-untouched (C-001).
75///
76/// `status` is project metadata and is deliberately a **free-form string**: it is
77/// not the issue's board column, and workflow-*category* semantics belong to
78/// source issue #183, so this section introduces no global category model (C-007).
79/// `lead` is an identity **string** in the same shape the existing `assignee`
80/// field tolerates (bare handle or `Name <email>`); it is not a user account, a
81/// directory entry, or a team membership (C-007).
82#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
83#[serde(default, deny_unknown_fields)]
84pub struct ProjectConfiguration {
85    /// Display label. Required and unique within the registry, so interactive
86    /// selection is unambiguous — but never the identity, which is the map key.
87    pub name: String,
88    /// Optional long-form context.
89    pub description: Option<String>,
90    /// Optional project status (free-form; not a board column, not a category).
91    pub status: Option<String>,
92    /// Optional lead identity string; no lookup, no user-identity concept.
93    pub lead: Option<String>,
94    /// Optional start date (`YYYY-MM-DD`).
95    pub start_date: Option<String>,
96    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
97    pub due_date: Option<String>,
98}
99
100/// An epic record (`[epics.<epic-id>]` in `tix.toml`).
101///
102/// Mission `epics-work-items-01M3BYW4` (WP01). The stable [`EpicId`] lives as the
103/// map key; `name` is a mutable display label and is **not** the identity (C-003),
104/// so a rename leaves every member issue's reference and every derived figure
105/// intact. The section is omitted entirely when there are no epics, so a legacy
106/// `tix.toml` is byte-untouched (C-004).
107///
108/// `status` is epic metadata and is deliberately a **free-form string**, exactly
109/// like [`ProjectConfiguration::status`]: it is not an issue's board column, and
110/// workflow-*category* semantics belong to source issue #183, so no global category
111/// model is introduced here. `owner` is an identity **string** in the same shape the
112/// existing `assignee` field tolerates (bare handle or `Name <email>`); it is not a
113/// user account, a directory entry, or a team membership (C-003).
114///
115/// `weight` is epic metadata used in epic rollups — a positive relative size. It is
116/// **not** a size field on issues: `Issue.estimate` stays the free-form string it is,
117/// and no typed size concept is introduced (research R3). A missing weight
118/// contributes nothing to a weighted figure.
119///
120/// `parent` is an **epic id** and `projects` are **project ids**, while `milestone`
121/// and `cycle` are the **name-keyed** references the existing registries use. That
122/// asymmetry is deliberate — it matches how `Issue.cycles`/`Issue.milestones`
123/// already work — and is recorded in `contracts/epic-registry.md` so it is not
124/// "corrected" by accident.
125///
126/// References are validated for **shape** only. Whether a project, milestone, cycle,
127/// or parent epic exists is resolved by the epic service and diagnosed by
128/// `tix lint`; a dangling reference must stay loadable and repairable, exactly as a
129/// view's project scope does (C-005).
130#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
131#[serde(default, deny_unknown_fields)]
132pub struct EpicConfiguration {
133    /// Display label. Required and unique within the registry, so selection is
134    /// unambiguous — but never the identity, which is the map key.
135    pub name: String,
136    /// Optional long-form context.
137    pub description: Option<String>,
138    /// Optional epic status (free-form; not a board column, not a category).
139    pub status: Option<String>,
140    /// Optional owner identity string; no lookup, no user-identity concept.
141    pub owner: Option<String>,
142    /// Optional start date (`YYYY-MM-DD`).
143    pub start_date: Option<String>,
144    /// Optional due date (`YYYY-MM-DD`); must be `>= start_date`.
145    pub due_date: Option<String>,
146    /// Optional positive relative size, used in epic rollups. Never an issue size.
147    pub weight: Option<i64>,
148    /// Optional parent epic **id**; shape is checked here, existence is not.
149    pub parent: Option<String>,
150    /// Project **ids** this epic concerns; shape is checked here, existence is not.
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub projects: Vec<String>,
153    /// Optional milestone **name** (the existing name-keyed reference).
154    pub milestone: Option<String>,
155    /// Optional cycle **name** (the existing name-keyed reference).
156    pub cycle: Option<String>,
157}
158
159/// An initiative record (`[initiatives.<initiative-id>]` in `tix.toml`).
160///
161/// Mission `roadmap-initiatives-01M3C8SX` (WP01). The third stable-ID record, after
162/// [`ProjectConfiguration`] and [`EpicConfiguration`]: the [`InitiativeId`] is the
163/// map key and `name` is a mutable display label, so a rename leaves every
164/// association and derived figure intact (C-002). The section is omitted entirely
165/// when there are no initiatives, so a legacy `tix.toml` is byte-untouched (C-001).
166///
167/// # Preserved extensions — deliberately unlike its siblings
168///
169/// This record does **not** set `deny_unknown_fields`. The data model lists
170/// `extensions` as preserved unknown data, so an unknown key is carried through
171/// [`InitiativeExtensions`](crate::roadmap::registry::InitiativeExtensions) and
172/// round-trips unchanged rather than making `tix.toml` unreadable. *The trade-off:
173/// a typo'd key is preserved rather than refused here*, which is the cost of the
174/// forward-compatibility guarantee — the siblings that deny unknown fields pay the
175/// opposite cost.
176///
177/// # Metadata is not issue state
178///
179/// `status` and `priority` are initiative metadata, **not** an issue's board column
180/// or priority: `status` is free-form exactly like [`ProjectConfiguration::status`],
181/// and `priority` reuses [`crate::model::Priority`] because its serde shape is a
182/// plain string that round-trips an unknown value verbatim (a known spelling is
183/// normalized to lowercase, the same rule issues already use). `owner` is an
184/// identity **string** with no lookup, never inferred from anything.
185///
186/// # Associations are explicit, shape-checked, and never resolved here
187///
188/// `projects` are project **ids**, `epics` are epic **ids**, and `milestones` are
189/// the **names** the existing milestone registry uses — the same id/name asymmetry
190/// the epic contract records. Every entry is validated for **shape** only: whether
191/// its target exists is resolved by the service (WP03) and diagnosed by `tix lint`,
192/// so a dangling reference stays loadable and repairable (C-005). A repeated entry
193/// is idempotent (see [`crate::roadmap::registry`]).
194#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
195#[serde(default)]
196pub struct InitiativeConfiguration {
197    /// Display label. Required and unique within the registry (case-folded), so
198    /// selection is unambiguous — but never the identity, which is the map key.
199    pub name: String,
200    /// Optional strategic outcome.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub outcome: Option<String>,
203    /// Optional owner identity **string**; no lookup, never inferred from anything.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub owner: Option<String>,
206    /// Optional initiative status (free-form; not a board column, not a category).
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub status: Option<String>,
209    /// Optional initiative priority (see [`crate::model::Priority`]).
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub priority: Option<crate::model::Priority>,
212    /// Filterable labels; a repeat collapses to one on the write path.
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub labels: Vec<String>,
215    /// Optional calendar target (`YYYY-MM-DD`).
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub target_date: Option<String>,
218    /// Project **ids** this initiative concerns; shape checked here, existence not.
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub projects: Vec<String>,
221    /// Epic **ids** this initiative concerns; shape checked here, existence not.
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub epics: Vec<String>,
224    /// Milestone **names** this initiative concerns (the name-keyed reference).
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub milestones: Vec<String>,
227    /// Unknown metadata, preserved verbatim (see the type-level note).
228    #[serde(flatten)]
229    pub extensions: crate::roadmap::registry::InitiativeExtensions,
230}
231
232/// The `[planning]` section: the monotonic auto-cycle counter plus the
233/// picker presets written by `tix init` (`contracts/tix-toml-planning.md`).
234///
235/// `cycle_number` is the next auto "Cycle N" number: it starts at 1, is bumped
236/// on every unnamed cycle create, and never decrements (FR-003,
237/// DM-01M311BJKMN3PPCR9GR9Y7ESD2). Contrary to the legacy `[state]` counter,
238/// this section **is** serialized once mutated.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(default, deny_unknown_fields)]
241pub struct PlanningConfig {
242    /// Monotonic next auto-cycle number; `>= 1`. Default `1`.
243    pub cycle_number: u32,
244    /// Optional picker preset naming a cycle.
245    pub default_cycle: Option<String>,
246    /// Optional picker preset naming a milestone.
247    pub default_milestone: Option<String>,
248    /// Optional picker preset naming a team.
249    pub default_team: Option<String>,
250}
251
252impl PlanningConfig {
253    /// Whether this section is untouched (all values at their defaults); such
254    /// a section is not written to disk so legacy `tix.toml` files keep
255    /// round-tripping byte-identically (NFR-003).
256    pub fn is_default(&self) -> bool {
257        self == &Self::default()
258    }
259}
260
261impl Default for PlanningConfig {
262    fn default() -> Self {
263        Self {
264            cycle_number: 1,
265            default_cycle: None,
266            default_milestone: None,
267            default_team: None,
268        }
269    }
270}
271
272/// A named new-issue template (`[templates.<name>]` in `tix.toml`).
273///
274/// The F4 New Issue mode (`tui-new-issue-mode`) lists these by name and, on
275/// selection, pre-fills the form's rows from the fields the template sets.
276/// Every field is optional, the section is absent by default, and an empty
277/// `[templates]` table writes no key — so a project that never writes one pays
278/// nothing and its `tix.toml` keeps round-tripping unchanged.
279///
280/// A template carries **content**, not identity: `number`, `created`,
281/// `filename` and `History` are creation outputs rather than inputs, so they are
282/// not fields here. `body` and `labels` are the two the mode's spec requires;
283/// the rest exist because the form already has a row for them, and a template
284/// that could not reach a row would only half-fill the form.
285///
286/// ```toml
287/// [templates.bug]
288/// labels = ["bug"]
289/// body = "## Steps to reproduce\n\n"
290/// ```
291#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
292#[serde(default, deny_unknown_fields)]
293pub struct IssueTemplate {
294    /// Pre-filled title. Optional: a template that sets none leaves the title to
295    /// the user, and the title stays required at submit either way.
296    pub title: Option<String>,
297    /// Pre-filled issue body (Markdown).
298    pub body: Option<String>,
299    /// Pre-filled labels.
300    pub labels: Vec<String>,
301    /// Pre-filled status column.
302    pub status: Option<String>,
303    /// Pre-filled priority.
304    pub priority: Option<String>,
305    /// Pre-filled assignee.
306    pub assignee: Option<String>,
307    /// Pre-filled estimate.
308    pub estimate: Option<String>,
309    /// Pre-filled reviewer.
310    pub reviewer: Option<String>,
311    /// Pre-filled cycles.
312    pub cycles: Vec<String>,
313    /// Pre-filled milestones.
314    pub milestones: Vec<String>,
315    /// Pre-filled start date (`YYYY-MM-DD`).
316    pub start_date: Option<String>,
317    /// Pre-filled due date (`YYYY-MM-DD`).
318    pub due_date: Option<String>,
319}
320
321/// The `[state]` section — deprecated runtime bookkeeping that is still
322/// tolerated on load for legacy `tix.toml` files but is never serialized.
323///
324/// The monotonic `next_number` counter this held was removed in issue #52:
325/// issue numbers are now resolved at creation time by scanning the live
326/// issue set, so [`Config::save`] writes no `[state]` section at all.
327#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
328#[serde(default, deny_unknown_fields)]
329pub struct StateConfig {
330    /// Legacy monotonic issue-number counter (C-007). Read-only — kept so
331    /// old `tix.toml` files with `[state].next_number` still load, and
332    /// never persisted once saved again.
333    #[serde(skip_serializing)]
334    pub next_number: u32,
335}
336
337/// Card ordering for the rendered board (`[kanban.<name>].order_by`).
338///
339/// Applied per column in the board builders; the store's underlying order is
340/// untouched so the CLI keeps its own sequencing (board-display FR-02).
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
342pub enum OrderBy {
343    /// Most recently created issues first (the default).
344    #[default]
345    #[serde(rename = "created_at_desc")]
346    CreatedAtDesc,
347    /// Oldest issues first.
348    #[serde(rename = "created_at_asc")]
349    CreatedAtAsc,
350    /// Preserve the underlying store (filename/created-date ascending) order.
351    #[serde(rename = "none")]
352    None,
353}
354
355impl OrderBy {
356    /// The TOML spelling of this variant.
357    pub fn as_str(self) -> &'static str {
358        match self {
359            Self::CreatedAtDesc => "created_at_desc",
360            Self::CreatedAtAsc => "created_at_asc",
361            Self::None => "none",
362        }
363    }
364
365    /// The next variant when cycling the setting in the TUI settings modal.
366    pub fn next(self) -> Self {
367        match self {
368            Self::CreatedAtDesc => Self::CreatedAtAsc,
369            Self::CreatedAtAsc => Self::None,
370            Self::None => Self::CreatedAtDesc,
371        }
372    }
373}
374
375/// The fixed, global category vocabulary (C-002, contract §1).
376///
377/// One list, referenced by the config validator, the per-board mapping and the
378/// service WP02 builds. A vocabulary spelled out in three places is a vocabulary
379/// that disagrees with itself the first time one of them changes.
380///
381/// `TERMINAL` is the subset that ends the workflow. `canceled` is terminal but
382/// **not** complete — `COMPLETE` is deliberately a separate one-element list rather
383/// than `!TERMINAL.contains(...)`, because "terminal" and "complete" are different
384/// claims and the contract makes conflating them an escalation.
385pub const CATEGORY_KEYS: &[&str] = &[
386    "backlog",
387    "unstarted",
388    "active",
389    "blocked",
390    "completed",
391    "canceled",
392];
393
394/// Categories that end the workflow.
395pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
396
397/// The **only** category that means done. `canceled` is terminal without being
398/// complete, so this is a one-element list and not "everything terminal".
399pub const COMPLETE_CATEGORY: &str = "completed";
400
401/// The documented safe default for a column with no mapping (contract §4).
402///
403/// Non-terminal and non-complete, so an unmapped column is never silently counted
404/// as done. A default that resolved to `completed` would change completion
405/// meaning by accident, which the contract makes an escalation rather than a
406/// convenience — [`validate_categories`] refuses one.
407pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
408
409/// `[categories]`: the global vocabulary and the safe default.
410///
411/// Omitted from disk when untouched, so a legacy `tix.toml` round-trips
412/// byte-identically and gains no new keys (NFR-003, C-007).
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414#[serde(default, deny_unknown_fields)]
415pub struct CategoryConfig {
416    /// The vocabulary, in the project's preferred order for presentation.
417    ///
418    /// Defaults to [`CATEGORY_KEYS`]. A configured list is still validated
419    /// against that vocabulary: a board maps columns *into* the global
420    /// vocabulary, it does not define its own (C-002), so adding a key here is
421    /// an `unknown_category` error rather than a new category.
422    pub order: Vec<String>,
423    /// The category an unmapped column resolves to. Must be in `order`, and must
424    /// not be [`COMPLETE_CATEGORY`].
425    pub default: String,
426}
427
428impl Default for CategoryConfig {
429    fn default() -> Self {
430        Self {
431            order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
432            default: SAFE_DEFAULT_CATEGORY.to_string(),
433        }
434    }
435}
436
437impl CategoryConfig {
438    /// Whether this section is untouched, and so is not written to disk.
439    pub fn is_default(&self) -> bool {
440        self == &Self::default()
441    }
442
443    /// Whether `key` is in the configured vocabulary.
444    pub fn contains(&self, key: &str) -> bool {
445        self.order.iter().any(|k| k == key)
446    }
447}
448
449/// `[transitions]`: the workflow policy.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(default, deny_unknown_fields)]
452pub struct TransitionConfig {
453    /// Whether an issue in a terminal category refuses an ordinary move.
454    ///
455    /// Default `true`. "Ordinary" is load-bearing: the escape hatch WP02 settles is
456    /// a *reasoned* transition out of a terminal category, not a silent
457    /// reassignment, so this switch governs the second and not the first.
458    pub terminal_locked: bool,
459}
460
461impl Default for TransitionConfig {
462    fn default() -> Self {
463        Self {
464            terminal_locked: true,
465        }
466    }
467}
468
469impl TransitionConfig {
470    pub fn is_default(&self) -> bool {
471        self == &Self::default()
472    }
473}
474
475/// The default number of lines a wrapped card title may occupy
476/// ([`BoardConfiguration::title_lines`]). Named so the manual `Serialize` can
477/// recognise it without repeating the literal (#1415).
478pub const DEFAULT_TITLE_LINES: usize = 3;
479
480/// The `done_column` a board gets when the key is absent.
481///
482/// `BoardConfiguration` is `#[serde(default)]` at the container level, so a
483/// missing `done_column` is filled from [`BoardConfiguration::default`] — this
484/// value, not an inference from `columns`. Named so the manual `Serialize` can
485/// omit a `done_column` that already equals it (#1415).
486pub const DEFAULT_DONE_COLUMN: &str = "done";
487
488/// Health policy configuration (`[health]` in `tix.toml`; mission
489/// `project-issue-health-status-01M3C8HX`, WP01 T002/T003).
490///
491/// One key, and that is the design: the only thing a repository *chooses* about
492/// health is which **version** of the derivation policy it is on. The vocabulary
493/// and the formula live in the build ([`crate::health::policy`]), because changing
494/// either changes what every stored value means — an escalation with its own plan
495/// (C-003), not a line in a config file. A version this build does not implement is
496/// refused at load, by name.
497///
498/// Omitted when absent, so a repository that has never mentioned health gains no
499/// section and a legacy `tix.toml` round-trips byte-identically.
500#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(default, deny_unknown_fields)]
502pub struct HealthConfig {
503    /// The policy version in force; absent means [`crate::health::HEALTH_POLICY_VERSION`].
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub policy: Option<String>,
506}
507
508impl HealthConfig {
509    /// Whether nothing is configured: the state the schema omits on save.
510    pub fn is_empty(&self) -> bool {
511        self.policy.is_none()
512    }
513
514    /// The policy in force: the configured version, or the current one (T002).
515    pub fn policy(&self) -> std::result::Result<crate::health::HealthPolicy, TixError> {
516        match self.policy.as_deref() {
517            Some(version) => crate::health::HealthPolicy::for_version(version)
518                .map_err(|error| TixError::Config(error.render())),
519            None => Ok(crate::health::HealthPolicy::v1()),
520        }
521    }
522}
523
524/// A named board configuration (`[kanban.<name>]` in `tix.toml`).
525///
526/// Carries the ordered column list plus per-configuration metadata: the done
527/// column, columns hidden from the rendered board, and whether the computed
528/// "Mismatched" pseudo-column is shown (C-002 / DM-01M2MH4D84NHG0JVWEPR6ZH4VR).
529///
530/// `Serialize` is written by hand (below) so every field equal to the value the
531/// loader would supply is omitted on save, keeping a legacy `tix.toml`
532/// byte-identical; `Deserialize` stays derived.
533#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
534#[serde(default, deny_unknown_fields)]
535pub struct BoardConfiguration {
536    /// Ordered, case-sensitive, non-empty, unique column names.
537    pub columns: Vec<String>,
538    /// The column treated as "done" (commit prompting). Defaults to `"done"`
539    /// when that name is present in `columns`, else the last configured
540    /// column (contracts/tix-toml.md field rules).
541    ///
542    /// **Its meaning is unchanged by this mission.** It remains the commit-prompt
543    /// trigger and the legacy completion signal. WP02 reads the *category* for the
544    /// completion decision; this field is not reinterpreted here, and a
545    /// `category_map` does not change what it does.
546    pub done_column: Option<String>,
547    /// Column name → category key (contract §2). Omitted when empty, so a board
548    /// without a mapping gains no key on save.
549    ///
550    /// A board with **no** map is a legacy board: its `done_column` resolves to
551    /// `completed` and every other column to the safe default. See
552    /// [`BoardConfiguration::category_for`], which is the single place that
553    /// fallback lives — duplicating it in the service is how a board ends up with
554    /// two notions of its own default.
555    pub category_map: IndexMap<String, String>,
556    /// Columns hidden from the rendered board. Must be a subset of `columns`;
557    /// their cards stay in board state and remain reachable via filters/search.
558    pub hidden_columns: Vec<String>,
559    /// Whether the computed "Mismatched" pseudo-column is rendered. Default
560    /// `false`. Never a configured column.
561    pub show_mismatch: bool,
562    /// Whether long card titles wrap onto multiple lines. Default `true`.
563    pub wrap_titles: bool,
564    /// The maximum number of lines a wrapped card may occupy. Default `3`;
565    /// validated `>= 1` in [`validate_board`]. Ignored when `wrap_titles`
566    /// is `false`.
567    pub title_lines: usize,
568    /// Sparse manual card order persisted by drag-and-drop reordering. When
569    /// non-empty, this order takes precedence over [`order_by`] for the cards
570    /// whose positions differ from the automatic ordering.
571    pub card_order: Vec<String>,
572    /// Ordering of cards within each rendered column. Default
573    /// `created_at_desc` (most recently created first).
574    pub order_by: OrderBy,
575    /// Per-column work-in-progress limits and the estimate units they count in
576    /// (mission `configurable-wip-limits-01M3C6WX`). Absent by default and omitted
577    /// when empty, so a board that configures no limits gains no key on save — and
578    /// **no status semantics change**: the columns, their categories and the done
579    /// column mean exactly what they meant before (C-002).
580    pub wip: crate::wip::WipConfig,
581}
582
583impl Default for BoardConfiguration {
584    fn default() -> Self {
585        Self {
586            columns: ["todo", "in progress", "in review", "done"]
587                .into_iter()
588                .map(String::from)
589                .collect(),
590            done_column: Some(DEFAULT_DONE_COLUMN.to_string()),
591            category_map: IndexMap::new(),
592            hidden_columns: Vec::new(),
593            show_mismatch: false,
594            wrap_titles: true,
595            title_lines: DEFAULT_TITLE_LINES,
596            card_order: Vec::new(),
597            order_by: OrderBy::default(),
598            wip: crate::wip::WipConfig::default(),
599        }
600    }
601}
602
603impl Serialize for BoardConfiguration {
604    /// Serialize a board as a `[kanban.<name>]` table, omitting **every field
605    /// that already equals the value the loader would supply** (#1415).
606    ///
607    /// `columns` is always written — a board without columns is not a board —
608    /// and `category_map`/`card_order` were already omitted when empty. The
609    /// display and ordering keys (`hidden_columns`, `show_mismatch`,
610    /// `wrap_titles`, `title_lines`, `order_by`) are omitted at their defaults,
611    /// and `done_column` when it already equals [`DEFAULT_DONE_COLUMN`] — the
612    /// value the loader fills in when the key is absent, since the container is
613    /// `#[serde(default)]`. `Deserialize` stays derived, so the round trip is a
614    /// fixed point: omitting a default writes nothing and loading a missing key
615    /// restores the same value.
616    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
617    where
618        S: Serializer,
619    {
620        let done = self
621            .done_column
622            .as_deref()
623            .filter(|done| *done != DEFAULT_DONE_COLUMN);
624        let order_by = (self.order_by != OrderBy::default()).then_some(self.order_by);
625
626        let mut len = 1; // `columns` is always written.
627        len += usize::from(done.is_some());
628        len += usize::from(!self.category_map.is_empty());
629        len += usize::from(!self.hidden_columns.is_empty());
630        len += usize::from(self.show_mismatch);
631        len += usize::from(!self.wrap_titles);
632        len += usize::from(self.title_lines != DEFAULT_TITLE_LINES);
633        len += usize::from(!self.card_order.is_empty());
634        len += usize::from(order_by.is_some());
635        len += usize::from(!self.wip.is_empty());
636
637        let mut board = serializer.serialize_struct("BoardConfiguration", len)?;
638        board.serialize_field("columns", &self.columns)?;
639        if let Some(done) = done {
640            board.serialize_field("done_column", done)?;
641        }
642        if !self.category_map.is_empty() {
643            board.serialize_field("category_map", &self.category_map)?;
644        }
645        if !self.hidden_columns.is_empty() {
646            board.serialize_field("hidden_columns", &self.hidden_columns)?;
647        }
648        if self.show_mismatch {
649            board.serialize_field("show_mismatch", &self.show_mismatch)?;
650        }
651        if !self.wrap_titles {
652            board.serialize_field("wrap_titles", &self.wrap_titles)?;
653        }
654        if self.title_lines != DEFAULT_TITLE_LINES {
655            board.serialize_field("title_lines", &self.title_lines)?;
656        }
657        if !self.card_order.is_empty() {
658            board.serialize_field("card_order", &self.card_order)?;
659        }
660        if let Some(order_by) = order_by {
661            board.serialize_field("order_by", &order_by)?;
662        }
663        if !self.wip.is_empty() {
664            board.serialize_field("wip", &self.wip)?;
665        }
666        board.end()
667    }
668}
669
670/// The board a **new repository** starts with (`tix init`), rather than the bare
671/// [`BoardConfiguration::default`] a config-less or partial file falls back to.
672///
673/// It carries the two columns the triage inbox needs: `triage` (where unreviewed work
674/// lands — `[triage].entry_status`) and `wontfix` (where a decline goes; it has to
675/// exist, because `declined_status` must resolve to the `canceled` category).
676///
677/// **Only those two are mapped**, and that is deliberate. The map is what makes a
678/// board `explicit_policy`, and an explicit policy brings *adjacency*: a category may
679/// move to itself or forward through the declared order, never backwards. Mapping the
680/// working columns would have to call them `unstarted` or `active`, and then
681/// `in progress -> todo` — a move people make every day when a review finds a problem
682/// — would be refused outright, with no `--reason` to override it. Leaving them
683/// unmapped resolves them all to the same safe default, so movement among them stays
684/// free while `done`/`wontfix` are terminal, which is the discipline a new repository
685/// can *opt into* later (`tix lint` names the unmapped columns and how to map them).
686pub fn starter_board() -> BoardConfiguration {
687    BoardConfiguration {
688        columns: [
689            "triage",
690            "todo",
691            "in progress",
692            "in review",
693            "done",
694            "wontfix",
695        ]
696        .into_iter()
697        .map(String::from)
698        .collect(),
699        category_map: IndexMap::from([
700            ("done".to_string(), "completed".to_string()),
701            ("wontfix".to_string(), "canceled".to_string()),
702        ]),
703        ..BoardConfiguration::default()
704    }
705}
706
707impl BoardConfiguration {
708    /// The category `column` resolves to, given the global configuration.
709    ///
710    /// The single place the fallback lives, and the seam WP02's service builds
711    /// on. Three cases, in order:
712    ///
713    /// 1. An explicit `category_map` entry wins.
714    /// 2. Otherwise, on a board with **no** map at all, `done_column` is
715    ///    `completed` and every other column is the safe default.
716    /// 3. Otherwise (a map exists but omits this column), the safe default.
717    ///
718    /// Case 2 is a reconciliation, and the contract's two rules collide without
719    /// it: *"a board without a `category_map` inherits `[categories].default` for
720    /// every column"* (§2) plus *"no column maps to `completed` is a configuration
721    /// error"* (§3) would reject every legacy `tix.toml`, while §2 also requires
722    /// that one to load unchanged (C-007). `done_column` is the resolution: it is
723    /// always a configured column, it already means "this column completes work",
724    /// and using it keeps a legacy board's completion behaviour exactly what it
725    /// was. A board that *does* declare a map gets no such help — a map that
726    /// forgot `completed` is a real mistake and is reported as one.
727    pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
728        if let Some(mapped) = self.category_map.get(column) {
729            return mapped;
730        }
731        if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
732            return COMPLETE_CATEGORY;
733        }
734        &categories.default
735    }
736
737    /// The resolved done column: the explicit `done_column`, else the one
738    /// [`BoardConfiguration::inferred_done_column`] derives from `columns`.
739    pub fn effective_done_column(&self) -> Option<&str> {
740        self.done_column
741            .as_deref()
742            .or_else(|| self.inferred_done_column())
743    }
744
745    /// The `done_column` inferred from `columns` alone, for a board built in
746    /// code rather than read from a file: `"done"` when that name is present,
747    /// else the last configured column. `None` only for empty `columns` (which
748    /// validation rejects).
749    ///
750    /// Split out of [`BoardConfiguration::effective_done_column`] so the
751    /// inference lives in one place.
752    fn inferred_done_column(&self) -> Option<&str> {
753        if self.columns.iter().any(|c| c == DEFAULT_DONE_COLUMN) {
754            Some(DEFAULT_DONE_COLUMN)
755        } else {
756            self.columns.last().map(String::as_str)
757        }
758    }
759}
760
761/// The `[kanban]` section: named configurations in declaration order.
762///
763/// `#[serde(transparent)]` maps the TOML table `[kanban.<name>]` straight
764/// onto the [`IndexMap`]. The `toml` parser sorts table keys, so
765/// [`Config::load_with_warning`] re-orders the map from the source
766/// document's `[kanban.<name>]` header order via
767/// [`restore_declaration_order`] (D-02: declaration order = tab order,
768/// validated by the round-trip test). The map is never empty after load —
769/// [`Config::load_with_warning`] synthesizes the shipped `default`
770/// configuration when `tix.toml` omits the section.
771#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
772#[serde(default, transparent)]
773pub struct KanbanSection {
774    /// Named configurations, keyed by name, in declaration order.
775    pub configurations: IndexMap<String, BoardConfiguration>,
776}
777
778impl KanbanSection {
779    /// Whether no board is configured. An empty section writes no `[kanban]`
780    /// table, which is how a board-file project keeps its `tix.toml` free of
781    /// board records.
782    pub fn is_empty(&self) -> bool {
783        self.configurations.is_empty()
784    }
785}
786
787/// The `[beads]` section: how this repository talks to a beads workspace.
788///
789/// The `binary` and `id_prefix` are stated rather than inferred so a workspace
790/// that renames its `bd` binary, or wants its exported rows under a different
791/// prefix, can say so without patching tix.
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(default, deny_unknown_fields)]
794pub struct BeadsConfig {
795    /// Whether tix talks to a beads workspace at all, and how.
796    pub mode: BeadsMode,
797    /// The binary shelled out to. Default `bd`.
798    pub binary: String,
799    /// The beads workspace to talk to, when it is not the one `bd` would find.
800    ///
801    /// Set into `BEADS_DIR` for every `bd` call, which is beads' own override.
802    /// `None` means "wherever `bd` looks", which is what a beads workspace in
803    /// this repository (or in a parent) needs.
804    #[serde(skip_serializing_if = "Option::is_none")]
805    pub workspace: Option<PathBuf>,
806    /// The prefix exported rows carry: issue `#1705` becomes `tix-1705`.
807    ///
808    /// Deterministic, which is what makes a re-export upsert rather than
809    /// duplicate.
810    pub id_prefix: String,
811}
812
813impl Default for BeadsConfig {
814    fn default() -> Self {
815        BeadsConfig {
816            mode: BeadsMode::default(),
817            binary: crate::sync::beads::DEFAULT_BINARY.to_string(),
818            workspace: None,
819            id_prefix: crate::sync::beads::DEFAULT_ID_PREFIX.to_string(),
820        }
821    }
822}
823
824impl BeadsConfig {
825    /// Whether this section carries nothing worth writing: the default section is
826    /// omitted, so a repository that never opted in gains no `[beads]` key on save
827    /// — and an older binary (whose `Config` is `deny_unknown_fields`) can still
828    /// read the file.
829    #[must_use]
830    pub fn is_default(&self) -> bool {
831        *self == BeadsConfig::default()
832    }
833}
834
835/// How tix talks to a beads workspace ([`BeadsConfig::mode`]).
836#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
837#[serde(rename_all = "lowercase")]
838pub enum BeadsMode {
839    /// No beads interaction. The default, and what a repository without beads
840    /// carries.
841    #[default]
842    Off,
843    /// Read a beads workspace through `bd` for viewing only; tix writes nothing —
844    /// neither to beads nor to an issue file.
845    Mirror,
846    /// Keep tix and beads in step through two one-way pipes
847    /// (`tix` → `bd import`, and beads' own hooks → `tix beads pull`), joined by a
848    /// watermark so neither side echoes the other's change back.
849    Sync,
850}
851
852impl BeadsMode {
853    /// The `--beads` spelling.
854    #[must_use]
855    pub fn as_str(self) -> &'static str {
856        match self {
857            BeadsMode::Off => "off",
858            BeadsMode::Mirror => "mirror",
859            BeadsMode::Sync => "sync",
860        }
861    }
862
863    /// Parse the `--beads` spelling, or `None` for anything outside the vocabulary
864    /// (which the caller reports rather than coercing).
865    #[must_use]
866    pub fn from_key(key: &str) -> Option<Self> {
867        match key {
868            "off" => Some(BeadsMode::Off),
869            "mirror" => Some(BeadsMode::Mirror),
870            "sync" => Some(BeadsMode::Sync),
871            _ => None,
872        }
873    }
874
875    /// Every mode, in the vocabulary's order.
876    pub const ALL: [BeadsMode; 3] = [BeadsMode::Off, BeadsMode::Mirror, BeadsMode::Sync];
877}
878
879/// The `[custom_attributes]` section: top-level frontmatter keys this project
880/// carries on purpose (#1361).
881///
882/// A **section** rather than a bare top-level array so there is somewhere to put
883/// the next option, and so the declaration reads as a named decision rather than as
884/// one more anonymous list in a file that is mostly named sections.
885///
886/// ```toml
887/// [custom_attributes]
888/// keys = ["build_id", "reviewed_by"]
889/// ```
890#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
891#[serde(default, deny_unknown_fields)]
892pub struct CustomAttributes {
893    /// Top-level frontmatter key names. Names only — no dotted paths; the thing
894    /// being declared is a top-level key, and a path syntax here would imply nested
895    /// declarations are checked when they are not.
896    pub keys: Vec<String>,
897}
898
899impl CustomAttributes {
900    /// Whether anything is declared, for `skip_serializing_if`.
901    pub fn is_empty(&self) -> bool {
902        self.keys.is_empty()
903    }
904}
905
906/// The default issue types: what `tix init` writes into `types`, and what
907/// `tix lint` accepts when a repository carries no `types`.
908///
909/// `default` is the value an issue without a `type:` falls back to (in `[layout]`
910/// and as an explicit option). `bug`, `open` and `duplicate` are the requested
911/// baseline, with the usual companions.
912pub const DEFAULT_TYPES: &[&str] = &[
913    "default",
914    "bug",
915    "feature",
916    "task",
917    "improvement",
918    "documentation",
919    "question",
920    "duplicate",
921    "chore",
922    "open",
923];
924
925/// Labels tix itself ships and always recognises — no configuration required.
926///
927/// `decision-pending` is the label behind the `DECISION:` convention: an open
928/// question a human must settle before the issue can move. It is a **builtin**,
929/// not a config default, so every repository resolves it whether or not its
930/// `tix.toml` names it — and `tix init` does not write a `labels` key for it (a
931/// new key would be rejected by an older binary's `deny_unknown_fields`).
932pub const BUILTIN_LABELS: &[&str] = &["decision-pending"];
933
934/// A shipped accent color per default type (tix #1530).
935///
936/// The color names are the ones [`crate::ui::color::parse`] understands. A type
937/// absent here — and absent from `[type_colors]` — renders uncolored. `open` and
938/// `default` are deliberately neutral (gray/white) so a type chip does not
939/// compete with the priority and blocked markers beside it.
940pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
941    ("default", "gray"),
942    ("bug", "red"),
943    ("feature", "green"),
944    ("task", "blue"),
945    ("improvement", "cyan"),
946    ("documentation", "yellow"),
947    ("question", "magenta"),
948    ("duplicate", "dark_gray"),
949    ("chore", "dark_gray"),
950    ("open", "white"),
951];
952
953/// The `[mentions]` section: durable `@handle` / `@email` mention tracking.
954///
955/// Default **enabled**, unlike the opt-in `[triage]` section: the feature is a scan that runs
956/// on an issue write that already happened, and its footprint is bounded on the other side —
957/// a mention file is created lazily, only once a real mention is found, and a bare `user@host`
958/// or a `@word` inside code is not a mention. Setting `enabled = false` is the off switch, and
959/// it stops both the scan and every write.
960///
961/// Omitted from the file when it is the default, so a `tix.toml` that has never configured
962/// mentions round-trips byte-identically and gains no key on save.
963#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
964#[serde(default, deny_unknown_fields)]
965pub struct MentionsConfig {
966    /// Whether issue writes scan for mentions and record them.
967    pub enabled: bool,
968    /// Directory holding the per-target mention files, relative to the project root unless
969    /// absolute. `None` is not a state: the default is [`DEFAULT_MENTIONS_DIR`].
970    pub dir: PathBuf,
971}
972
973/// The directory mention files live in, relative to the project root.
974pub const DEFAULT_MENTIONS_DIR: &str = "docs/mentions";
975
976impl Default for MentionsConfig {
977    fn default() -> Self {
978        MentionsConfig {
979            enabled: true,
980            dir: PathBuf::from(DEFAULT_MENTIONS_DIR),
981        }
982    }
983}
984
985impl MentionsConfig {
986    /// Whether this is the default, for `skip_serializing_if`.
987    #[must_use]
988    pub fn is_default(&self) -> bool {
989        *self == Self::default()
990    }
991
992    /// The absolute directory the store reads and writes.
993    ///
994    /// A relative `dir` resolves against the **project root** — the directory holding
995    /// `tix.toml` — not the invocation directory, the same rule `issues_dir` follows
996    /// ([`crate::storage::Store::open`]).
997    #[must_use]
998    pub fn resolve_dir(&self, issues_dir: &Path) -> PathBuf {
999        if self.dir.is_absolute() {
1000            return self.dir.clone();
1001        }
1002        match Config::project_root(issues_dir) {
1003            Some(root) => root.join(&self.dir),
1004            None => issues_dir.parent().unwrap_or(issues_dir).join(&self.dir),
1005        }
1006    }
1007}
1008
1009/// The `[layout]` section: configurable on-disk issue layout.
1010///
1011/// Tokens are validated at config load ([`crate::layout::validate`]). The
1012/// defaults reproduce the documented layout — `{assignee}/{type}/{status}` for
1013/// the directory, `{date}-{slug}.md` for the filename, moving files when a
1014/// templated value changes.
1015#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1016#[serde(default, deny_unknown_fields)]
1017pub struct LayoutConfig {
1018    /// Directory template, relative to `issues_dir`.
1019    pub directory: String,
1020    /// Filename template; must end in `.md`.
1021    pub filename: String,
1022    /// Relocate an issue when a templated value changes (status/assignee/type).
1023    pub move_on_change: bool,
1024}
1025
1026impl Default for LayoutConfig {
1027    fn default() -> Self {
1028        LayoutConfig {
1029            directory: "{assignee}/{type}/{status}".to_string(),
1030            // Date first, then the id: a lexicographic listing of the directory
1031            // is then creation order, while the id keeps the name globally
1032            // unique across branches (and disambiguates same-day, same-title
1033            // issues). `number` is deliberately absent — it can collide across
1034            // branches and is not an identity.
1035            filename: "{date}-{id}-{slug}.md".to_string(),
1036            move_on_change: true,
1037        }
1038    }
1039}
1040
1041impl LayoutConfig {
1042    /// Whether this is the default, for `skip_serializing_if`.
1043    #[must_use]
1044    pub fn is_default(&self) -> bool {
1045        *self == Self::default()
1046    }
1047}