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;