tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
#[allow(clippy::wildcard_imports)]
use super::*;

/// Validation for the `[planning]` section (NFR-003): the counter must be
/// positive once the section is present.
pub(super) fn validate_planning(planning: &PlanningConfig) -> Result<()> {
    if planning.cycle_number == 0 {
        return Err(TixError::Config(
            "`planning.cycle_number` must be at least 1".into(),
        ));
    }
    Ok(())
}

/// `[repos]`: a name and a path, neither blank (tix #1529).
///
/// The depth rule is structural rather than a check here: a parent lists only
/// its own direct children, and a child reaches the parent through
/// `parent_repo`, so a two-level chain is two processes, never a nested walk.
pub(super) fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
    for (name, path) in repos {
        if name.trim().is_empty() {
            return Err(TixError::Config(
                "`[repos]` entry names must not be blank".into(),
            ));
        }
        if path.as_os_str().is_empty() {
            return Err(TixError::Config(format!(
                "`repos.{name}` must name the repository path"
            )));
        }
    }
    Ok(())
}

/// Per-team validation (NFR-003): non-blank label, non-empty members, unique
/// member identities.
pub(super) fn validate_team(label: &str, team: &Team) -> Result<()> {
    if label.trim().is_empty() {
        return Err(TixError::Config("`teams.<label>` must not be blank".into()));
    }
    if team.members.is_empty() {
        return Err(TixError::Config(format!(
            "`teams.{label}.members` must not be empty"
        )));
    }
    let mut seen: HashSet<&str> = HashSet::new();
    for member in &team.members {
        if !seen.insert(member.as_str()) {
            return Err(TixError::Config(format!(
                "`teams.{label}.members` must be unique; found duplicate `{member}`"
            )));
        }
    }
    Ok(())
}

/// The key of a `[<section>.<id>]` registry must be a valid stable id.
///
/// Shared by `[projects.<project-id>]`, `[epics.<epic-id>]`,
/// `[initiatives.<initiative-id>]` and `[cycle_schedules.<schedule-id>]`: the rule
/// that keeps a rename from breaking a reference โ€” and the key safe as a TOML table
/// header โ€” lives on [`StableId`], so this only names the section the error belongs
/// to.
pub(crate) fn validate_registry_key<I: StableId>(section: &str, id: &str) -> Result<()> {
    I::new(id).map_err(|err| TixError::Config(format!("`{section}`: {err}")))?;
    Ok(())
}

/// The display-name rules shared by the named registries.
///
/// A name is required (an unnamed record cannot be selected), must not be padded (a
/// padded name selects differently from how it looks, exactly as a padded id does),
/// and must be unique within its registry, compared **case-folded**: the point is
/// that a user typing a name selects exactly one record, and a user does not tell
/// `Platform` from `platform`. The stored spelling is kept verbatim. `noun` is the
/// word the error uses for the kind of record (`project`, `epic`).
pub(crate) fn validate_display_name(
    section: &str,
    id: &str,
    name: &str,
    noun: &str,
    seen: &mut HashSet<String>,
) -> Result<()> {
    if name.trim().is_empty() {
        return Err(TixError::Config(format!(
            "`{section}.{id}.name` must not be blank"
        )));
    }
    if name.trim() != name {
        return Err(TixError::Config(format!(
            "`{section}.{id}.name` has leading or trailing whitespace"
        )));
    }
    if !seen.insert(name.to_lowercase()) {
        return Err(TixError::Config(format!(
            "`{section}.{id}.name` (`{name}`) must be unique; another {noun} already uses it"
        )));
    }
    Ok(())
}

/// Optional text that is present but blank is a typo, and silence about it is the one
/// thing a config loader should not offer. Shared by the named registries, including
/// the name-keyed reference fields (`lead`, `owner`, `milestone`, `cycle`).
pub(super) fn validate_optional_text(
    section: &str,
    id: &str,
    fields: &[(&str, Option<&str>)],
) -> Result<()> {
    for (field, value) in fields {
        if value.is_some_and(|v| v.trim().is_empty()) {
            return Err(TixError::Config(format!(
                "`{section}.{id}.{field}` must not be blank when present"
            )));
        }
    }
    Ok(())
}

