Skip to main content

tix/
config.rs

1//! `tix.toml` config schema, defaults, loading, saving and validation.
2//!
3//! Mirrors `contracts/tix-toml.md` and `data-model.md` §Config. The
4//! [`Config`] type is the Serde surface for `tix.toml`; [`StateConfig`]
5//! carries the legacy `[state].next_number` counter, which is tolerated on
6//! load and never written back out (issue #52 — the issue number is
7//! resolved at creation time by scanning the live issue set). Loading emits
8//! a warning whenever a cleartext token is read from the file, and
9//! [`Config::save`] never serializes tokens back to disk.
10
11use std::collections::HashSet;
12use std::hash::{BuildHasher, Hash, Hasher};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use indexmap::IndexMap;
18use serde::ser::SerializeStruct;
19use serde::{Deserialize, Serialize, Serializer};
20
21use crate::board_store::BoardStore;
22use crate::capacity::config::CapacityConfig;
23use crate::cycle_store::{CycleStore, is_implicit_default};
24use crate::error::{Result, TixError};
25use crate::records::RecordStore;
26use crate::templates::registry::TemplateRegistry;
27
28mod ids;
29mod io;
30mod models;
31mod validate;
32
33pub use ids::*;
34use io::allocate_stable_id;
35pub(crate) use io::restore_section_order;
36pub use models::*;
37pub(crate) use validate::*;
38
39/// The rules every `tix.toml` registry key obeys.
40///
41/// `[projects.<project-id>]`, `[epics.<epic-id>]` and
42/// `[initiatives.<initiative-id>]` are keyed by a **stable generated identifier**,
43/// and the rules that keep such a key stable must not drift between the registries:
44/// never blank, never padded, bounded in length, free of the TOML-structural
45/// characters a table header cannot carry, and drawn from a shape that cannot hand
46/// the same value out twice. Only the prefix and the words used in an error differ,
47/// so those are the associated constants and everything else lives here once.
48///
49/// Hand-written ids are accepted when they pass [`StableId::new`], so a readable
50/// handle such as `p-platform` and the generated shape coexist.
51pub trait StableId: Sized {
52    /// The generated prefix: `p-` for projects, `e-` for epics, `i-` for initiatives,
53    /// `cs-` for cycle schedules.
54    const PREFIX: &'static str;
55    /// The human label used in messages, e.g. `project id`.
56    const LABEL: &'static str;
57    /// The indefinite article for [`Self::LABEL`]: `a` for a project, `an` for an epic.
58    const ARTICLE: &'static str;
59    /// The longest id a registry accepts. Long enough for a readable handle, short
60    /// enough that a typo cannot turn into a paragraph in a table header.
61    const MAX_LEN: usize = 128;
62
63    /// Wrap an id that has already passed [`StableId::new`].
64    fn from_validated(raw: String) -> Self;
65
66    /// The id as written.
67    fn as_str(&self) -> &str;
68
69    /// Validate an explicit id.
70    ///
71    /// Ids are chosen by a caller (an `--project-id`/`--epic-id` flag) or generated.
72    /// Either way the same rules apply, because the id ends up as a TOML table
73    /// header: [`restore_section_order`] reads `[<section>.<id>]`, and an id
74    /// containing `.` or a bracket would read as a nested table or a malformed
75    /// document.
76    fn new(raw: &str) -> Result<Self> {
77        if raw.trim().is_empty() {
78            return Err(TixError::Config(format!(
79                "{} {} must not be blank",
80                Self::ARTICLE,
81                Self::LABEL
82            )));
83        }
84        if raw.trim() != raw {
85            return Err(TixError::Config(format!(
86                "{} `{raw}` has leading or trailing whitespace",
87                Self::LABEL
88            )));
89        }
90        if raw.chars().count() > Self::MAX_LEN {
91            return Err(TixError::Config(format!(
92                "{} is {} characters; the limit is {}",
93                Self::LABEL,
94                raw.chars().count(),
95                Self::MAX_LEN
96            )));
97        }
98        if let Some(bad) = raw.chars().find(|c| is_structural_key_char(*c)) {
99            return Err(TixError::Config(format!(
100                "{} `{raw}` contains `{bad}`; use letters, digits, `-`, `_`, or `:`",
101                Self::LABEL
102            )));
103        }
104        Ok(Self::from_validated(raw.to_string()))
105    }
106
107    /// Allocate a new id: 64 mixed bits from the process hasher, [`Self::PREFIX`]-prefixed.
108    ///
109    /// No dependency and no counter stored in `tix.toml`, so there is no allocator
110    /// whose state a deletion could reset. The never-reused guarantee rests on the
111    /// **id shape** rather than on a persisted high-water mark: the value is drawn
112    /// from 2^64 with the process id, a process-monotonic counter, and the wall
113    /// clock mixed in, so two draws in one process differ with overwhelming
114    /// probability (~2^-64 for a SipHash collision), and a re-draw after a deletion
115    /// is as unlikely to repeat a previously handed-out value as any fresh draw is
116    /// to collide. On top of that, [`Config::allocate_project_id`],
117    /// [`Config::allocate_epic_id`] and [`Config::allocate_initiative_id`] redraw
118    /// against the ids already stored, so a *stored* id is never handed out a second
119    /// time — that half is exact, not probabilistic.
120    #[must_use]
121    fn generate() -> Self {
122        static COUNTER: AtomicU64 = AtomicU64::new(0);
123
124        let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
125        std::process::id().hash(&mut hasher);
126        COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
127        SystemTime::now()
128            .duration_since(UNIX_EPOCH)
129            .map_or(0, |since| since.as_nanos())
130            .hash(&mut hasher);
131        let bits = hasher.finish();
132        // The prefix plus 16 hex digits is a valid id by construction, so the private
133        // wrapper is used directly rather than re-validating the format.
134        Self::from_validated(format!("{}{bits:016x}", Self::PREFIX))
135    }
136}
137
138impl From<ProjectId> for String {
139    fn from(id: ProjectId) -> Self {
140        id.0
141    }
142}
143
144impl From<EpicId> for String {
145    fn from(id: EpicId) -> Self {
146        id.0
147    }
148}
149
150impl From<InitiativeId> for String {
151    fn from(id: InitiativeId) -> Self {
152        id.0
153    }
154}
155
156/// The project configuration file (`tix.toml`).
157///
158/// Not `Eq`, unlike each of the sections it holds: [`Config::views`] preserves
159/// unknown view metadata as `toml::Value`, whose float variant includes `nan`, so
160/// the reflexive equality `Eq` promises cannot be guaranteed for a config that
161/// carries one. `PartialEq` is what every comparison in the tree needs — `save`
162/// then `load` round-trip tests included.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(default, deny_unknown_fields)]
165pub struct Config {
166    /// Named board configurations (`[kanban]`). Always ≥1 after load.
167    #[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
168    pub kanban: KanbanSection,
169    /// Directory holding issue files, relative to the repo root.
170    pub issues_dir: PathBuf,
171    /// Directory holding milestone markdown files, relative to the repo
172    /// root. `None` → the default `docs/milestones`.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub milestones_dir: Option<PathBuf>,
175    /// Named teams (`[teams.<label>]`), in declaration order.
176    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
177    pub teams: IndexMap<String, Team>,
178    /// Named cycles (`[cycles.<name>]`), in declaration order.
179    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
180    pub cycles: IndexMap<String, PlanningCycle>,
181    /// Named new-issue templates (`[templates.<name>]`), in declaration order.
182    ///
183    /// Optional and absent by default: a project with no templates writes no
184    /// section, and the F4 New Issue mode simply offers none.
185    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
186    pub templates: IndexMap<String, IssueTemplate>,
187    /// Saved view definitions (`[views.<view-id>]`), in declaration order.
188    ///
189    /// Mission `saved-issue-views-01M3C926` (WP01). Omitted when empty, so a
190    /// repository that has never saved a view gains no key on save. The records
191    /// are [`crate::views::registry::SavedView`]s: a stable id, a mutable display
192    /// name, explicit scope metadata, the query plan, and preserved extension
193    /// fields. Every write goes through
194    /// [`crate::views::registry::ViewRegistry::commit`].
195    #[serde(
196        default,
197        skip_serializing_if = "crate::views::registry::ViewRegistry::is_empty"
198    )]
199    pub views: crate::views::registry::ViewRegistry,
200    /// Health policy configuration (`[health]`). Omitted when absent.
201    #[serde(default, skip_serializing_if = "HealthConfig::is_empty")]
202    pub health: HealthConfig,
203    /// Stable-ID issue/form templates (`[issue_templates.<id>]`), in
204    /// declaration order.
205    ///
206    /// As distinct from [`Self::templates`] as an ID is from a name: this is the
207    /// registry behind `tix template` and `tix create --template`, keyed by an
208    /// immutable ID with an explicit repository/board/team scope, typed defaults
209    /// and prompts, typed references, and preserved unknown metadata
210    /// ([`crate::templates::registry`]). The legacy name-keyed `templates` above
211    /// only pre-fills the TUI New Issue form. Omitted when empty, so a project
212    /// with no stable-ID templates gains no section and a legacy file
213    /// round-trips byte-identically.
214    #[serde(default, skip_serializing_if = "TemplateRegistry::is_empty")]
215    pub issue_templates: TemplateRegistry,
216    /// Planning presets and the auto-cycle counter (`[planning]`).
217    #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
218    pub planning: PlanningConfig,
219    /// `[categories]`: the global vocabulary and safe default. Omitted when
220    /// untouched, so a legacy file gains no key on save.
221    #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
222    pub categories: CategoryConfig,
223    /// `[transitions]`: the workflow policy. Omitted when untouched.
224    #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
225    pub transitions: TransitionConfig,
226    /// `[triage]`: the normal columns that make up the triage inbox.
227    ///
228    /// Mission `triage-inbox-review-flow-01M3C600`. The inbox is opt-in: `None` means
229    /// no section, so no inbox, and every `tix triage` command refuses by naming the
230    /// missing section. **`tix init` writes one**, with the board columns it needs, so
231    /// a new repository starts with the inbox on; a legacy `tix.toml` gains no key on
232    /// save. See [`crate::triage::config`].
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub triage: Option<crate::triage::TriageConfig>,
235    /// Optional `$EDITOR` override for full-file editing.
236    pub editor: Option<String>,
237    /// Read-only viewer for the TUI's `v` key (tix #1507).
238    ///
239    /// `None` and `Some("auto")` both mean the built-in probe chain — `mdterm`,
240    /// then `batcat`, `most`, `less`, `more` — and every other value names the
241    /// program to run directly. Never written when unset, so a legacy file gains
242    /// no key on save.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub viewer: Option<String>,
245    /// Configured AI agent command (opencode, claude, etc.) launched with
246    /// the issue body as its specification. Mirrors the `editor` resolution
247    /// chain (explicit → `$TIX_AGENT` → `$AGENT` env → PATH fallback).
248    pub agent: Option<String>,
249    /// Persisted runtime state (`[state]`). Tolerated on load for legacy
250    /// files, never written back to disk (issue #52).
251    #[serde(skip_serializing)]
252    pub state: StateConfig,
253    /// External tracker targets (`[export]`). Omitted when empty, so a legacy
254    /// file gains no section on save (#1415).
255    #[serde(default, skip_serializing_if = "ExportConfig::is_empty")]
256    pub export: ExportConfig,
257    /// Top-level frontmatter keys this project uses on purpose (`[custom_attributes]`).
258    ///
259    /// #1361. `tix` preserves a frontmatter key it does not recognise and warns
260    /// about it, because an unrecognised key is usually a typo (`titel:`) and a
261    /// typo that round-trips silently is worse than one that is dropped. A project
262    /// that *does* use a custom key declares it here once and stops being warned.
263    ///
264    /// **Top-level names only**, not dotted paths — the thing being declared is a
265    /// top-level key, and accepting a path syntax here would imply nested
266    /// declarations are checked, which they are not.
267    #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
268    pub custom_attributes: CustomAttributes,
269    /// Whether `tix lint` also lints the foreign repositories reachable from
270    /// this store's `related_issues` path references and merges their warnings.
271    ///
272    /// Default `true`. Skipped when `true` so a `tix.toml` that does not set it
273    /// round-trips byte-identically; only an explicit `false` is written.
274    #[serde(default = "default_true", skip_serializing_if = "is_true")]
275    pub lint_foreign_repositories: bool,
276    /// Whether the TUI opens its "did you know?" tips modal on startup (tix #1632).
277    ///
278    /// Default `true`: a session that reaches the TUI shows one tip, then gets out
279    /// of the way. `tix init` writes the key explicitly, so the setting is
280    /// discoverable where the rest of the configuration lives, and the modal's `d`
281    /// key writes `false` back. Skipped when `true` for the same reason
282    /// [`Self::lint_foreign_repositories`] is, so a file that does not mention it
283    /// round-trips byte-identically.
284    #[serde(default = "default_true", skip_serializing_if = "is_true")]
285    pub tips_on_startup: bool,
286    /// `[mentions]`: durable `@handle` / `@email` mention tracking.
287    ///
288    /// OpenSpec change `issue-mentions`. Absent means the defaults — enabled, under
289    /// `docs/mentions` — so no legacy file gains a key on save.
290    #[serde(default, skip_serializing_if = "MentionsConfig::is_default")]
291    pub mentions: MentionsConfig,
292    /// `[layout]`: configurable on-disk issue layout.
293    #[serde(default, skip_serializing_if = "LayoutConfig::is_default")]
294    pub layout: LayoutConfig,
295    /// Allowed issue `type:` values (tix #1530 renamed the key from
296    /// `custom_types`; the old name is still accepted on read).
297    ///
298    /// `tix init` writes the default set (see [`DEFAULT_TYPES`]); `tix lint`
299    /// accepts an issue's `type:` only from this list. `None` falls back to
300    /// [`DEFAULT_TYPES`], so a repository that never configured it still gets a
301    /// meaningful check.
302    #[serde(
303        default,
304        skip_serializing_if = "Option::is_none",
305        alias = "custom_types"
306    )]
307    pub types: Option<Vec<String>>,
308    /// Extra known issue `labels` for this project, beyond the builtins.
309    ///
310    /// [`BUILTIN_LABELS`] are always resolved (see [`Self::labels`]), so this key
311    /// is an **optional extension**: a project that wants to name its own
312    /// additional labels lists them here. `tix init` does **not** write the key —
313    /// a builtin needs no key, and emitting a brand-new key would make a fresh
314    /// `tix.toml` unreadable to a released binary (`deny_unknown_fields`).
315    /// Omitted when unset, so a legacy file gains no key on save.
316    ///
317    /// Unlike [`Self::types`], `tix lint` does **not** reject an undeclared
318    /// label: labels are free-form across the ecosystem (tracker mirrors, import
319    /// provenance), and a hard allow-list would flag every one of them.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub labels: Option<Vec<String>>,
322    /// Per-type accent colors (`[type_colors]`, tix #1530).
323    ///
324    /// Maps a type name to a color name. A type with no entry uses the shipped
325    /// [`DEFAULT_TYPE_COLORS`]. Omitted when empty, so an untouched file gains
326    /// no table on save.
327    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
328    pub type_colors: IndexMap<String, String>,
329    /// Directory holding tag definition files (`tags_dir`, tix #1530).
330    ///
331    /// Each `*.md` there names a tag and its `color:`; a tag colors the issue
332    /// label of the same name in the TUI and the CLI. `None` → the default
333    /// `docs/tags`. Absent by default, so a file gains no key on save.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub tags_dir: Option<PathBuf>,
336    /// Child repositories this project coordinates (`[repos]`, tix #1529).
337    ///
338    /// A **parent-only** map: a project-management repository names the
339    /// repositories it manages, one level deep, and each value is that child's
340    /// project root (the directory holding its `tix.toml`), relative to this
341    /// file. Absent by default, so a file gains no section on save. The TUI's
342    /// Repos mode lists these and runs `tix` inside the chosen one.
343    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
344    pub repos: IndexMap<String, PathBuf>,
345    /// The parent repository, when this project is itself a managed child
346    /// (tix #1529).
347    ///
348    /// Set here, the TUI's Repos mode offers a limb that runs `tix` in the
349    /// parent. The parent also sets `TIX_PARENT_REPO` when it spawns a child, so
350    /// the jump back works even without this key.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub parent_repo: Option<PathBuf>,
353    /// Project records (`[projects.<project-id>]`), in declaration order.
354    ///
355    /// Mission `first-class-projects-01M3BYBW` (WP01). The map key is a stable
356    /// generated [`ProjectId`] and never a display name (C-002), so renaming a
357    /// project leaves every issue association and derived figure intact. Omitted
358    /// entirely when empty, so a project-free repository gains no section on save
359    /// and a legacy `tix.toml` round-trips byte-identically (C-001). Issue-side
360    /// associations are the `projects` list in issue frontmatter; this registry is
361    /// the only place a project's own record lives.
362    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
363    pub projects: IndexMap<String, ProjectConfiguration>,
364    /// Epic records (`[epics.<epic-id>]`), in declaration order.
365    ///
366    /// Mission `epics-work-items-01M3BYW4` (WP01). The registry key is a stable
367    /// generated [`EpicId`] and never a display name (C-003), so renaming an epic
368    /// leaves every member issue's reference and every derived figure intact.
369    /// Omitted entirely when empty, so an epic-free repository gains no section on
370    /// save and a legacy `tix.toml` round-trips byte-identically (C-004). Member
371    /// issues point at an epic through the `epics` list in their frontmatter; this
372    /// registry is the only place an epic's own record lives. Reference fields
373    /// (`parent`, `projects`, `milestone`, `cycle`) are **shape**-checked only —
374    /// resolution and diagnosis belong to the epic service and `tix lint` (C-005).
375    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
376    pub epics: IndexMap<String, EpicConfiguration>,
377    /// Initiative records (`[initiatives.<initiative-id>]`), in declaration order.
378    ///
379    /// Mission `roadmap-initiatives-01M3C8SX` (WP01). The registry key is a stable
380    /// generated [`InitiativeId`] and never a display name (C-002), so renaming an
381    /// initiative leaves every association and derived figure intact. Omitted
382    /// entirely when empty, so an initiative-free repository gains no section on save
383    /// and a legacy `tix.toml` round-trips byte-identically (C-001). All writes go
384    /// through the atomic boundary in [`crate::roadmap::registry`], which validates
385    /// the whole change before [`Config::save`] touches the file; reference fields
386    /// (`projects`, `epics`, `milestones`) are **shape**-checked only — resolution and
387    /// diagnosis belong to the initiative service (WP03) and `tix lint` (C-005).
388    ///
389    /// Unlike its two siblings this record preserves unknown metadata rather than
390    /// refusing it; see [`InitiativeConfiguration`].
391    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
392    pub initiatives: IndexMap<String, InitiativeConfiguration>,
393    /// Cycle-schedule definitions (`[cycle_schedules.<schedule-id>]`), in
394    /// declaration order.
395    ///
396    /// Mission `repeating-cycle-schedules-01M3C7W1` (WP01). A **schedule** is not a
397    /// **cycle**: the legacy, name-keyed `[cycles.<name>]` registry above stays
398    /// exactly as it is, and issue `cycles` references keep pointing at it (C-002).
399    /// A schedule is the stable-ID *definition* — cadence, duration, start date,
400    /// timezone, look-ahead, state — from which cycles are explicitly generated
401    /// (WP04).
402    ///
403    /// The registry key is a stable generated [`crate::cycle_schedule::config::CycleScheduleId`]
404    /// and never the display name (C-003), so a rename leaves every generated cycle,
405    /// cursor and closed snapshot intact. Omitted entirely when empty, so a
406    /// schedule-free repository gains no section on save and a legacy `tix.toml`
407    /// round-trips byte-identically (C-001). Every field is validated by
408    /// [`crate::cycle_schedule::config::validate_schedules`] before persistence, with
409    /// a diagnostic that names the field (C-005).
410    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
411    pub cycle_schedules:
412        IndexMap<String, crate::cycle_schedule::config::CycleScheduleConfiguration>,
413    /// Capacity ceilings (`[capacity.<entry-id>]`), in declaration order.
414    ///
415    /// Mission `cycle-capacity-planning-01M3C85C` (WP01). Each entry names exactly one
416    /// scope — a cycle, a team, or an assignee — and the ceiling it is allowed to commit
417    /// to, in an estimate unit the WIP registry already declares (C-003, C-004). The
418    /// entry's key is only a handle: the scope is a field of the record, so "which
419    /// dimension is this?" is always stated rather than inferred from a table's shape,
420    /// and [`crate::capacity::config::validate`] refuses an entry that names none or
421    /// more than one.
422    ///
423    /// Omitted entirely when empty, so a capacity-free repository gains no section on
424    /// save and a legacy `tix.toml` round-trips byte-identically (C-001), and unknown
425    /// metadata inside an entry is preserved verbatim for a newer `tix`. A closed
426    /// cycle's capacity is historical evidence: this section holds no snapshot state,
427    /// and [`crate::capacity::config::ensure_cycle_editable`] is the seam that refuses a
428    /// configuration edit aimed at a closed cycle (C-008).
429    #[serde(default, skip_serializing_if = "CapacityConfig::is_empty")]
430    pub capacity: CapacityConfig,
431    /// How this repository talks to a [beads](https://github.com/gastownhall/beads)
432    /// workspace (`[beads]`).
433    ///
434    /// Mission-tracking issues tix #1709/#1710. `off` (the default) writes no
435    /// section, so a repository that never opts in is unchanged and round-trips
436    /// byte-identically. `mirror` reads a beads workspace through the `bd` CLI
437    /// without writing anything anywhere; `sync` keeps both sides in step through
438    /// two one-way pipes. Every interaction is a `bd` CLI call — tix never opens
439    /// the Dolt database. See [`crate::sync::beads`].
440    #[serde(default, skip_serializing_if = "BeadsConfig::is_default")]
441    pub beads: BeadsConfig,
442}
443
444/// Serde default for a boolean that defaults to `true`.
445///
446/// Shared with [`crate::capacity::config::CapacityEntry`], whose `enabled` follows the
447/// same rule the WIP limits set: writing the default is not writing anything.
448pub(crate) fn default_true() -> bool {
449    true
450}
451/// `skip_serializing_if` for a boolean whose default is `true`: only an explicit
452/// `false` is written to disk.
453#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&bool) -> bool`
454pub(crate) fn is_true(value: &bool) -> bool {
455    *value
456}
457
458impl Default for Config {
459    fn default() -> Self {
460        Config {
461            kanban: KanbanSection {
462                configurations: IndexMap::from([(
463                    "default".to_string(),
464                    BoardConfiguration::default(),
465                )]),
466            },
467            issues_dir: PathBuf::from("docs/issues"),
468            milestones_dir: None,
469            teams: IndexMap::new(),
470            cycles: IndexMap::new(),
471            templates: IndexMap::new(),
472            views: crate::views::registry::ViewRegistry::new(),
473            health: HealthConfig::default(),
474            issue_templates: TemplateRegistry::default(),
475            planning: PlanningConfig::default(),
476            categories: CategoryConfig::default(),
477            transitions: TransitionConfig::default(),
478            // The bare default has no inbox, and that stays true: the mechanism is
479            // opt-in, so a config with no `[triage]` section has no inbox and every
480            // `tix triage` command refuses, naming the section. What changed is the
481            // *starting point*: `tix init` writes the section (and the board columns it
482            // needs), so a new repository begins with an inbox rather than with a
483            // refusal. See [`crate::commands::init`].
484            triage: None,
485            editor: None,
486            viewer: None,
487            state: StateConfig::default(),
488            export: ExportConfig::default(),
489            agent: None,
490            custom_attributes: CustomAttributes::default(),
491            lint_foreign_repositories: true,
492            tips_on_startup: true,
493            mentions: MentionsConfig::default(),
494            layout: LayoutConfig::default(),
495            types: None,
496            labels: None,
497            type_colors: IndexMap::new(),
498            tags_dir: None,
499            repos: IndexMap::new(),
500            parent_repo: None,
501            projects: IndexMap::new(),
502            epics: IndexMap::new(),
503            initiatives: IndexMap::new(),
504            cycle_schedules: IndexMap::new(),
505            capacity: CapacityConfig::new(),
506            beads: BeadsConfig::default(),
507        }
508    }
509}
510
511impl Config {
512    /// The configuration a **new repository** starts with — what `tix init` writes.
513    ///
514    /// Deliberately not [`Default`]: that is the fallback for a missing or partial
515    /// `tix.toml` (and the base hundreds of tests build fixtures on), and it stays
516    /// minimal. The *starting point* is a product decision: a new repository gets the
517    /// board [`starter_board`] lays out and a working `[triage]` section, so
518    /// `tix triage list` works on a fresh checkout instead of refusing and asking for
519    /// configuration first.
520    ///
521    /// The three keys match the board: unreviewed work sits in `triage`, an accept
522    /// lands in `todo`, a decline in `wontfix`. `deferred_status` is left unset
523    /// because it defaults to `entry_status`, and a default that is never written is a
524    /// default that cannot drift from the meaning it stands for.
525    #[must_use]
526    pub fn starter() -> Config {
527        let mut config = Config::default();
528        config
529            .kanban
530            .configurations
531            .insert("default".to_string(), starter_board());
532        config.triage = Some(crate::triage::TriageConfig {
533            entry_status: Some("triage".to_string()),
534            accepted_status: Some("todo".to_string()),
535            declined_status: Some("wontfix".to_string()),
536            deferred_status: None,
537        });
538        // `tix init` writes the default issue types explicitly, so a new project
539        // sees and can edit them; a repository without the key falls back to the
540        // same list at lint time.
541        config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
542        // `labels` is deliberately NOT written: `decision-pending` is a builtin
543        // (`BUILTIN_LABELS`), so no key is needed, and emitting a brand-new key
544        // would make a fresh `tix.toml` unreadable to a released binary whose
545        // `Config` denies unknown fields.
546        // Ship the tag directory explicitly (tix #1530), so a new project has
547        // somewhere to drop `docs/tags/*.md` without having to know the key.
548        config.tags_dir = Some(PathBuf::from("docs/tags"));
549        config
550    }
551
552    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
553    /// the `Config` error variant with a human message. Errors name the
554    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
555    pub(crate) fn validate(&self) -> Result<()> {
556        if self.issues_dir.as_os_str().is_empty() {
557            return Err(TixError::Config("`issues_dir` must not be blank".into()));
558        }
559        if self.milestones_dir().as_os_str().is_empty() {
560            return Err(TixError::Config(
561                "`milestones_dir` must not be blank".into(),
562            ));
563        }
564        if self.tags_dir().as_os_str().is_empty() {
565            return Err(TixError::Config("`tags_dir` must not be blank".into()));
566        }
567        validate_repos(&self.repos)?;
568        // The health policy version is a closed set, and a repository naming one
569        // this build does not implement is refused here rather than at the first
570        // rollup: a value derived under a formula nobody can name is worse than a
571        // file that will not load.
572        self.health.policy()?;
573        if self
574            .parent_repo
575            .as_deref()
576            .is_some_and(|p| p.as_os_str().is_empty())
577        {
578            return Err(TixError::Config("`parent_repo` must not be blank".into()));
579        }
580        // A blank `viewer` is a mistake with a clear spelling: the way to ask for
581        // the probe chain is `auto` (or omitting the key). Rejected here rather
582        // than silently treated as auto, so a typo cannot quietly change the
583        // meaning of a saved file.
584        if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
585            return Err(TixError::Config(
586                "`viewer` must not be blank; use `auto` for the default chain".into(),
587            ));
588        }
589        validate_planning(&self.planning)?;
590        crate::layout::validate(&self.layout)?;
591        validate_categories(&self.categories)?;
592        if self.kanban.configurations.is_empty() {
593            return Err(TixError::Config(
594                "`[kanban]` must define at least one configuration".into(),
595            ));
596        }
597        for (name, board) in &self.kanban.configurations {
598            validate_board(name, board)?;
599            validate_category_map(name, board, &self.categories)?;
600        }
601        for (label, team) in &self.teams {
602            validate_team(label, team)?;
603        }
604        for (name, cycle) in &self.cycles {
605            validate_cycle(name, cycle)?;
606        }
607        for (name, template) in &self.templates {
608            validate_template(name, template)?;
609        }
610        // Project records: a valid stable id as the key, a non-blank unique display
611        // name, present-but-blank optional text refused, and the shared date rules.
612        // Project *resolution* (does a view/issue reference an existing project?) is
613        // not `Config` validation, for the same reason view scopes are not: a
614        // reference to a deleted project must stay loadable and diagnosable (C-005).
615        validate_projects(&self.projects)?;
616        // Epic records: the same structural rules as projects (a valid stable id as
617        // the key, a non-blank unique display name, present-but-blank optional text
618        // refused, the shared date rules), plus a positive `weight` and the
619        // epic-specific reference *shapes*. Epic *resolution* — does a project,
620        // milestone, cycle, or parent epic exist? — is deliberately not `Config`
621        // validation, for the same reason project resolution is not: a dangling
622        // reference caused by a deletion or a hand edit must stay loadable and
623        // diagnosable (C-005).
624        validate_epics(&self.epics)?;
625        // Initiative records: the same structural rules as projects and epics (a
626        // valid stable id as the key, a non-blank unique display name, the shared
627        // calendar-date rule), plus the initiative-specific reference *shapes* —
628        // project/epic ids for `projects`/`epics` and non-blank names for
629        // `milestones`. Initiative *resolution* — does a referenced project, epic, or
630        // milestone exist? — is deliberately not `Config` validation, for the same
631        // reason project/epic resolution is not: a dangling reference caused by a
632        // deletion or a hand edit must stay loadable and diagnosable (C-005).
633        validate_initiatives(&self.initiatives)?;
634        crate::cycle_schedule::config::validate_schedules(&self.cycle_schedules)?;
635        // `[capacity]`'s rootless half — the entry handle, exactly one scope, the
636        // ceiling, and the *declared unit* — is validated here, because every
637        // validate-then-save path (`tix wip unit --remove`, the schedule registry, the MCP
638        // write tools) validates with this method before it writes: without the unit check
639        // there, removing a unit a ceiling names would persist a file the next load
640        // refuses. The half that needs the cycle **records** — resolving a cycle reference
641        // to a generated cycle's identity — is validated by the loader
642        // (`Config::load_with_warning`) and by `crate::capacity::config::set_capacity`,
643        // the only two gates with a project root; see `crate::capacity::config::validate`.
644        crate::capacity::config::validate(&self.capacity, self)?;
645        // View records are validated for *structure* here: a blank name, a scope
646        // that contradicts itself, an id that disagrees with its table key. Project
647        // *resolution* is deliberately not part of `Config` validation — it needs
648        // the project registry, and a view pointing at a project that was deleted
649        // must stay loadable (and diagnosable) rather than making `tix.toml`
650        // unreadable. The write path refuses it instead
651        // (`ViewRegistry::commit`, C-008).
652        self.views
653            .check_structure()
654            .map_err(|refusal| TixError::Config(refusal.render()))?;
655        // Stable-ID templates: scope binding, prompt shape, references and
656        // preserved metadata. Field-aware errors name `issue_templates.<id>...`.
657        self.issue_templates.validate(self)?;
658        // Last, deliberately. `[triage]` names columns from the active board
659        // *and* from that board's `category_map`, so it can only be judged once
660        // both are; running it earlier would hand `workflow::resolution` an
661        // unvalidated vocabulary, which is the `unreachable!` it documents
662        // (C-002).
663        crate::triage::config::validate(self)?;
664        Ok(())
665    }
666
667    /// Allocate a [`ProjectId`] that is not already in the registry.
668    ///
669    /// Redraws against the stored ids, so a stored id is never handed out a second
670    /// time. The rest of the never-reused guarantee is carried by the id shape
671    /// itself (see [`StableId::generate`]); there is no counter persisted in
672    /// `tix.toml`, so a deletion cannot reset an allocator — the id a deleted
673    /// project carried stays spent because no draw can repeat one this process
674    /// already produced.
675    #[must_use]
676    pub fn allocate_project_id(&self) -> ProjectId {
677        self.allocate_project_id_with(ProjectId::generate)
678    }
679
680    /// [`Self::allocate_project_id`] with the draw injected, so the redraw loop
681    /// itself is testable: a real generator collides so rarely that a test driving
682    /// it can only ever observe the first iteration.
683    fn allocate_project_id_with(&self, draw: impl FnMut() -> ProjectId) -> ProjectId {
684        allocate_stable_id(&self.projects, draw)
685    }
686
687    /// Allocate an [`EpicId`] that is not already in the registry.
688    ///
689    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
690    /// ids, so a stored id is never handed out a second time, and the rest of the
691    /// never-reused guarantee is carried by the id shape itself
692    /// ([`StableId::generate`]) — a deleted epic's id stays spent, keeping a member
693    /// issue's dangling reference diagnosable rather than silently rebound (C-005).
694    #[must_use]
695    pub fn allocate_epic_id(&self) -> EpicId {
696        self.allocate_epic_id_with(EpicId::generate)
697    }
698
699    /// [`Self::allocate_epic_id`] with the draw injected, so the redraw loop itself
700    /// is testable.
701    fn allocate_epic_id_with(&self, draw: impl FnMut() -> EpicId) -> EpicId {
702        allocate_stable_id(&self.epics, draw)
703    }
704
705    /// Allocate an [`InitiativeId`] that is not already in the registry.
706    ///
707    /// Mirrors [`Self::allocate_project_id`]: the draw is redrawn against the stored
708    /// ids, so a stored id is never handed out a second time, and the rest of the
709    /// never-reused guarantee is carried by the id shape itself
710    /// ([`StableId::generate`]) — a deleted initiative's id stays spent, keeping the
711    /// record's identity distinct from any later one.
712    #[must_use]
713    pub fn allocate_initiative_id(&self) -> InitiativeId {
714        self.allocate_initiative_id_with(InitiativeId::generate)
715    }
716
717    /// [`Self::allocate_initiative_id`] with the draw injected, so the redraw loop
718    /// itself is testable.
719    fn allocate_initiative_id_with(&self, draw: impl FnMut() -> InitiativeId) -> InitiativeId {
720        allocate_stable_id(&self.initiatives, draw)
721    }
722
723    /// Allocate a fresh [`crate::cycle_schedule::config::CycleScheduleId`].
724    ///
725    /// Mirrors [`Self::allocate_initiative_id`]: the draw is redrawn against the
726    /// stored ids, so a stored id is never handed out a second time, and the rest of
727    /// the never-reused guarantee is carried by the id shape itself
728    /// ([`StableId::generate`]) — a deleted schedule's id stays spent, keeping the
729    /// record's identity distinct from any later one (C-003).
730    #[must_use]
731    pub fn allocate_cycle_schedule_id(&self) -> crate::cycle_schedule::config::CycleScheduleId {
732        self.allocate_cycle_schedule_id_with(
733            crate::cycle_schedule::config::CycleScheduleId::generate,
734        )
735    }
736
737    /// [`Self::allocate_cycle_schedule_id`] with the draw injected, so the redraw loop
738    /// itself is testable: a real generator collides with a stored id so rarely that
739    /// the loop is otherwise unverifiable.
740    fn allocate_cycle_schedule_id_with(
741        &self,
742        draw: impl FnMut() -> crate::cycle_schedule::config::CycleScheduleId,
743    ) -> crate::cycle_schedule::config::CycleScheduleId {
744        allocate_stable_id(&self.cycle_schedules, draw)
745    }
746
747    /// Restore the canonical declaration order of named configurations,
748    /// which the TOML parser loses (it sorts table keys). The order comes
749    /// from `[kanban.<name>]` section headers in the source document.
750    fn restore_declaration_order(&mut self, contents: &str) {
751        crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
752        crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
753        crate::config::restore_section_order(&mut self.teams, contents, "teams");
754        crate::config::restore_section_order(&mut self.templates, contents, "templates");
755        self.views.restore_declaration_order(contents);
756        crate::config::restore_section_order(
757            &mut self.issue_templates.templates,
758            contents,
759            "issue_templates",
760        );
761        crate::config::restore_section_order(&mut self.projects, contents, "projects");
762        crate::config::restore_section_order(&mut self.epics, contents, "epics");
763        crate::config::restore_section_order(&mut self.initiatives, contents, "initiatives");
764        crate::config::restore_section_order(
765            &mut self.cycle_schedules,
766            contents,
767            "cycle_schedules",
768        );
769        self.capacity.restore_declaration_order(contents);
770    }
771
772    /// Declared custom top-level frontmatter keys, lowercased for comparison.
773    ///
774    /// Lowercased because YAML keys are case-sensitive but nobody has ever
775    /// intended `Titel` and `titel` to be different keys, and a suppression list
776    /// that silently fails to match is worse than no list at all.
777    pub fn custom_attributes(&self) -> Vec<String> {
778        self.custom_attributes
779            .keys
780            .iter()
781            .map(|k| k.trim().to_ascii_lowercase())
782            .collect()
783    }
784
785    /// The allowed issue types: the configured `types`, or [`DEFAULT_TYPES`]
786    /// when unset. Compared case-insensitively by callers.
787    #[must_use]
788    pub fn types(&self) -> Vec<String> {
789        self.types
790            .clone()
791            .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
792    }
793
794    /// The pre-#1530 name for [`Self::types`], kept so existing call sites and
795    /// tests read the same list under the old spelling.
796    #[must_use]
797    pub fn custom_types(&self) -> Vec<String> {
798        self.types()
799    }
800
801    /// The recognised issue labels: [`BUILTIN_LABELS`], plus any extra labels the
802    /// project declares under `labels` in `tix.toml`.
803    ///
804    /// Builtins come first and are always present, so `decision-pending` resolves
805    /// in a repository that never configured the key — which is why `tix init`
806    /// does not write one.
807    #[must_use]
808    pub fn labels(&self) -> Vec<String> {
809        let mut out: Vec<String> = BUILTIN_LABELS.iter().map(|s| (*s).to_string()).collect();
810        if let Some(extra) = &self.labels {
811            for label in extra {
812                if !out.contains(label) {
813                    out.push(label.clone());
814                }
815            }
816        }
817        out
818    }
819
820    /// The color name for issue `type`, from `[type_colors]` when set for it, else
821    /// the shipped [`DEFAULT_TYPE_COLORS`] entry. `None` for a type with no color
822    /// anywhere (an unknown type). Compared case-insensitively (tix #1530).
823    #[must_use]
824    pub fn type_color(&self, type_name: &str) -> Option<String> {
825        let needle = type_name.trim().to_ascii_lowercase();
826        self.type_colors
827            .iter()
828            .find(|(name, _)| name.to_ascii_lowercase() == needle)
829            .map(|(_, color)| color.clone())
830            .or_else(|| {
831                DEFAULT_TYPE_COLORS
832                    .iter()
833                    .find(|(name, _)| *name == needle)
834                    .map(|(_, color)| (*color).to_string())
835            })
836    }
837
838    /// The attribute list shown and edited in the Config view: the declared
839    /// `[custom_attributes].keys`, or the built-in attributes tix models when
840    /// none are declared. Mirrors [`Self::custom_types`] (`configured-or-defaults`).
841    #[must_use]
842    pub fn attributes(&self) -> Vec<String> {
843        if self.custom_attributes.keys.is_empty() {
844            crate::model::KNOWN_FRONTMATTER_KEYS
845                .iter()
846                .filter(|key| **key != "links")
847                .map(|key| (*key).to_string())
848                .collect()
849        } else {
850            self.custom_attributes.keys.clone()
851        }
852    }
853
854    /// The resolved milestones directory: the configured value, or the
855    /// contract default `docs/milestones` when unset.
856    pub fn milestones_dir(&self) -> &Path {
857        self.milestones_dir
858            .as_deref()
859            .unwrap_or(Path::new("docs/milestones"))
860    }
861
862    /// The resolved tags directory: the configured `tags_dir`, or the contract
863    /// default `docs/tags` when unset (tix #1530).
864    pub fn tags_dir(&self) -> &Path {
865        self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
866    }
867
868    /// The child repositories this project coordinates (`[repos]`, tix #1529).
869    pub fn repos(&self) -> &IndexMap<String, PathBuf> {
870        &self.repos
871    }
872
873    /// The parent repository, when this project is a managed child (tix #1529).
874    pub fn parent_repo(&self) -> Option<&Path> {
875        self.parent_repo.as_deref()
876    }
877
878    /// Absolutize the paths that are resolved against the project root:
879    /// an explicitly configured `tags_dir`, every `[repos]` entry, and
880    /// `parent_repo` (tix #1529/#1530).
881    ///
882    /// Only a **relative** configured value is rewritten, so an unset key stays
883    /// `None` (and is not materialized on save) and an absolute one is left
884    /// alone. `issues_dir`/`milestones_dir` are resolved elsewhere; this covers
885    /// the newer keys so the TUI can spawn `tix` in a child from any working
886    /// directory.
887    pub fn resolve_repo_paths(&mut self, base: &Path) {
888        if let Some(path) = self.tags_dir.as_ref() {
889            if path.is_relative() {
890                self.tags_dir = Some(base.join(path));
891            }
892        }
893        for path in self.repos.values_mut() {
894            if path.is_relative() {
895                *path = base.join(&*path);
896            }
897        }
898        if let Some(path) = self.parent_repo.clone() {
899            if path.is_relative() {
900                self.parent_repo = Some(base.join(path));
901            }
902        }
903    }
904
905    /// The configuration used by the CLI and other single-config surfaces:
906    /// the one named `default` if present, else the first declared.
907    pub fn default_config(&self) -> &BoardConfiguration {
908        self.kanban
909            .configurations
910            .get("default")
911            .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
912            .expect("validate() guarantees at least one configuration")
913    }
914
915    /// Named configurations in declaration order (tab order = declaration
916    /// order, D-02).
917    pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
918        self.kanban
919            .configurations
920            .iter()
921            .map(|(name, board)| (name.as_str(), board))
922    }
923}
924
925#[cfg(test)]
926mod tests;