tix-tracker 0.10.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
//! The active-view marker and its informational divergence report (WP03;
//! mission `saved-issue-views-01M3C926`).
//!
//! Two values, both **read-only and derived**:
//!
//! - [`ActiveViewMarker`] records *which* saved definition the live board was
//!   last associated with: the stable [`ViewId`] (never the name — C-002), the
//!   normalized [`ViewPlan`] that was applied, an optional display timestamp, and
//!   the surface it came from. The marker is **metadata**: it grants nothing,
//!   proves nothing, and is never consulted by the registry's scope resolution.
//!   `source` is provenance for display, not authorization (data-model §4).
//! - [`ViewDivergence`] compares the live normalized plan with the saved
//!   definition's plan, field by field, and returns the differences in a fixed
//!   order plus an `in_sync` summary.
//!
//! # Informational, never mutating (C-006)
//!
//! Comparing two [`ViewPlan`]s cannot write: both are borrowed, and the result
//! owns copies only. Nothing here reconciles live state to the saved plan or the
//! saved plan to live state — a divergent board is *reported*, and the caller
//! decides what to do. That is the whole point of the distinction decision
//! `01M3C93PMG4WAWERRJ8YVPSQ0J` draws between "last applied" and "now saved": the
//! definition can change under a live board, and the difference between them is
//! exactly what a user needs to see.
//!
//! # Normalized comparison, not display formatting
//!
//! [`ViewPlan`] normalization happens at construction, so two plans that *mean*
//! the same thing compare equal field by field even if their TOML spellings
//! differ. The comparison walks the typed accessors, never the serialized table,
//! which is why an unknown extension key does not register as divergence
//! (unknown metadata is neither live state nor a supported setting).
//!
//! # A missing marker or a deleted view (data-model §5)
//!
//! "No active view" and "the active view no longer exists" are explicit
//! diagnostics, not silently `in_sync = true`. A caller learns the comparison had
//! nothing to compare, which is different from learning the board matches.

use crate::views::plan::ViewPlan;
use crate::views::registry::{ViewDiagnostic, ViewDiagnosticKind, ViewId};

/// The surface a marker was set from (data-model §4).
///
/// Provenance for display only. The value is never compared against anything to
/// decide what a user may do — it is not an identity, an account, or a
/// permission (C-003/C-009).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewSurface {
    /// The command-line surface.
    Cli,
    /// The terminal UI.
    Tui,
    /// The MCP tool surface.
    Mcp,
}

impl ViewSurface {
    /// The stable display name of this surface.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Cli => "cli",
            Self::Tui => "tui",
            Self::Mcp => "mcp",
        }
    }
}

impl std::fmt::Display for ViewSurface {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The selected saved view associated with live board state (data-model §4).
///
/// The marker identifies a definition; it does not own the live filter state and
/// cannot overwrite it. Reapplying the same view produces an equal marker, which
/// is what makes "mark active" idempotent.
#[derive(Debug, Clone, PartialEq)]
pub struct ActiveViewMarker {
    view_id: ViewId,
    applied_plan: ViewPlan,
    applied_at: Option<String>,
    source: Option<ViewSurface>,
}

impl ActiveViewMarker {
    /// A marker for a view, carrying the normalized plan that was applied.
    #[must_use]
    pub fn new(view_id: ViewId, applied_plan: ViewPlan, source: Option<ViewSurface>) -> Self {
        Self {
            view_id,
            applied_plan,
            applied_at: None,
            source,
        }
    }

    /// Attach an audit/display timestamp. Caller-supplied so the marker stays
    /// deterministic and the service needs no clock.
    #[must_use]
    pub fn with_applied_at(mut self, applied_at: impl Into<String>) -> Self {
        self.applied_at = Some(applied_at.into());
        self
    }

    /// The stable id of the selected definition.
    #[must_use]
    pub fn view_id(&self) -> &ViewId {
        &self.view_id
    }

    /// The normalized plan that was applied when the marker was set.
    #[must_use]
    pub fn applied_plan(&self) -> &ViewPlan {
        &self.applied_plan
    }

    /// The optional timestamp, for display.
    #[must_use]
    pub fn applied_at(&self) -> Option<&str> {
        self.applied_at.as_deref()
    }