/// The shared date rule for a registry record: `YYYY-MM-DD`, with
/// `due_date >= start_date`, via [`crate::model::validate_issue_dates`], wrapped so
/// the message names the record the way the rest of this module names its keys.
pub(super) fn validate_record_dates(
    section: &str,
    id: &str,
    start_date: Option<&str>,
    due_date: Option<&str>,
) -> Result<()> {
    crate::model::validate_issue_dates(start_date, due_date)
        .map_err(|err| TixError::Config(format!("`{section}.{id}`: {err}")))
}

/// Per-project validation for `[projects.<project-id>]` (FR-001, C-001, C-002).
///
/// Every rule here is the shared one: the key must be a valid stable [`ProjectId`]
/// (that is what keeps a rename from breaking an association, and what keeps the key
/// safe as a TOML table header); the display `name` is required, unpadded and unique
/// within the registry, since a user types the name and two projects answering to one
/// name would be ambiguous; optional text fields are refused when present but blank;
/// dates go through the shared rule. See the helpers for the reasoning.
pub(super) fn validate_projects(projects: &IndexMap<String, ProjectConfiguration>) -> Result<()> {
    let mut seen_names: HashSet<String> = HashSet::new();
    for (id, project) in projects {
        validate_registry_key::<ProjectId>("projects", id)?;
        validate_display_name("projects", id, &project.name, "project", &mut seen_names)?;
        validate_optional_text(
            "projects",
            id,
            &[
                ("description", project.description.as_deref()),
                ("status", project.status.as_deref()),
                ("lead", project.lead.as_deref()),
            ],
        )?;
        validate_record_dates(
            "projects",
            id,
            project.start_date.as_deref(),
            project.due_date.as_deref(),
        )?;
    }
    Ok(())
}

/// Per-epic validation for `[epics.<epic-id>]` (FR-001, FR-003, C-002, C-003, C-004).
///
/// The structural half is exactly the project registry's, through the shared helpers:
/// a valid stable [`EpicId`] key, a non-blank unique display name, present-but-blank
/// optional text refused, and the shared date rule โ€” plus, new here, a **positive**
/// `weight`. A non-positive weight is a typo with a clear intent, and a relative size
/// has no meaning at or below zero.
///
/// The reference fields are validated for **shape** only. A `parent` must be a
/// well-formed [`EpicId`] and must not name the record's own key; every `projects`
/// entry must be a well-formed [`ProjectId`]; `milestone` and `cycle` must be
/// non-blank names โ€” the same name-keyed references the existing registries use.
/// Whether those targets **exist** is deliberately not decided here: cross-record
/// resolution belongs to the epic service, and a parent that names an epic which was
/// deleted (or a project that never existed) must stay loadable and diagnosable
/// (C-005). A cycle across several parent records is likewise not checkable from a
/// single record; it belongs to the service and to `tix lint`.
pub(super) fn validate_epics(epics: &IndexMap<String, EpicConfiguration>) -> Result<()> {
    let mut seen_names: HashSet<String> = HashSet::new();
    for (id, epic) in epics {
        validate_registry_key::<EpicId>("epics", id)?;
        validate_display_name("epics", id, &epic.name, "epic", &mut seen_names)?;
        validate_optional_text(
            "epics",
            id,
            &[
                ("description", epic.description.as_deref()),
                ("status", epic.status.as_deref()),
                ("owner", epic.owner.as_deref()),
                ("milestone", epic.milestone.as_deref()),
                ("cycle", epic.cycle.as_deref()),
            ],
        )?;
        validate_record_dates(
            "epics",
            id,
            epic.start_date.as_deref(),
            epic.due_date.as_deref(),
        )?;
        if let Some(weight) = epic.weight
            && weight <= 0
        {
            return Err(TixError::Config(format!(
                "`epics.{id}.weight` must be a positive integer; got {weight}"
            )));
        }
        if let Some(parent) = epic.parent.as_deref() {
            EpicId::new(parent)
                .map_err(|err| TixError::Config(format!("`epics.{id}.parent`: {err}")))?;
            if parent == id {
                return Err(TixError::Config(format!(
                    "`epics.{id}.parent` must not name the epic itself"
                )));
            }
        }
        for reference in &epic.projects {
            ProjectId::new(reference)
                .map_err(|err| TixError::Config(format!("`epics.{id}.projects`: {err}")))?;
        }
    }
    Ok(())
}

