1use crate::views::plan::ViewPlan;
41use crate::views::registry::{ViewDiagnostic, ViewDiagnosticKind, ViewId};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ViewSurface {
50 Cli,
52 Tui,
54 Mcp,
56}
57
58impl ViewSurface {
59 #[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#[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 #[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 #[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 #[must_use]
111 pub fn view_id(&self) -> &ViewId {
112 &self.view_id
113 }
114
115 #[must_use]
117 pub fn applied_plan(&self) -> &ViewPlan {
118 &self.applied_plan
119 }
120
121 #[must_use]
123 pub fn applied_at(&self) -> Option<&str> {
124 self.applied_at.as_deref()
125 }
126
127 #[must_use]
129 pub const fn source(&self) -> Option<ViewSurface> {
130 self.source
131 }
132
133 #[must_use]
136 pub fn divergence(&self, saved: &ViewPlan, live: &ViewPlan) -> ViewDivergence {
137 ViewDivergence::for_marker(self, saved, live)
138 }
139}
140
141#[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 #[must_use]
164 pub fn field(&self) -> &str {
165 &self.field
166 }
167
168 #[must_use]
170 pub fn saved(&self) -> Option<&str> {
171 self.saved.as_deref()
172 }
173
174 #[must_use]
176 pub fn live(&self) -> Option<&str> {
177 self.live.as_deref()
178 }
179}
180
181#[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
273 pub fn view_id(&self) -> Option<&ViewId> {
274 self.view_id.as_ref()
275 }
276
277 #[must_use]
279 pub fn saved_plan(&self) -> Option<&ViewPlan> {
280 self.saved_plan.as_ref()
281 }
282
283 #[must_use]
285 pub fn live_plan(&self) -> &ViewPlan {
286 &self.live_plan
287 }
288
289 #[must_use]
291 pub fn differences(&self) -> &[ViewDifference] {
292 &self.differences
293 }
294
295 #[must_use]
297 pub fn diagnostics(&self) -> &[ViewDiagnostic] {
298 &self.diagnostics
299 }
300
301 #[must_use]
306 pub fn in_sync(&self) -> bool {
307 self.diagnostics.is_empty() && self.differences.is_empty()
308 }
309}
310
311fn 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}