    /// The surface the marker came from, when it is known.
    #[must_use]
    pub const fn source(&self) -> Option<ViewSurface> {
        self.source
    }

    /// Compare live state against the **saved definition's** plan, keeping this
    /// marker's identity on the report.
    #[must_use]
    pub fn divergence(&self, saved: &ViewPlan, live: &ViewPlan) -> ViewDivergence {
        ViewDivergence::for_marker(self, saved, live)
    }
}

/// One divergent field: its record-relative path, and the saved/live values.
///
/// `saved` and `live` are the normalized spellings of the field. `None` means the
/// field is unset, which is why an absent visibility snapshot (inherit the live
/// board) is distinct from a present-but-empty one (show every column).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewDifference {
    field: String,
    saved: Option<String>,
    live: Option<String>,
}

impl ViewDifference {
    fn new(field: impl Into<String>, saved: Option<String>, live: Option<String>) -> Self {
        Self {
            field: field.into(),
            saved,
            live,
        }
    }

    /// The record-relative field path (`plan.sort`, `plan.visibility.hidden_columns`).
    #[must_use]
    pub fn field(&self) -> &str {
        &self.field
    }

    /// The saved definition's normalized value, or `None` when unset.
    #[must_use]
    pub fn saved(&self) -> Option<&str> {
        self.saved.as_deref()
    }

    /// The live normalized value, or `None` when unset.
    #[must_use]
    pub fn live(&self) -> Option<&str> {
        self.live.as_deref()
    }
}

/// The read-only comparison of live state to a saved plan (data-model §5).
///
/// `differences` is ordered by the field walk in [`fields`], so two runs over the
/// same inputs report the same list in the same order. `in_sync` is a convenience
/// summary: it is true exactly when there are no differences **and** no
/// diagnostics.
#[derive(Debug, Clone, PartialEq)]
pub struct ViewDivergence {
    view_id: Option<ViewId>,
    saved_plan: Option<ViewPlan>,
    live_plan: ViewPlan,
    differences: Vec<ViewDifference>,
    diagnostics: Vec<ViewDiagnostic>,
}

impl ViewDivergence {
    /// Compare two plans directly, with no marker identity.
    #[must_use]
    pub fn between(saved: &ViewPlan, live: &ViewPlan) -> Self {
        Self {
            view_id: None,
            saved_plan: Some(saved.clone()),
            live_plan: live.clone(),
            differences: differences(saved, live),
            diagnostics: Vec::new(),
        }
    }

    /// Compare live state to a saved plan, keeping the marker's identity.
    #[must_use]
    pub fn for_marker(marker: &ActiveViewMarker, saved: &ViewPlan, live: &ViewPlan) -> Self {
        let mut divergence = Self::between(saved, live);
        divergence.view_id = Some(marker.view_id.clone());
        divergence
    }

    /// No active view is selected: an explicit diagnostic, never a silent match.
    #[must_use]
    pub fn missing_marker(live: &ViewPlan) -> Self {
        Self {
            view_id: None,
            saved_plan: None,
            live_plan: live.clone(),
            differences: Vec::new(),
            diagnostics: vec![
                ViewDiagnostic::new(
                    ViewDiagnosticKind::UnknownView,
                    "no active saved view is selected, so there is nothing to compare",
                )
                .with_field("active")
                .with_remedy("select a view with `tix view use <id>` before checking divergence"),
            ],
        }
    }

    /// The marker's view is no longer in the registry (it was deleted).
    #[must_use]
    pub fn deleted(view_id: &ViewId, live: &ViewPlan) -> Self {
        Self {
            view_id: Some(view_id.clone()),
            saved_plan: None,
            live_plan: live.clone(),
            differences: Vec::new(),
            diagnostics: vec![
                ViewDiagnostic::for_view(
                    ViewDiagnosticKind::UnknownView,
                    view_id,
                    format!(
                        "the active view `{view_id}` no longer exists; its definition was deleted"
                    ),
                )
                .with_field("active")
                .with_remedy("select another view, or clear the active marker"),
            ],
        }
    }

