tix-tracker 0.12.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
//! The **one** implementation of a schedule registry change: create, edit, delete,
//! and the selector that names a schedule (WP07 review finding H4).
//!
//! # Why this module exists
//!
//! `tix cycle schedule create|edit|delete` was the first implementation of these
//! three operations, and the MCP adapter copied it. A copy is not a shortcut: the
//! copy was already **behind** the original — its spent-id guard counted stored cycle
//! records only, so following its own advice (delete the record) reopened the hole the
//! original had closed by counting the closed-cycle snapshot too. That divergence is
//! exactly what C-008 forbids, and a third copy in the TUI form would have made it
//! worse. The CLI, the TUI's `F8` Schedule page and the MCP tools therefore all call
//! **these** functions, and a change to the guard, the resolution rule or the
//! vocabulary reaches every surface at once.
//!
//! # What this module is not
//!
//! It does not print, prompt or confirm: `--confirm` is the CLI's flag, the form's
//! submit is the TUI's explicit action, and a tool's argument is MCP's. Consent is the
//! *caller's* before either reaches here. It also owns no second validator and no
//! second vocabulary: the cadence family and unit spellings are the ones
//! [`crate::recurrence::rule`] defines, and `tix.toml` is validated by
//! [`Config::validate`] **before** it is written, so a refused change leaves the file
//! exactly as it was.
//!
//! # The registry rules, stated once
//!
//! * The **table key** is the identity: `name` is a mutable label and the key is a
//!   [`CycleScheduleId`]. A selector resolves by id first, then by a unique
//!   case-folded name, and an ambiguous name is refused rather than picked.
//! * A deleted schedule's id stays **spent** (C-003). It is claimed by a stored cycle
//!   record **or** by a closed-cycle snapshot, because a snapshot is immutable history
//!   a new definition must not inherit — `close` finds the existing snapshot
//!   ([`SnapshotOccupant::Same`](crate::cycle_schedule::snapshot::SnapshotOccupant))
//!   and would report it "already captured" while the record's start/due/actor/closed
//!   time silently described the old definition. The refusal names the actual holder.
//! * An edit changes only the fields the caller named; the rest of the record —
//!   including the generation cursor and preserved metadata — is carried over.

use std::path::Path;

use crate::config::Config;
use crate::cycle_schedule::config::{
    Cadence, CycleDuration, CycleScheduleConfiguration, CycleScheduleExtensions, CycleScheduleId,
    ScheduleState,
};
use crate::cycle_schedule::identity::ScheduleIndex;
use crate::cycle_schedule::snapshot::CycleSnapshotStore;
use crate::cycle_store::CycleStore;
use crate::error::{Result, TixError, render_diagnostics};
use crate::recurrence::rule::{IntervalUnit, RuleKind, Timezone};

/// The fields a caller spells to **define** a schedule (`create`).
///
/// The spellings are the contract's, not Rust values: a surface that takes text (the
/// CLI's flags, the TUI's form) hands the text over and this module parses it, so
/// "what is a cadence family" has one answer for every caller.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleDraft {
    /// The stable id; the registry key, never reused.
    pub id: String,
    /// The display label, unique case-insensitively.
    pub name: String,
    /// The cadence family: `daily`, `weekly`, `monthly` or `interval`.
    pub cadence: String,
    /// How many cadence units between starts when the family is `interval`.
    pub interval: Option<u32>,
    /// The unit an `interval` cadence counts in: `days`, `weeks` or `months`.
    pub unit: Option<String>,
    /// How long one cycle lasts.
    pub duration: u32,
    /// The duration's unit: `days`, `weeks` or `months`.
    pub duration_unit: String,
    /// The anchor date, `YYYY-MM-DD`.
    pub start_date: String,
    /// The fixed UTC offset the schedule is expressed in.
    pub timezone: String,
    /// How many future cycles to consider; absent means none.
    pub look_ahead: Option<u32>,
}