/// Every field name [`InitiativeConfiguration`] declares.
///
/// This is the set serde itself treats as **known** for the record โ€” the fields that
/// survive through the typed struct rather than being swept into the flattened
/// `extensions` map. It must stay in step with `InitiativeConfiguration`: add a field
/// there and add it here, or a later shadowing check would let the new name through.
/// It is listed in one place, and checked in [`validate_initiatives`], because that is
/// the single validation every write goes through.
pub(crate) const INITIATIVE_KNOWN_FIELDS: &[&str] = &[
    "name",
    "outcome",
    "owner",
    "status",
    "priority",
    "labels",
    "target_date",
    "projects",
    "epics",
    "milestones",
];

/// Per-initiative validation for `[initiatives.<initiative-id>]` (FR-001, C-001, C-002).
///
/// The structural half is the project and epic registries' own, through the shared
/// helpers: a valid stable [`InitiativeId`] key, a non-blank unique (case-folded)
/// display name, present-but-blank optional text refused, and the shared calendar
/// rule for `target_date` ([`crate::model::validate_date`] โ€” the same rule issues
/// use, so the format has one owner). Each `labels` entry must be non-blank.
///
/// The reference fields are validated for **shape** only. Every `projects` entry must
/// be a well-formed [`ProjectId`] and every `epics` entry a well-formed [`EpicId`],
/// because that is what a registry must hold to load; every `milestones` entry must
/// be a non-blank **name**, the name-keyed reference the existing milestone registry
/// uses. Whether those targets **exist** is deliberately not decided here:
/// cross-record resolution belongs to the initiative service (WP03) and `tix lint`,
/// and a dangling reference caused by a deletion must stay loadable and diagnosable
/// (C-005). Repeated entries are not refused here โ€” the write path collapses them โ€”
/// because identity is the concern, not redundancy.
///
/// A preserved extension key must not **shadow** a built-in field. A file can never
/// deliver one โ€” serde matches the known field first, so the extension map is only
/// ever the leftovers โ€” but the public write path can inject one through
/// [`crate::roadmap::registry::InitiativeExtensions`], and `toml` would then serialize
/// the same table key twice with no duplicate detection: the write would appear to
/// succeed while the next load failed on `duplicate key`, diverging the file from the
/// caller's in-memory config. Refusing it here is what keeps
/// "an accepted change persists the complete record" true.
pub(super) fn validate_initiatives(
    initiatives: &IndexMap<String, InitiativeConfiguration>,
) -> Result<()> {
    let mut seen_names: HashSet<String> = HashSet::new();
    for (id, initiative) in initiatives {
        validate_registry_key::<InitiativeId>("initiatives", id)?;
        validate_display_name(
            "initiatives",
            id,
            &initiative.name,
            "initiative",
            &mut seen_names,
        )?;
        validate_optional_text(
            "initiatives",
            id,
            &[
                ("outcome", initiative.outcome.as_deref()),
                ("owner", initiative.owner.as_deref()),
                ("status", initiative.status.as_deref()),
            ],
        )?;
        for key in initiative.extensions.keys() {
            if INITIATIVE_KNOWN_FIELDS.contains(&key) {
                return Err(TixError::Config(format!(
                    "`initiatives.{id}.{key}` collides with a built-in field; rename the extension key"
                )));
            }
        }
        if let Some(date) = initiative.target_date.as_deref() {
            if !crate::model::validate_date(date) {
                return Err(TixError::Config(format!(
                    "`initiatives.{id}.target_date` (`{date}`) must be a `YYYY-MM-DD` calendar date"
                )));
            }
        }
        for label in &initiative.labels {
            if label.trim().is_empty() {
                return Err(TixError::Config(format!(
                    "`initiatives.{id}.labels` must not contain a blank value"
                )));
            }
        }
        for reference in &initiative.projects {
            ProjectId::new(reference)
                .map_err(|err| TixError::Config(format!("`initiatives.{id}.projects`: {err}")))?;
        }
        for reference in &initiative.epics {
            EpicId::new(reference)
                .map_err(|err| TixError::Config(format!("`initiatives.{id}.epics`: {err}")))?;
        }
        for reference in &initiative.milestones {
            if reference.trim().is_empty() {
                return Err(TixError::Config(format!(
                    "`initiatives.{id}.milestones` must not contain a blank value"
                )));
            }
        }
    }
    Ok(())
}

