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}