/// The fields a caller changes (`edit`).
///
/// `None` means **leave it as it is** — the CLI's flag semantics, and the reason a
/// caller cannot clear an optional field here. The validator is what refuses a
/// combination the record cannot carry (a unit on a family that has none), with the
/// field named, rather than this module inventing a normalization for it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleEdit {
    /// The new display label.
    pub name: Option<String>,
    /// The new cadence family.
    pub cadence: Option<String>,
    /// The new cadence interval.
    pub interval: Option<u32>,
    /// The new cadence unit.
    pub unit: Option<String>,
    /// The new duration count.
    pub duration: Option<u32>,
    /// The new duration unit.
    pub duration_unit: Option<String>,
    /// The new anchor date.
    pub start_date: Option<String>,
    /// The new fixed offset.
    pub timezone: Option<String>,
    /// The new look-ahead count.
    pub look_ahead: Option<u32>,
}

/// What a delete removed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleDeletion {
    /// The schedule that is no longer defined.
    pub id: CycleScheduleId,
    /// How many generated cycles still reference it — the records and snapshots a
    /// delete deliberately leaves on disk, for a surface to report.
    pub generated_cycles: usize,
}

/// The read model, built from the registry and the records on disk.
///
/// Rebuilt per call rather than cached: a stale index must not be able to decide a
/// write, and every read is cheap (WP02/WP04 make the same choice).
pub fn load_index(config: &Config, root: &Path) -> Result<ScheduleIndex> {
    let records = CycleStore::new(root).list()?;
    Ok(ScheduleIndex::build(config, &records))
}

/// Resolve a schedule selector: the stable id first, then a unique display name.
///
/// The name is folded exactly as the registry's uniqueness rule folds it
/// (`to_lowercase`, not an ASCII-only fold), so a name that is unique in `tix.toml`
/// is always resolvable — including one carrying a non-ASCII letter. An ambiguous
/// name names every candidate rather than picking one.
pub fn resolve(index: &ScheduleIndex, selector: &str) -> Result<CycleScheduleId> {
    let trimmed = selector.trim();
    if let Ok(id) = CycleScheduleId::new(trimmed)
        && index.schedule(&id).is_some()
    {
        return Ok(id);
    }
    let folded = trimmed.to_lowercase();
    let matches: Vec<CycleScheduleId> = index
        .schedules()
        .filter(|(_, record)| record.name.to_lowercase() == folded)
        .map(|(id, _)| id.clone())
        .collect();
    match matches.as_slice() {
        [id] => Ok(id.clone()),
        [] => Err(TixError::NotFound(format!("cycle schedule `{trimmed}`"))),
        many => Err(TixError::InvalidInput(format!(
            "cycle schedule name `{trimmed}` is ambiguous: {}",
            many.iter()
                .map(CycleScheduleId::as_str)
                .collect::<Vec<_>>()
                .join(", ")
        ))),
    }
}

/// Define a new schedule, validated **before** persistence.
///
/// A refused create writes nothing: the id-exists and id-spent refusals return before
/// the record is inserted, and the validator runs before the file is written, so the
/// registry is exactly as it was.
pub fn create(config: &mut Config, root: &Path, draft: &ScheduleDraft) -> Result<CycleScheduleId> {
    let id = CycleScheduleId::new(&draft.id)?;
    if config.cycle_schedules.contains_key(id.as_str()) {
        return Err(refuse(
            "exists",
            format!(
                "schedule `{id}` already exists; ids are never reused — choose another `--id` or \
                 edit the existing schedule"
            ),
        ));
    }
    // C-003: a deleted schedule's id stays spent. A re-created id would silently
    // rebind the records *and the snapshots* still carrying it, and a snapshot is
    // immutable history the new definition must not inherit.
    let claimed_by_record = load_index(config, root)?.cycles_of(&id).next().is_some();
    let claimed_by_snapshot = CycleSnapshotStore::new(root)
        .list()?
        .iter()
        .any(|snapshot| snapshot.schedule_id == id);
    if claimed_by_record || claimed_by_snapshot {
        let holder = match (claimed_by_record, claimed_by_snapshot) {
            (true, true) => "generated cycle record(s) and closed-cycle snapshot(s)",
            (true, false) => "generated cycle record(s)",
            (false, _) => "closed-cycle snapshot(s)",
        };
        return Err(refuse(
            "id_spent",
            format!(
                "schedule id `{id}` is still claimed by {holder}; a deleted schedule's id is \
                 never reused, so nothing is silently rebound to a new definition — choose a new \
                 `--id`, or delete those {holder} deliberately"
            ),
        ));
    }
    let record = record_for(draft)?;
    config
        .cycle_schedules
        .insert(id.as_str().to_string(), record);
    save(config, root)?;
    Ok(id)
}