/// Per-cycle validation (NFR-003): non-blank name, parseable dates, and
/// `due_date >= start_date` when both are present.
pub(super) fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
    if name.trim().is_empty() {
        return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
    }
    if let Some(start) = &cycle.start_date {
        if !crate::model::validate_date(start) {
            return Err(TixError::Config(format!(
                "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
            )));
        }
    }
    if let Some(due) = &cycle.due_date {
        if !crate::model::validate_date(due) {
            return Err(TixError::Config(format!(
                "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
            )));
        }
        if let Some(start) = &cycle.start_date
            && due.as_str() < start.as_str()
        {
            return Err(TixError::Config(format!(
                "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
            )));
        }
    }
    Ok(())
}

/// Per-template validation for `[templates.<name>]` (`tui-new-issue-mode`).
///
/// The rules exist because of how a template reaches the form: `labels`,
/// `cycles` and `milestones` are joined with `,` into their single text rows and
/// split again at submit, so a value that itself contains a comma would silently
/// become two values the user never typed. A blank label is refused for the same
/// reason the form filters blanks โ€” a template that "sets" an empty label sets
/// nothing. Dates follow the cycle rules verbatim.
pub(super) fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
    if name.trim().is_empty() {
        return Err(TixError::Config(
            "`templates` must not contain a blank name".into(),
        ));
    }
    for (field, values) in [
        ("labels", &template.labels),
        ("cycles", &template.cycles),
        ("milestones", &template.milestones),
    ] {
        for value in values {
            if value.trim().is_empty() {
                return Err(TixError::Config(format!(
                    "`templates.{name}.{field}` must not contain a blank value"
                )));
            }
            if value.contains(',') {
                return Err(TixError::Config(format!(
                    "`templates.{name}.{field}` value `{value}` must not contain a comma \
                     โ€” the form joins these with `,`"
                )));
            }
        }
    }
    if let Some(start) = &template.start_date
        && !crate::model::validate_date(start)
    {
        return Err(TixError::Config(format!(
            "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
        )));
    }
    if let Some(due) = &template.due_date {
        if !crate::model::validate_date(due) {
            return Err(TixError::Config(format!(
                "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
            )));
        }
        if let Some(start) = &template.start_date
            && due.as_str() < start.as_str()
        {
            return Err(TixError::Config(format!(
                "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
            )));
        }
    }
    Ok(())
}