    /// The saved definition's plan could not be interpreted, so no comparison was
    /// possible. The refusal's own diagnostics are carried through verbatim.
    #[must_use]
    pub fn unreadable(view_id: &ViewId, live: &ViewPlan, diagnostics: Vec<ViewDiagnostic>) -> Self {
        Self {
            view_id: Some(view_id.clone()),
            saved_plan: None,
            live_plan: live.clone(),
            differences: Vec::new(),
            diagnostics,
        }
    }

    /// The saved view the comparison is about, when a marker identified one.
    #[must_use]
    pub fn view_id(&self) -> Option<&ViewId> {
        self.view_id.as_ref()
    }

    /// The saved definition's normalized plan, when it could be read.
    #[must_use]
    pub fn saved_plan(&self) -> Option<&ViewPlan> {
        self.saved_plan.as_ref()
    }

    /// The live normalized plan that was compared.
    #[must_use]
    pub fn live_plan(&self) -> &ViewPlan {
        &self.live_plan
    }

    /// The divergent fields, in a fixed field order.
    #[must_use]
    pub fn differences(&self) -> &[ViewDifference] {
        &self.differences
    }

    /// Problems that prevented or qualified the comparison.
    #[must_use]
    pub fn diagnostics(&self) -> &[ViewDiagnostic] {
        &self.diagnostics
    }

    /// Whether the live plan matches the saved plan, with no diagnostics.
    ///
    /// False for a missing marker or a deleted view: "nothing to compare" is not
    /// "in sync".
    #[must_use]
    pub fn in_sync(&self) -> bool {
        self.diagnostics.is_empty() && self.differences.is_empty()
    }
}