/// Change the schedule `selector` names, keeping its identity and every field the
/// caller did not name.
pub fn edit(
    config: &mut Config,
    root: &Path,
    selector: &str,
    patch: &ScheduleEdit,
) -> Result<CycleScheduleId> {
    let id = resolve(&load_index(config, root)?, selector)?;
    let mut record = config
        .cycle_schedules
        .get(id.as_str())
        .cloned()
        .expect("a resolved schedule is in the registry");

    if let Some(name) = patch.name.as_deref() {
        record.name = name.to_string();
    }
    if patch.cadence.is_some() || patch.interval.is_some() || patch.unit.is_some() {
        let mut cadence = record.cadence.clone();
        if let Some(kind) = patch.cadence.as_deref() {
            cadence.kind = Some(cadence_kind(kind)?);
        }
        if let Some(interval) = patch.interval {
            cadence.interval = Some(interval);
        }
        if let Some(unit) = patch.unit.as_deref() {
            cadence.unit = Some(interval_unit(unit)?);
        }
        record.cadence = cadence;
    }
    if patch.duration.is_some() || patch.duration_unit.is_some() {
        let count = patch.duration.unwrap_or(record.duration.count);
        let unit = match patch.duration_unit.as_deref() {
            Some(unit) => Some(interval_unit(unit)?),
            None => record.duration.unit,
        };
        record.duration = CycleDuration {
            count,
            unit,
            extensions: record.duration.extensions.clone(),
        };
    }
    if let Some(start_date) = patch.start_date.as_deref() {
        record.start_date = start_date.to_string();
    }
    if let Some(timezone) = patch.timezone.as_deref() {
        record.timezone = Some(Timezone::parse(timezone));
    }
    if let Some(look_ahead) = patch.look_ahead {
        record.look_ahead = look_ahead;
    }
    config
        .cycle_schedules
        .insert(id.as_str().to_string(), record);
    save(config, root)?;
    Ok(id)
}

/// Remove the registry entry `selector` names.
///
/// The generated cycles it created and their snapshots are **not** deleted: they keep
/// their schedule reference, which then reads as a diagnosable missing schedule, so
/// nothing is destroyed by deleting a definition. The count is returned for a surface
/// to report that.
pub fn delete(config: &mut Config, root: &Path, selector: &str) -> Result<ScheduleDeletion> {
    let index = load_index(config, root)?;
    let id = resolve(&index, selector)?;
    let generated_cycles = index.cycles_of(&id).count();
    config.cycle_schedules.shift_remove(id.as_str());
    save(config, root)?;
    Ok(ScheduleDeletion {
        id,
        generated_cycles,
    })
}

/// The record a draft defines, without writing anything.
///
/// Split out so a caller can validate a definition (and a surface can show what it
/// would create) before it is persisted; every spelling is parsed here, so there is
/// one answer to "is `fortnightly` a cadence family".
pub fn record_for(draft: &ScheduleDraft) -> Result<CycleScheduleConfiguration> {
    Ok(CycleScheduleConfiguration {
        name: draft.name.clone(),
        cadence: build_cadence(
            draft.cadence.as_str(),
            draft.interval,
            draft.unit.as_deref(),
        )?,
        duration: CycleDuration::new(draft.duration, interval_unit(&draft.duration_unit)?),
        start_date: draft.start_date.clone(),
        timezone: Some(Timezone::parse(&draft.timezone)),
        look_ahead: draft.look_ahead.unwrap_or(0),
        state: ScheduleState::Active,
        cursor: None,
        extensions: CycleScheduleExtensions::default(),
    })
}

