Skip to main content

tix/views/
divergence.rs

1//! The active-view marker and its informational divergence report (WP03;
2//! mission `saved-issue-views-01M3C926`).
3//!
4//! Two values, both **read-only and derived**:
5//!
6//! - [`ActiveViewMarker`] records *which* saved definition the live board was
7//!   last associated with: the stable [`ViewId`] (never the name — C-002), the
8//!   normalized [`ViewPlan`] that was applied, an optional display timestamp, and
9//!   the surface it came from. The marker is **metadata**: it grants nothing,
10//!   proves nothing, and is never consulted by the registry's scope resolution.
11//!   `source` is provenance for display, not authorization (data-model §4).
12//! - [`ViewDivergence`] compares the live normalized plan with the saved
13//!   definition's plan, field by field, and returns the differences in a fixed
14//!   order plus an `in_sync` summary.
15//!
16//! # Informational, never mutating (C-006)
17//!
18//! Comparing two [`ViewPlan`]s cannot write: both are borrowed, and the result
19//! owns copies only. Nothing here reconciles live state to the saved plan or the
20//! saved plan to live state — a divergent board is *reported*, and the caller
21//! decides what to do. That is the whole point of the distinction decision
22//! `01M3C93PMG4WAWERRJ8YVPSQ0J` draws between "last applied" and "now saved": the
23//! definition can change under a live board, and the difference between them is
24//! exactly what a user needs to see.
25//!
26//! # Normalized comparison, not display formatting
27//!
28//! [`ViewPlan`] normalization happens at construction, so two plans that *mean*
29//! the same thing compare equal field by field even if their TOML spellings
30//! differ. The comparison walks the typed accessors, never the serialized table,
31//! which is why an unknown extension key does not register as divergence
32//! (unknown metadata is neither live state nor a supported setting).
33//!
34//! # A missing marker or a deleted view (data-model §5)
35//!
36//! "No active view" and "the active view no longer exists" are explicit
37//! diagnostics, not silently `in_sync = true`. A caller learns the comparison had
38//! nothing to compare, which is different from learning the board matches.
39
40use crate::views::plan::ViewPlan;
41use crate::views::registry::{ViewDiagnostic, ViewDiagnosticKind, ViewId};
42
43/// The surface a marker was set from (data-model §4).
44///
45/// Provenance for display only. The value is never compared against anything to
46/// decide what a user may do — it is not an identity, an account, or a
47/// permission (C-003/C-009).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ViewSurface {
50    /// The command-line surface.
51    Cli,
52    /// The terminal UI.
53    Tui,
54    /// The MCP tool surface.
55    Mcp,
56}
57
58impl ViewSurface {
59    /// The stable display name of this surface.
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::Cli => "cli",
64            Self::Tui => "tui",
65            Self::Mcp => "mcp",
66        }
67    }
68}
69
70impl std::fmt::Display for ViewSurface {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(self.as_str())
73    }
74}
75
76/// The selected saved view associated with live board state (data-model §4).
77///
78/// The marker identifies a definition; it does not own the live filter state and
79/// cannot overwrite it. Reapplying the same view produces an equal marker, which
80/// is what makes "mark active" idempotent.
81#[derive(Debug, Clone, PartialEq)]
82pub struct ActiveViewMarker {
83    view_id: ViewId,
84    applied_plan: ViewPlan,
85    applied_at: Option<String>,
86    source: Option<ViewSurface>,
87}
88
89impl ActiveViewMarker {
90    /// A marker for a view, carrying the normalized plan that was applied.
91    #[must_use]
92    pub fn new(view_id: ViewId, applied_plan: ViewPlan, source: Option<ViewSurface>) -> Self {
93        Self {
94            view_id,
95            applied_plan,
96            applied_at: None,
97            source,
98        }
99    }
100
101    /// Attach an audit/display timestamp. Caller-supplied so the marker stays
102    /// deterministic and the service needs no clock.
103    #[must_use]
104    pub fn with_applied_at(mut self, applied_at: impl Into<String>) -> Self {
105        self.applied_at = Some(applied_at.into());
106        self
107    }
108
109    /// The stable id of the selected definition.
110    #[must_use]
111    pub fn view_id(&self) -> &ViewId {
112        &self.view_id
113    }
114
115    /// The normalized plan that was applied when the marker was set.
116    #[must_use]
117    pub fn applied_plan(&self) -> &ViewPlan {
118        &self.applied_plan
119    }
120
121    /// The optional timestamp, for display.
122    #[must_use]
123    pub fn applied_at(&self) -> Option<&str> {
124        self.applied_at.as_deref()
125    }
126
127    /// The surface the marker came from, when it is known.
128    #[must_use]
129    pub const fn source(&self) -> Option<ViewSurface> {
130        self.source
131    }
132
133    /// Compare live state against the **saved definition's** plan, keeping this
134    /// marker's identity on the report.
135    #[must_use]
136    pub fn divergence(&self, saved: &ViewPlan, live: &ViewPlan) -> ViewDivergence {
137        ViewDivergence::for_marker(self, saved, live)
138    }
139}
140
141/// One divergent field: its record-relative path, and the saved/live values.
142///
143/// `saved` and `live` are the normalized spellings of the field. `None` means the
144/// field is unset, which is why an absent visibility snapshot (inherit the live
145/// board) is distinct from a present-but-empty one (show every column).
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct ViewDifference {
148    field: String,
149    saved: Option<String>,
150    live: Option<String>,
151}
152
153impl ViewDifference {
154    fn new(field: impl Into<String>, saved: Option<String>, live: Option<String>) -> Self {
155        Self {
156            field: field.into(),
157            saved,
158            live,
159        }
160    }
161
162    /// The record-relative field path (`plan.sort`, `plan.visibility.hidden_columns`).
163    #[must_use]
164    pub fn field(&self) -> &str {
165        &self.field
166    }
167
168    /// The saved definition's normalized value, or `None` when unset.
169    #[must_use]
170    pub fn saved(&self) -> Option<&str> {
171        self.saved.as_deref()
172    }
173
174    /// The live normalized value, or `None` when unset.
175    #[must_use]
176    pub fn live(&self) -> Option<&str> {
177        self.live.as_deref()
178    }
179}
180
181/// The read-only comparison of live state to a saved plan (data-model §5).
182///
183/// `differences` is ordered by the field walk in [`fields`], so two runs over the
184/// same inputs report the same list in the same order. `in_sync` is a convenience
185/// summary: it is true exactly when there are no differences **and** no
186/// diagnostics.
187#[derive(Debug, Clone, PartialEq)]
188pub struct ViewDivergence {
189    view_id: Option<ViewId>,
190    saved_plan: Option<ViewPlan>,
191    live_plan: ViewPlan,
192    differences: Vec<ViewDifference>,
193    diagnostics: Vec<ViewDiagnostic>,
194}
195
196impl ViewDivergence {
197    /// Compare two plans directly, with no marker identity.
198    #[must_use]
199    pub fn between(saved: &ViewPlan, live: &ViewPlan) -> Self {
200        Self {
201            view_id: None,
202            saved_plan: Some(saved.clone()),
203            live_plan: live.clone(),
204            differences: differences(saved, live),
205            diagnostics: Vec::new(),
206        }
207    }
208
209    /// Compare live state to a saved plan, keeping the marker's identity.
210    #[must_use]
211    pub fn for_marker(marker: &ActiveViewMarker, saved: &ViewPlan, live: &ViewPlan) -> Self {
212        let mut divergence = Self::between(saved, live);
213        divergence.view_id = Some(marker.view_id.clone());
214        divergence
215    }
216
217    /// No active view is selected: an explicit diagnostic, never a silent match.
218    #[must_use]
219    pub fn missing_marker(live: &ViewPlan) -> Self {
220        Self {
221            view_id: None,
222            saved_plan: None,
223            live_plan: live.clone(),
224            differences: Vec::new(),
225            diagnostics: vec![
226                ViewDiagnostic::new(
227                    ViewDiagnosticKind::UnknownView,
228                    "no active saved view is selected, so there is nothing to compare",
229                )
230                .with_field("active")
231                .with_remedy("select a view with `tix view use <id>` before checking divergence"),
232            ],
233        }
234    }
235
236    /// The marker's view is no longer in the registry (it was deleted).
237    #[must_use]
238    pub fn deleted(view_id: &ViewId, live: &ViewPlan) -> Self {
239        Self {
240            view_id: Some(view_id.clone()),
241            saved_plan: None,
242            live_plan: live.clone(),
243            differences: Vec::new(),
244            diagnostics: vec![
245                ViewDiagnostic::for_view(
246                    ViewDiagnosticKind::UnknownView,
247                    view_id,
248                    format!(
249                        "the active view `{view_id}` no longer exists; its definition was deleted"
250                    ),
251                )
252                .with_field("active")
253                .with_remedy("select another view, or clear the active marker"),
254            ],
255        }
256    }
257
258    /// The saved definition's plan could not be interpreted, so no comparison was
259    /// possible. The refusal's own diagnostics are carried through verbatim.
260    #[must_use]
261    pub fn unreadable(view_id: &ViewId, live: &ViewPlan, diagnostics: Vec<ViewDiagnostic>) -> Self {
262        Self {
263            view_id: Some(view_id.clone()),
264            saved_plan: None,
265            live_plan: live.clone(),
266            differences: Vec::new(),
267            diagnostics,
268        }
269    }
270
271    /// The saved view the comparison is about, when a marker identified one.
272    #[must_use]
273    pub fn view_id(&self) -> Option<&ViewId> {
274        self.view_id.as_ref()
275    }
276
277    /// The saved definition's normalized plan, when it could be read.
278    #[must_use]
279    pub fn saved_plan(&self) -> Option<&ViewPlan> {
280        self.saved_plan.as_ref()
281    }
282
283    /// The live normalized plan that was compared.
284    #[must_use]
285    pub fn live_plan(&self) -> &ViewPlan {
286        &self.live_plan
287    }
288
289    /// The divergent fields, in a fixed field order.
290    #[must_use]
291    pub fn differences(&self) -> &[ViewDifference] {
292        &self.differences
293    }
294
295    /// Problems that prevented or qualified the comparison.
296    #[must_use]
297    pub fn diagnostics(&self) -> &[ViewDiagnostic] {
298        &self.diagnostics
299    }
300
301    /// Whether the live plan matches the saved plan, with no diagnostics.
302    ///
303    /// False for a missing marker or a deleted view: "nothing to compare" is not
304    /// "in sync".
305    #[must_use]
306    pub fn in_sync(&self) -> bool {
307        self.diagnostics.is_empty() && self.differences.is_empty()
308    }
309}
310
311/// The ordered field walk. Each entry reads one normalized field from both plans
312/// and records a difference when they disagree. The order here *is* the order of
313/// [`ViewDivergence::differences`], so it never depends on map iteration.
314fn differences(saved: &ViewPlan, live: &ViewPlan) -> Vec<ViewDifference> {
315    let mut out = Vec::new();
316
317    let saved_filter = saved.filter();
318    let live_filter = live.filter();
319
320    let mut compare = |field: &str, saved: Option<String>, live: Option<String>| {
321        if saved != live {
322            out.push(ViewDifference::new(field, saved, live));
323        }
324    };
325
326    compare(
327        "plan.filter.assignee",
328        saved_filter.assignee.clone(),
329        live_filter.assignee.clone(),
330    );
331    compare(
332        "plan.filter.label",
333        saved_filter.label.clone(),
334        live_filter.label.clone(),
335    );
336    compare(
337        "plan.filter.status",
338        saved_filter.status.clone(),
339        live_filter.status.clone(),
340    );
341    compare(
342        "plan.filter.priority",
343        saved_filter.priority.clone(),
344        live_filter.priority.clone(),
345    );
346    compare(
347        "plan.filter.cycle",
348        saved_filter.cycle.clone(),
349        live_filter.cycle.clone(),
350    );
351    compare(
352        "plan.filter.milestone",
353        saved_filter.milestone.clone(),
354        live_filter.milestone.clone(),
355    );
356    compare(
357        "plan.filter.reviewer",
358        saved_filter.reviewer.clone(),
359        live_filter.reviewer.clone(),
360    );
361    compare(
362        "plan.filter.start_date",
363        saved_filter.start_date.clone(),
364        live_filter.start_date.clone(),
365    );
366    compare(
367        "plan.filter.due_date",
368        saved_filter.due_date.clone(),
369        live_filter.due_date.clone(),
370    );
371    compare(
372        "plan.filter.overdue",
373        saved_filter.overdue.map(|value| value.to_string()),
374        live_filter.overdue.map(|value| value.to_string()),
375    );
376    compare(
377        "plan.filter.parent",
378        saved_filter.parent.clone(),
379        live_filter.parent.clone(),
380    );
381    compare(
382        "plan.filter.category",
383        saved_filter.category.clone(),
384        live_filter.category.clone(),
385    );
386    compare(
387        "plan.filter.scope",
388        saved_filter.scope.as_ref().map(|scope| scope.join(",")),
389        live_filter.scope.as_ref().map(|scope| scope.join(",")),
390    );
391    compare(
392        "plan.text_query",
393        saved.text_query().map(str::to_string),
394        live.text_query().map(str::to_string),
395    );
396    compare(
397        "plan.sort",
398        saved.sort().map(str::to_string),
399        live.sort().map(str::to_string),
400    );
401    compare(
402        "plan.group",
403        saved.group().map(str::to_string),
404        live.group().map(str::to_string),
405    );
406    compare(
407        "plan.visibility.hidden_columns",
408        saved
409            .visibility()
410            .map(|visibility| visibility.hidden_columns().join(",")),
411        live.visibility()
412            .map(|visibility| visibility.hidden_columns().join(",")),
413    );
414    compare(
415        "plan.visibility.show_mismatch",
416        saved
417            .visibility()
418            .and_then(crate::views::plan::ViewVisibility::show_mismatch)
419            .map(|value| value.to_string()),
420        live.visibility()
421            .and_then(crate::views::plan::ViewVisibility::show_mismatch)
422            .map(|value| value.to_string()),
423    );
424
425    out
426}
427
428#[cfg(test)]
429mod tests {
430    #[allow(clippy::wildcard_imports)]
431    use super::*;
432    use crate::search::StructuredFilter;
433    use crate::views::plan::ViewVisibility;
434    use crate::views::registry::ViewId;
435
436    fn id(raw: &str) -> ViewId {
437        ViewId::new(raw).unwrap()
438    }
439
440    #[test]
441    fn identical_plans_are_in_sync_with_no_differences() {
442        let plan = ViewPlan::from_query("label:urgent sort:due");
443        let divergence = ViewDivergence::between(&plan, &plan.clone());
444        assert!(divergence.in_sync());
445        assert_eq!(divergence.differences(), []);
446        assert_eq!(divergence.diagnostics(), []);
447    }
448
449    #[test]
450    fn differences_are_reported_in_a_fixed_field_order() {
451        let saved = ViewPlan::from_parts(
452            StructuredFilter {
453                label: Some("a".into()),
454                ..StructuredFilter::default()
455            },
456            "sort:due",
457        )
458        .with_visibility(ViewVisibility::new(vec!["mismatch".into()], Some(true)));
459        let live = ViewPlan::from_parts(
460            StructuredFilter {
461                category: Some("active".into()),
462                ..StructuredFilter::default()
463            },
464            "label:b",
465        )
466        .with_group("cycle");
467
468        let divergence = ViewDivergence::between(&saved, &live);
469        let fields: Vec<&str> = divergence
470            .differences()
471            .iter()
472            .map(ViewDifference::field)
473            .collect();
474        assert_eq!(
475            fields,
476            vec![
477                "plan.filter.label",
478                "plan.filter.category",
479                "plan.text_query",
480                "plan.sort",
481                "plan.group",
482                "plan.visibility.hidden_columns",
483                "plan.visibility.show_mismatch",
484            ]
485        );
486        assert!(!divergence.in_sync());
487    }
488
489    #[test]
490    fn an_absent_visibility_snapshot_differs_from_an_empty_one() {
491        let saved = ViewPlan::from_query("label:a");
492        let live = saved
493            .clone()
494            .with_visibility(ViewVisibility::new(vec![], None));
495        let divergence = ViewDivergence::between(&saved, &live);
496        let difference = divergence
497            .differences()
498            .iter()
499            .find(|difference| difference.field() == "plan.visibility.hidden_columns")
500            .expect("an empty snapshot still differs from inheriting the live board");
501        assert_eq!(difference.saved(), None);
502        assert_eq!(difference.live(), Some(""));
503    }
504
505    #[test]
506    fn a_marker_keeps_the_view_identity_on_the_report() {
507        let plan = ViewPlan::from_query("label:a");
508        let marker = ActiveViewMarker::new(id("v-one"), plan.clone(), Some(ViewSurface::Cli));
509        let divergence = marker.divergence(&plan, &ViewPlan::from_query("label:b"));
510        assert_eq!(divergence.view_id(), Some(&id("v-one")));
511        assert_eq!(divergence.live_plan().text_query(), Some("label:b"));
512        assert_eq!(
513            divergence
514                .saved_plan()
515                .and_then(ViewPlan::text_query)
516                .map(str::to_string)
517                .as_deref(),
518            Some("label:a")
519        );
520    }
521
522    #[test]
523    fn a_missing_marker_is_an_explicit_diagnostic_not_a_silent_match() {
524        let divergence = ViewDivergence::missing_marker(&ViewPlan::default());
525        assert!(!divergence.in_sync());
526        assert_eq!(divergence.differences(), []);
527        assert_eq!(divergence.diagnostics().len(), 1);
528        assert_eq!(
529            divergence.diagnostics()[0].kind(),
530            ViewDiagnosticKind::UnknownView
531        );
532    }
533
534    #[test]
535    fn a_deleted_view_is_an_explicit_diagnostic() {
536        let divergence = ViewDivergence::deleted(&id("v-gone"), &ViewPlan::default());
537        assert!(!divergence.in_sync());
538        assert_eq!(divergence.diagnostics().len(), 1);
539        assert!(
540            divergence.diagnostics()[0].message().contains("v-gone"),
541            "the diagnostic names the deleted view"
542        );
543    }
544}