/// The ordered field walk. Each entry reads one normalized field from both plans
/// and records a difference when they disagree. The order here *is* the order of
/// [`ViewDivergence::differences`], so it never depends on map iteration.
fn differences(saved: &ViewPlan, live: &ViewPlan) -> Vec<ViewDifference> {
    let mut out = Vec::new();

    let saved_filter = saved.filter();
    let live_filter = live.filter();

    let mut compare = |field: &str, saved: Option<String>, live: Option<String>| {
        if saved != live {
            out.push(ViewDifference::new(field, saved, live));
        }
    };

    compare(
        "plan.filter.assignee",
        saved_filter.assignee.clone(),
        live_filter.assignee.clone(),
    );
    compare(
        "plan.filter.label",
        saved_filter.label.clone(),
        live_filter.label.clone(),
    );
    compare(
        "plan.filter.status",
        saved_filter.status.clone(),
        live_filter.status.clone(),
    );
    compare(
        "plan.filter.priority",
        saved_filter.priority.clone(),
        live_filter.priority.clone(),
    );
    compare(
        "plan.filter.cycle",
        saved_filter.cycle.clone(),
        live_filter.cycle.clone(),
    );
    compare(
        "plan.filter.milestone",
        saved_filter.milestone.clone(),
        live_filter.milestone.clone(),
    );
    compare(
        "plan.filter.reviewer",
        saved_filter.reviewer.clone(),
        live_filter.reviewer.clone(),
    );
    compare(
        "plan.filter.start_date",
        saved_filter.start_date.clone(),
        live_filter.start_date.clone(),
    );
    compare(
        "plan.filter.due_date",
        saved_filter.due_date.clone(),
        live_filter.due_date.clone(),
    );
    compare(
        "plan.filter.overdue",
        saved_filter.overdue.map(|value| value.to_string()),
        live_filter.overdue.map(|value| value.to_string()),
    );
    compare(
        "plan.filter.parent",
        saved_filter.parent.clone(),
        live_filter.parent.clone(),
    );
    compare(
        "plan.filter.category",
        saved_filter.category.clone(),
        live_filter.category.clone(),
    );
    compare(
        "plan.filter.scope",
        saved_filter.scope.as_ref().map(|scope| scope.join(",")),
        live_filter.scope.as_ref().map(|scope| scope.join(",")),
    );
    compare(
        "plan.text_query",
        saved.text_query().map(str::to_string),
        live.text_query().map(str::to_string),
    );
    compare(
        "plan.sort",
        saved.sort().map(str::to_string),
        live.sort().map(str::to_string),
    );
    compare(
        "plan.group",
        saved.group().map(str::to_string),
        live.group().map(str::to_string),
    );
    compare(
        "plan.visibility.hidden_columns",
        saved
            .visibility()
            .map(|visibility| visibility.hidden_columns().join(",")),
        live.visibility()
            .map(|visibility| visibility.hidden_columns().join(",")),
    );
    compare(
        "plan.visibility.show_mismatch",
        saved
            .visibility()
            .and_then(crate::views::plan::ViewVisibility::show_mismatch)
            .map(|value| value.to_string()),
        live.visibility()
            .and_then(crate::views::plan::ViewVisibility::show_mismatch)
            .map(|value| value.to_string()),
    );

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::search::StructuredFilter;
    use crate::views::plan::ViewVisibility;
    use crate::views::registry::ViewId;

    fn id(raw: &str) -> ViewId {
        ViewId::new(raw).unwrap()
    }

    #[test]
    fn identical_plans_are_in_sync_with_no_differences() {
        let plan = ViewPlan::from_query("label:urgent sort:due");
        let divergence = ViewDivergence::between(&plan, &plan.clone());
        assert!(divergence.in_sync());
        assert_eq!(divergence.differences(), []);
        assert_eq!(divergence.diagnostics(), []);
    }

    #[test]
    fn differences_are_reported_in_a_fixed_field_order() {
        let saved = ViewPlan::from_parts(
            StructuredFilter {
                label: Some("a".into()),
                ..StructuredFilter::default()
            },
            "sort:due",
        )
        .with_visibility(ViewVisibility::new(vec!["mismatch".into()], Some(true)));
        let live = ViewPlan::from_parts(
            StructuredFilter {
                category: Some("active".into()),
                ..StructuredFilter::default()
            },
            "label:b",
        )
        .with_group("cycle");

        let divergence = ViewDivergence::between(&saved, &live);
        let fields: Vec<&str> = divergence
            .differences()
            .iter()
            .map(ViewDifference::field)
            .collect();
        assert_eq!(
            fields,
            vec![
                "plan.filter.label",
                "plan.filter.category",
                "plan.text_query",
                "plan.sort",
                "plan.group",
                "plan.visibility.hidden_columns",
                "plan.visibility.show_mismatch",
            ]
        );
        assert!(!divergence.in_sync());
    }

    #[test]
    fn an_absent_visibility_snapshot_differs_from_an_empty_one() {
        let saved = ViewPlan::from_query("label:a");
        let live = saved
            .clone()
            .with_visibility(ViewVisibility::new(vec![], None));
        let divergence = ViewDivergence::between(&saved, &live);
        let difference = divergence
            .differences()
            .iter()
            .find(|difference| difference.field() == "plan.visibility.hidden_columns")
            .expect("an empty snapshot still differs from inheriting the live board");
        assert_eq!(difference.saved(), None);
        assert_eq!(difference.live(), Some(""));
    }

    #[test]
    fn a_marker_keeps_the_view_identity_on_the_report() {
        let plan = ViewPlan::from_query("label:a");
        let marker = ActiveViewMarker::new(id("v-one"), plan.clone(), Some(ViewSurface::Cli));
        let divergence = marker.divergence(&plan, &ViewPlan::from_query("label:b"));
        assert_eq!(divergence.view_id(), Some(&id("v-one")));
        assert_eq!(divergence.live_plan().text_query(), Some("label:b"));
        assert_eq!(
            divergence
                .saved_plan()
                .and_then(ViewPlan::text_query)
                .map(str::to_string)
                .as_deref(),
            Some("label:a")
        );
    }

    #[test]
    fn a_missing_marker_is_an_explicit_diagnostic_not_a_silent_match() {
        let divergence = ViewDivergence::missing_marker(&ViewPlan::default());
        assert!(!divergence.in_sync());
        assert_eq!(divergence.differences(), []);
        assert_eq!(divergence.diagnostics().len(), 1);
        assert_eq!(
            divergence.diagnostics()[0].kind(),
            ViewDiagnosticKind::UnknownView
        );
    }

    #[test]
    fn a_deleted_view_is_an_explicit_diagnostic() {
        let divergence = ViewDivergence::deleted(&id("v-gone"), &ViewPlan::default());
        assert!(!divergence.in_sync());
        assert_eq!(divergence.diagnostics().len(), 1);
        assert!(
            divergence.diagnostics()[0].message().contains("v-gone"),
            "the diagnostic names the deleted view"
        );
    }
}