/// Persist the registry after an accepted change, validating first so a change that
/// would make `tix.toml` unloadable is refused before it is written.
pub fn save(config: &mut Config, root: &Path) -> Result<()> {
    config.validate()?;
    let path = root.join("tix.toml");
    config
        .save(&path)
        .map_err(|error| TixError::Config(format!("could not save `{}`: {error}", path.display())))
}

/// One rendered refusal in the schedule surface's own vocabulary.
///
/// Public so a caller that refuses *before* reaching this module (a missing
/// `--confirm`, an unresolvable selector spelling) spells the same block: one
/// diagnostic renderer, one code.
pub fn refuse(kind: &'static str, message: String) -> TixError {
    TixError::Refused(render_diagnostics(
        "E-tix-cycle-schedule",
        std::iter::once((kind, message.as_str())),
    ))
}

/// Parse a cadence family from its contract spelling, using the vocabulary the rule
/// module defines rather than a second list.
pub fn cadence_kind(raw: &str) -> Result<RuleKind> {
    [
        RuleKind::Daily,
        RuleKind::Weekly,
        RuleKind::Monthly,
        RuleKind::Interval,
        RuleKind::CronLike,
    ]
    .into_iter()
    .find(|kind| kind.as_str() == raw.trim())
    .ok_or_else(|| {
        TixError::InvalidInput(format!(
            "`{raw}` is not a cadence family; expected `daily`, `weekly`, `monthly` or `interval`"
        ))
    })
}

/// Parse a unit from its contract spelling.
pub fn interval_unit(raw: &str) -> Result<IntervalUnit> {
    [
        IntervalUnit::Days,
        IntervalUnit::Weeks,
        IntervalUnit::Months,
    ]
    .into_iter()
    .find(|unit| unit.as_str() == raw.trim())
    .ok_or_else(|| {
        TixError::InvalidInput(format!(
            "`{raw}` is not a unit; expected `days`, `weeks` or `months`"
        ))
    })
}

/// A cadence from its spelled parts, refusing an unknown family or unit by name.
pub fn build_cadence(kind: &str, interval: Option<u32>, unit: Option<&str>) -> Result<Cadence> {
    let mut cadence = Cadence::new(cadence_kind(kind)?);
    if let Some(interval) = interval {
        cadence = cadence.with_interval(interval);
    }
    if let Some(unit) = unit {
        cadence = cadence.with_unit(interval_unit(unit)?);
    }
    Ok(cadence)
}

