use crate::views::plan::ViewPlan;
use crate::views::registry::{ViewDiagnostic, ViewDiagnosticKind, ViewId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewSurface {
Cli,
Tui,
Mcp,
}
impl ViewSurface {
#[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())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ActiveViewMarker {
view_id: ViewId,
applied_plan: ViewPlan,
applied_at: Option<String>,
source: Option<ViewSurface>,
}
impl ActiveViewMarker {
#[must_use]
pub fn new(view_id: ViewId, applied_plan: ViewPlan, source: Option<ViewSurface>) -> Self {
Self {
view_id,
applied_plan,
applied_at: None,
source,
}
}
#[must_use]
pub fn with_applied_at(mut self, applied_at: impl Into<String>) -> Self {
self.applied_at = Some(applied_at.into());
self
}
#[must_use]
pub fn view_id(&self) -> &ViewId {
&self.view_id
}
#[must_use]
pub fn applied_plan(&self) -> &ViewPlan {
&self.applied_plan
}
#[must_use]
pub fn applied_at(&self) -> Option<&str> {
self.applied_at.as_deref()
}
#[must_use]
pub const fn source(&self) -> Option<ViewSurface> {
self.source
}
#[must_use]
pub fn divergence(&self, saved: &ViewPlan, live: &ViewPlan) -> ViewDivergence {
ViewDivergence::for_marker(self, saved, live)
}
}
#[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,
}
}
#[must_use]
pub fn field(&self) -> &str {
&self.field
}
#[must_use]
pub fn saved(&self) -> Option<&str> {
self.saved.as_deref()
}
#[must_use]
pub fn live(&self) -> Option<&str> {
self.live.as_deref()
}
}
#[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 {
#[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(),
}
}
#[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
}
#[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"),
],
}
}
#[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"),
],
}
}
#[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,
}
}
#[must_use]
pub fn view_id(&self) -> Option<&ViewId> {
self.view_id.as_ref()
}
#[must_use]
pub fn saved_plan(&self) -> Option<&ViewPlan> {
self.saved_plan.as_ref()
}
#[must_use]
pub fn live_plan(&self) -> &ViewPlan {
&self.live_plan
}
#[must_use]
pub fn differences(&self) -> &[ViewDifference] {
&self.differences
}
#[must_use]
pub fn diagnostics(&self) -> &[ViewDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn in_sync(&self) -> bool {
self.diagnostics.is_empty() && self.differences.is_empty()
}
}
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"
);
}
}