/// Per-configuration validation (NFR-003): non-empty unique non-blank
/// columns, `done_column` โІ columns, `hidden_columns` โІ columns.
/// The `[categories]` section's own rules (contract ยง2, ยง4).
///
/// Both are refusals of a *default*, which is the escalation the contract names:
/// a default naming a category outside the vocabulary is a typo, and a default
/// that resolves to `completed` would silently mark every unmapped column done.
pub(super) fn validate_categories(categories: &CategoryConfig) -> Result<()> {
    if categories.order.is_empty() {
        return Err(TixError::Config(
            "`categories.order` must not be empty โ€” it is the global vocabulary".into(),
        ));
    }
    for key in &categories.order {
        if !CATEGORY_KEYS.contains(&key.as_str()) {
            return Err(TixError::Config(format!(
                "unknown_category: `categories.order` names `{key}`, which is not in the \
                 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
                 board maps columns into it and does not define it.",
                CATEGORY_KEYS.join(", ")
            )));
        }
    }
    if !categories.contains(&categories.default) {
        return Err(TixError::Config(format!(
            "unknown_category: `categories.default` is `{}`, which is not in \
             `categories.order`",
            categories.default
        )));
    }
    if categories.default == COMPLETE_CATEGORY {
        return Err(TixError::Config(format!(
            "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
             without an explicit mapping would resolve to complete, which changes \
             completion meaning by accident. Map the completing column explicitly \
             instead."
        )));
    }
    Ok(())
}

/// Per-board mapping validation (contract ยง3).
///
/// A board either has a completion category or is an explicit configuration
/// error; there is no silent "nothing completes". A *missing* mapping is never an
/// error โ€” it resolves by the safe default โ€” because erroring would reject every
/// existing custom configuration (NFR-003, C-007).
pub(super) fn validate_category_map(
    name: &str,
    board: &BoardConfiguration,
    categories: &CategoryConfig,
) -> Result<()> {
    for (column, key) in &board.category_map {
        if !board.columns.iter().any(|c| c == column) {
            return Err(TixError::Config(format!(
                "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
                 `{column}`, which is not one of the board's columns ({})",
                board.columns.join(", ")
            )));
        }
        if !categories.contains(key) {
            return Err(TixError::Config(format!(
                "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
                 `{key}`, which is not in the vocabulary ({})",
                categories.order.join(", ")
            )));
        }
    }
    // Only a board that *declares* a map is held to naming a completion column. A
    // board with no map gets `done_column` -> `completed` in `category_for`, so
    // counting completions here would reject every legacy config.
    if board.category_map.is_empty() {
        return Ok(());
    }
    let completers: Vec<&String> = board
        .category_map
        .iter()
        .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
        .map(|(column, _)| column)
        .collect();
    match completers.as_slice() {
        [] => Err(TixError::Config(format!(
            "missing_completion_category: `kanban.{name}.category_map` maps no column \
             to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
             is ever complete โ€” map exactly one column to it.",
            board.category_map.len()
        ))),
        [_only] => Ok(()),
        many => Err(TixError::Config(format!(
            "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
             ({}); exactly one column may complete work",
            many.len(),
            many.iter()
                .map(|c| c.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        ))),
    }
}

pub(super) fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
    if board.columns.is_empty() {
        return Err(TixError::Config(format!(
            "`kanban.{name}.columns` must not be empty"
        )));
    }
    if board.columns.iter().any(|c| c.trim().is_empty()) {
        return Err(TixError::Config(format!(
            "`kanban.{name}.columns` must not contain blank names"
        )));
    }
    let mut seen: HashSet<&str> = HashSet::new();
    for column in &board.columns {
        if !seen.insert(column.as_str()) {
            return Err(TixError::Config(format!(
                "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
            )));
        }
    }
    if let Some(done) = &board.done_column
        && !board.columns.contains(done)
    {
        return Err(TixError::Config(format!(
            "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
        )));
    }
    for hidden in &board.hidden_columns {
        if !board.columns.contains(hidden) {
            return Err(TixError::Config(format!(
                "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
            )));
        }
    }
    if board.title_lines == 0 {
        return Err(TixError::Config(format!(
            "`kanban.{name}.title_lines` must be at least 1"
        )));
    }
    // The WIP schema validates itself against this board's own columns, so a limit
    // can only ever be attached to a column the board declares (T001/T003).
    board.wip.validate(name, &board.columns)?;
    Ok(())
}