/// Validate and return a canonical `YYYY-MM-DD` date string.
pub fn canonical_date(raw: &str) -> Result<String> {
    let raw = raw.trim();
    if !crate::model::validate_date(raw) {
        return Err(TixError::InvalidInput(format!(
            "`{raw}` is not a `YYYY-MM-DD` calendar date"
        )));
    }
    Ok(raw.to_string())
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::cycle_schedule::identity::{GeneratedCycleId, ScheduleIndex, cadence_rule};
    use crate::cycle_schedule::snapshot::CycleSnapshotStore;
    use crate::cycle_store::CycleRecord;
    use std::collections::BTreeMap;
    use tempfile::TempDir;

    /// A project with a `tix.toml` and an issues directory.
    fn project() -> (TempDir, Config, std::path::PathBuf) {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path().join("issues");
        std::fs::create_dir_all(&dir).unwrap();
        let mut config = Config {
            issues_dir: dir,
            ..Config::default()
        };
        config.layout.move_on_change = false;
        config.save(&tmp.path().join("tix.toml")).unwrap();
        let root = tmp.path().to_path_buf();
        (tmp, config, root)
    }

    fn weekly() -> ScheduleDraft {
        ScheduleDraft {
            id: "cs-weekly".to_string(),
            name: "Weekly sprint".to_string(),
            cadence: "weekly".to_string(),
            duration: 1,
            duration_unit: "weeks".to_string(),
            start_date: "2026-01-05".to_string(),
            timezone: "UTC".to_string(),
            look_ahead: Some(1),
            ..Default::default()
        }
    }

    #[test]
    fn a_draft_defines_a_validated_record_and_a_snapshot_spends_the_id() {
        let (_tmp, mut config, root) = project();
        let id = create(&mut config, &root, &weekly()).unwrap();
        assert_eq!(id.as_str(), "cs-weekly");
        let record = config.cycle_schedules.get("cs-weekly").unwrap();
        assert_eq!(record.look_ahead, 1);
        assert!(cadence_rule(record).is_some(), "the cadence resolves");

        // A second create with the same id is the `exists` refusal.
        let err = create(&mut config, &root, &weekly())
            .unwrap_err()
            .to_string();
        assert!(err.contains("already exists"), "{err}");

        // Delete the record, leave only a snapshot: the id is still spent, and the
        // refusal names the snapshot rather than the (absent) record.
        delete(&mut config, &root, "cs-weekly").unwrap();
        let cycle = GeneratedCycleId::new(&id, 1);
        CycleSnapshotStore::new(&root)
            .capture(&crate::cycle_schedule::snapshot::CycleSnapshot {
                cycle_id: cycle.clone(),
                schedule_id: id.clone(),
                sequence: 1,
                start_date: "2026-01-05".to_string(),
                due_date: "2026-01-12".to_string(),
                closed_at: "2026-01-13T09:00:00Z".to_string(),
                closed_by: "tester".to_string(),
                state_summary: "closed".to_string(),
                measurements: BTreeMap::default(),
                extensions: BTreeMap::default(),
            })
            .unwrap();
        let err = create(&mut config, &root, &weekly())
            .unwrap_err()
            .to_string();
        assert!(err.contains("closed-cycle snapshot"), "{err}");
        assert!(!err.contains("generated cycle record"), "{err}");
    }

    #[test]
    fn a_selector_resolves_by_id_then_by_a_unique_folded_name() {
        let (_tmp, mut config, root) = project();
        create(&mut config, &root, &weekly()).unwrap();
        let index = load_index(&config, &root).unwrap();
        assert_eq!(resolve(&index, "cs-weekly").unwrap().as_str(), "cs-weekly");
        assert_eq!(
            resolve(&index, " WEEKLY SPRINT ").unwrap().as_str(),
            "cs-weekly"
        );
        assert!(matches!(
            resolve(&index, "nope"),
            Err(TixError::NotFound(_))
        ));
    }

    #[test]
    fn an_edit_changes_only_the_fields_it_names_and_keeps_the_cursor() {
        let (_tmp, mut config, root) = project();
        create(&mut config, &root, &weekly()).unwrap();
        let before = config.cycle_schedules.get("cs-weekly").unwrap().clone();
        let id = edit(
            &mut config,
            &root,
            "Weekly sprint",
            &ScheduleEdit {
                name: Some("Platform sprint".to_string()),
                look_ahead: Some(3),
                ..Default::default()
            },
        )
        .unwrap();
        assert_eq!(id.as_str(), "cs-weekly", "the identity survives a rename");
        let after = config.cycle_schedules.get("cs-weekly").unwrap();
        assert_eq!(after.name, "Platform sprint");
        assert_eq!(after.look_ahead, 3, "the named field changed");
        assert_eq!(after.duration, before.duration);
        assert_eq!(after.cadence, before.cadence);
        assert_eq!(after.start_date, before.start_date);
        assert_eq!(after.state, before.state);
        // A rename did not break the record's resolvability by its new name.
        let index = ScheduleIndex::build(&config, &[]);
        assert_eq!(
            resolve(&index, "platform sprint").unwrap().as_str(),
            "cs-weekly"
        );
    }

    #[test]
    fn a_delete_reports_the_records_it_left_on_disk() {
        let (_tmp, mut config, root) = project();
        create(&mut config, &root, &weekly()).unwrap();
        CycleStore::new(&root)
            .save_record(
                &CycleRecord {
                    name: "Sprint 1".to_string(),
                    start_date: Some("2026-01-05".to_string()),
                    due_date: Some("2026-01-12".to_string()),
                    schedule_id: Some("cs-weekly".to_string()),
                    sequence: Some(1),
                    state: Some("active".to_string()),
                    ..Default::default()
                },
                None,
            )
            .unwrap();
        let deletion = delete(&mut config, &root, "cs-weekly").unwrap();
        assert_eq!(deletion.generated_cycles, 1);
        assert!(!config.cycle_schedules.contains_key("cs-weekly"));
    }
}