tix/views/service.rs
1//! The saved-view service: CRUD, definition-only delete, read-only apply, and
2//! the active marker (WP03; mission `saved-issue-views-01M3C926`).
3//!
4//! This module is the **operation layer** over WP01's registry and WP02's plan.
5//! It adds no storage, no query grammar, and no second result formula:
6//!
7//! - every write goes through [`ViewRegistry::commit`], the one atomic path, so
8//! an accepted mutation is a durable `tix.toml` change and a refused one writes
9//! no config and no History (C-008);
10//! - every apply goes through [`ViewPlan::apply_as`], the one
11//! `BoardState::build_filtered` pass, so the result is the existing query
12//! result and the corpus/config are only borrowed (C-005/C-007);
13//! - the active marker and its divergence live in [`crate::views::divergence`]
14//! and are informational: selecting a view never rewrites live filters (C-006).
15//!
16//! # Read-only versus write operations
17//!
18//! The split is deliberate and visible in the signatures:
19//!
20//! | operation | returns | writes |
21//! |---|---|---|
22//! | `list`, `show`, `apply`, `divergence` | `ViewResult<..>` | nothing |
23//! | `create`, `update`, `duplicate`, `delete` | `ViewMutation<..>` | one `tix.toml` commit |
24//! | `mark_active`, `clear_active` | `ViewResult` / `()` | nothing (in-memory marker) |
25//!
26//! A read can only be refused (a `ViewRefusal` with diagnostics). A write can also
27//! *fail* (the atomic save could not complete) — the registry already models that
28//! as [`ViewWriteError`], and the service keeps the distinction rather than
29//! flattening it.
30//!
31//! # Auditable, idempotent mutations (T017/C-008)
32//!
33//! An accepted mutation returns a [`ViewOutcome`] carrying exactly one
34//! [`ViewAuditEvent`] — what changed, for which stable id, under which name. A
35//! refused or failed operation returns `Err` and carries **no** event, because
36//! nothing happened. A mutation whose candidate equals the current registry (a
37//! same-content `update`) is a **no-op**: no commit is issued, and the outcome
38//! reports `is_noop()` with no event. Committing the same registry twice writes
39//! the same bytes, so "apply the same operation again" converges.
40//!
41//! There is no separate audit *file*. The existing registry commit is the
42//! persistence, and the event is the structured record the operation returns; a
43//! surface renders it without this module inventing a second log (research R10,
44//! data-model §8).
45//!
46//! # Definition-only delete (T014/C-005)
47//!
48//! [`ViewService::delete`] cannot be called without a [`DeleteConfirmed`], a
49//! token whose field is private so "I forgot to confirm" is a compile error
50//! rather than a defaulted `true`. It removes the definition and nothing else:
51//! no issue, comment, relationship, or board data is touched, and the outcome
52//! reports whether the active marker named the deleted view so the caller can
53//! clear it.
54
55use std::path::Path;
56
57use crate::config::{BoardConfiguration, Config};
58use crate::search::Corpus;
59use crate::views::divergence::{ActiveViewMarker, ViewDivergence, ViewSurface};
60use crate::views::plan::{ViewApplyResult, ViewPlan};
61use crate::views::registry::{
62 ProjectDirectory, SavedView, ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRefusal,
63 ViewRegistry, ViewResult, ViewScope, ViewWriteResult,
64};
65use crate::workflow::CategoryResolution;
66
67/// The outcome of a service **write**: an accepted value plus its audit event, or
68/// a refusal/failure that wrote nothing.
69pub type ViewMutation<T> = ViewWriteResult<ViewOutcome<T>>;
70
71/// What kind of registry mutation an accepted operation performed.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ViewMutationKind {
74 /// A new definition was added under a freshly allocated stable id.
75 Create,
76 /// An existing definition's name, scope, or plan was replaced.
77 Update,
78 /// A new definition was copied from an existing one under a new stable id.
79 Duplicate,
80 /// A definition was removed. Only the definition.
81 Delete,
82}
83
84impl ViewMutationKind {
85 /// The stable name of this mutation, for output and matching.
86 #[must_use]
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::Create => "create",
90 Self::Update => "update",
91 Self::Duplicate => "duplicate",
92 Self::Delete => "delete",
93 }
94 }
95}
96
97impl std::fmt::Display for ViewMutationKind {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.write_str(self.as_str())
100 }
101}
102
103/// The auditable record of one **accepted** registry mutation (T017).
104///
105/// Emitted once per accepted change, and never for a refusal or a no-op. The view
106/// id is the identity; the name is the display label *at the time of the
107/// operation*, kept for a readable history even after a later rename.
108#[derive(Debug, Clone, PartialEq)]
109pub struct ViewAuditEvent {
110 kind: ViewMutationKind,
111 view_id: ViewId,
112 view_name: String,
113}
114
115impl ViewAuditEvent {
116 fn new(kind: ViewMutationKind, view_id: ViewId, view_name: String) -> Self {
117 Self {
118 kind,
119 view_id,
120 view_name,
121 }
122 }
123
124 /// What kind of mutation this was.
125 #[must_use]
126 pub const fn kind(&self) -> ViewMutationKind {
127 self.kind
128 }
129
130 /// The stable id of the affected definition.
131 #[must_use]
132 pub fn view_id(&self) -> &ViewId {
133 &self.view_id
134 }
135
136 /// The display name the definition carried when the mutation was accepted.
137 #[must_use]
138 pub fn view_name(&self) -> &str {
139 &self.view_name
140 }
141}
142
143/// An accepted operation's value and its audit event.
144///
145/// `audit` is `None` exactly when the candidate registry equalled the current one
146/// — an idempotent no-op that issued no commit.
147#[derive(Debug, Clone, PartialEq)]
148pub struct ViewOutcome<T> {
149 value: T,
150 audit: Option<ViewAuditEvent>,
151}
152
153impl<T> ViewOutcome<T> {
154 fn accepted(value: T, audit: ViewAuditEvent) -> Self {
155 Self {
156 value,
157 audit: Some(audit),
158 }
159 }
160
161 fn unchanged(value: T) -> Self {
162 Self { value, audit: None }
163 }
164
165 /// The operation's result value.
166 #[must_use]
167 pub fn value(&self) -> &T {
168 &self.value
169 }
170
171 /// Consume the outcome for its value.
172 #[must_use]
173 pub fn into_value(self) -> T {
174 self.value
175 }
176
177 /// The one audit event, or `None` for an idempotent no-op.
178 #[must_use]
179 pub fn audit(&self) -> Option<&ViewAuditEvent> {
180 self.audit.as_ref()
181 }
182
183 /// Whether the operation changed nothing (no commit, no event).
184 #[must_use]
185 pub fn is_noop(&self) -> bool {
186 self.audit.is_none()
187 }
188}
189
190/// Explicit confirmation for a definition-only delete (T014).
191///
192/// The private field is the whole point: the token cannot be built outside this
193/// module, so [`ViewService::delete`] is not callable without a deliberate
194/// confirmation. There is no `Default` and no `From<bool>`.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct DeleteConfirmed(());
197
198impl DeleteConfirmed {
199 /// Acknowledge that only the view **definition** is being removed — never its
200 /// issues, comments, relationships, or board data.
201 #[must_use]
202 pub const fn confirmed() -> Self {
203 Self(())
204 }
205}
206
207/// A full definition handed to `create`/`update`: the mutable fields of a record.
208///
209/// The id and the preserved extension fields are **not** here: identity is
210/// allocated by the registry and extensions survive an update untouched, so a
211/// caller cannot accidentally drop either (NFR-002).
212#[derive(Debug, Clone, PartialEq)]
213pub struct ViewDefinition {
214 name: String,
215 scope: ViewScope,
216 plan: ViewPlan,
217}
218
219impl ViewDefinition {
220 /// A named, scoped, normalized plan.
221 #[must_use]
222 pub fn new(name: impl Into<String>, scope: ViewScope, plan: ViewPlan) -> Self {
223 Self {
224 name: name.into(),
225 scope,
226 plan,
227 }
228 }
229
230 /// The display label.
231 #[must_use]
232 pub fn name(&self) -> &str {
233 &self.name
234 }
235
236 /// The explicit scope metadata.
237 #[must_use]
238 pub fn scope(&self) -> &ViewScope {
239 &self.scope
240 }
241
242 /// The normalized query/board plan.
243 #[must_use]
244 pub fn plan(&self) -> &ViewPlan {
245 &self.plan
246 }
247}
248
249/// A definition shown to a caller, with its plan interpreted and any preserved
250/// unknown metadata surfaced (T013).
251#[derive(Debug, Clone, PartialEq)]
252pub struct ViewDetail {
253 view: SavedView,
254 plan: ViewPlan,
255 diagnostics: Vec<ViewDiagnostic>,
256}
257
258impl ViewDetail {
259 /// The stored record (id, name, scope, raw plan, extensions).
260 #[must_use]
261 pub fn view(&self) -> &SavedView {
262 &self.view
263 }
264
265 /// The plan, parsed and normalized.
266 #[must_use]
267 pub fn plan(&self) -> &ViewPlan {
268 &self.plan
269 }
270
271 /// Unknown-metadata notices for the record and its plan; never a refusal.
272 #[must_use]
273 pub fn diagnostics(&self) -> &[ViewDiagnostic] {
274 &self.diagnostics
275 }
276}
277
278/// The value of a successful delete: the removed definition, and whether the
279/// active marker named it (T014).
280#[derive(Debug, Clone, PartialEq)]
281pub struct DeletedView {
282 view: SavedView,
283 marker_cleared: bool,
284}
285
286impl DeletedView {
287 /// The definition that was removed. Issues and comments are untouched.
288 #[must_use]
289 pub fn view(&self) -> &SavedView {
290 &self.view
291 }
292
293 /// Whether the service's active marker pointed at the deleted view and was
294 /// cleared as a result.
295 #[must_use]
296 pub const fn marker_cleared(&self) -> bool {
297 self.marker_cleared
298 }
299}
300
301/// The saved-view service: the registry, the caller's config, and the project
302/// directory the scope checks resolve against.
303///
304/// The service borrows the caller's [`Config`] mutably because an accepted
305/// mutation *is* `config.views` changing; it never reaches around the registry to
306/// write anything else. The active marker is in-memory state beside the config —
307/// data-model §4's "if explicitly persisted" — so marking a view writes no file.
308pub struct ViewService<'a, P: ProjectDirectory + ?Sized> {
309 config: &'a mut Config,
310 projects: &'a P,
311 path: &'a Path,
312 active: Option<ActiveViewMarker>,
313}
314
315impl<'a, P: ProjectDirectory + ?Sized> ViewService<'a, P> {
316 /// A service over a caller's config, project directory, and `tix.toml` path.
317 #[must_use]
318 pub fn new(config: &'a mut Config, projects: &'a P, path: &'a Path) -> Self {
319 Self {
320 config,
321 projects,
322 path,
323 active: None,
324 }
325 }
326
327 /// The registry, for a surface that needs to enumerate or render it directly.
328 #[must_use]
329 pub fn registry(&self) -> &ViewRegistry {
330 &self.config.views
331 }
332
333 // -- read-only --------------------------------------------------------
334
335 /// Every stored view, in declaration order (T013).
336 #[must_use]
337 pub fn list(&self) -> Vec<SavedView> {
338 self.config.views.iter().cloned().collect()
339 }
340
341 /// Resolve one view by stable id or display name, parse and validate its plan,
342 /// and return it with any unknown-metadata diagnostics (T013).
343 ///
344 /// A malformed plan is refused here, before a caller could try to apply or
345 /// re-save it: "show" is the first place a stored-but-unsupported field would
346 /// otherwise slip through.
347 pub fn show(&self, reference: &str) -> ViewResult<ViewDetail> {
348 let view = self.config.views.select(reference, self.projects)?;
349 let plan = ViewPlan::from_toml(&view.plan)?;
350 self.validate_plan(&plan)?;
351
352 let mut diagnostics: Vec<ViewDiagnostic> = self
353 .config
354 .views
355 .diagnostics(self.projects)
356 .into_iter()
357 .filter(|diagnostic| diagnostic.view_id() == Some(&view.id))
358 .collect();
359 diagnostics.extend(plan.diagnostics());
360 Ok(ViewDetail {
361 view: view.clone(),
362 plan,
363 diagnostics,
364 })
365 }
366
367 /// Apply a saved view: resolve it, then build the board **once** through the
368 /// existing filter path (T015/C-005).
369 ///
370 /// Read-only: `board`, `corpus`, and `config` are borrowed, and the result
371 /// owns only derived copies. Nothing is written, and a refused or unsupported
372 /// plan produces no board.
373 pub fn apply(
374 &self,
375 reference: &str,
376 board: &BoardConfiguration,
377 corpus: &Corpus,
378 resolution: &CategoryResolution,
379 ) -> ViewResult<ViewApplyResult> {
380 let view = self.config.views.select(reference, self.projects)?;
381 let plan = ViewPlan::from_toml(&view.plan)?;
382 plan.apply_as(&view.id, board, self.config, corpus, resolution)
383 }
384
385 // -- active marker (informational) ------------------------------------
386
387 /// The active marker, when a view has been selected.
388 #[must_use]
389 pub fn active(&self) -> Option<&ActiveViewMarker> {
390 self.active.as_ref()
391 }
392
393 /// Select a saved view as the active marker (T016).
394 ///
395 /// The plan is parsed and validated first, so an unsupported definition
396 /// cannot be marked. Selecting is **informational and idempotent**: it writes
397 /// no config and does not touch live filters, and selecting the same view
398 /// twice leaves an equal marker (C-006).
399 pub fn mark_active(&mut self, reference: &str, source: Option<ViewSurface>) -> ViewResult<()> {
400 let (id, plan) = {
401 let view = self.config.views.select(reference, self.projects)?;
402 let plan = ViewPlan::from_toml(&view.plan)?;
403 self.validate_plan(&plan)?;
404 (view.id.clone(), plan)
405 };
406 self.active = Some(ActiveViewMarker::new(id, plan, source));
407 Ok(())
408 }
409
410 /// Install a marker directly, for a surface restoring one it persisted.
411 ///
412 /// Still informational: this writes nothing, and a marker whose view has since
413 /// been deleted is reported (not repaired) by [`Self::divergence`].
414 pub fn set_active(&mut self, marker: ActiveViewMarker) {
415 self.active = Some(marker);
416 }
417
418 /// Clear the active marker. Informational, writes nothing.
419 pub fn clear_active(&mut self) {
420 self.active = None;
421 }
422
423 /// Compare the live plan against the active view's **saved** definition
424 /// (T016).
425 ///
426 /// Never mutates anything. A missing marker, a deleted view, or an
427 /// uninterpretable saved plan is an explicit diagnostic rather than a
428 /// `in_sync = true`.
429 #[must_use]
430 pub fn divergence(&self, live: &ViewPlan) -> ViewDivergence {
431 let Some(marker) = &self.active else {
432 return ViewDivergence::missing_marker(live);
433 };
434 match self.config.views.get(marker.view_id()) {
435 None => ViewDivergence::deleted(marker.view_id(), live),
436 Some(view) => match ViewPlan::from_toml(&view.plan) {
437 Ok(saved) => marker.divergence(&saved, live),
438 Err(refusal) => ViewDivergence::unreadable(
439 marker.view_id(),
440 live,
441 refusal.diagnostics().to_vec(),
442 ),
443 },
444 }
445 }
446
447 // -- mutations --------------------------------------------------------
448
449 /// Create a definition under a freshly allocated stable id (T013).
450 ///
451 /// The complete definition is validated — name, scope, and every plan field —
452 /// **before** the candidate registry is committed, so a rejected create
453 /// writes nothing.
454 pub fn create(&mut self, definition: ViewDefinition) -> ViewMutation<ViewId> {
455 self.validate_definition(&definition)?;
456
457 let mut candidate = self.config.views.clone();
458 let id = candidate.allocate_id();
459 let view = SavedView::new(id.clone(), definition.name(), definition.scope.clone())?
460 .with_plan(definition.plan().to_toml());
461 candidate.insert(view)?;
462
463 candidate.commit(self.config, self.projects, self.path)?;
464 let name = definition.name().trim().to_string();
465 Ok(ViewOutcome::accepted(
466 id.clone(),
467 ViewAuditEvent::new(ViewMutationKind::Create, id, name),
468 ))
469 }
470
471 /// Replace an existing definition's name, scope, and plan in place (T013).
472 ///
473 /// The stable id never changes (identity survives a rename, C-002) and the
474 /// record's preserved extension fields are left untouched (NFR-002). A
475 /// same-content update is a no-op: no commit, no audit event.
476 pub fn update(&mut self, reference: &str, definition: ViewDefinition) -> ViewMutation<ViewId> {
477 let id = self
478 .config
479 .views
480 .select(reference, self.projects)?
481 .id
482 .clone();
483 self.validate_definition(&definition)?;
484
485 let mut candidate = self.config.views.clone();
486 candidate.rename(&id, definition.name())?;
487 candidate.set_scope(&id, definition.scope.clone())?;
488 candidate.set_plan(&id, definition.plan().to_toml())?;
489
490 let name = definition.name().trim().to_string();
491 if candidate == self.config.views {
492 return Ok(ViewOutcome::unchanged(id));
493 }
494 candidate.commit(self.config, self.projects, self.path)?;
495 Ok(ViewOutcome::accepted(
496 id.clone(),
497 ViewAuditEvent::new(ViewMutationKind::Update, id, name),
498 ))
499 }
500
501 /// Duplicate a definition under a new stable id (T013).
502 ///
503 /// The copy carries the source's scope, plan, and preserved extensions
504 /// verbatim; only the id is new. `name` overrides the label when given.
505 pub fn duplicate(&mut self, reference: &str, name: Option<&str>) -> ViewMutation<ViewId> {
506 let source = self.config.views.select(reference, self.projects)?.clone();
507 // A duplicate is a create under a new id, so it runs the same plan
508 // validation as create/update (C-008): a loadable-but-invalid plan must
509 // not be duplicated into the registry, where `show`/`apply` would refuse
510 // it. Without this, `duplicate` was the one path that could persist a
511 // definition the other two refuse.
512 self.validate_plan(&ViewPlan::from_toml(&source.plan)?)?;
513
514 let mut candidate = self.config.views.clone();
515 let id = candidate.allocate_id();
516 let label = name.map_or_else(|| source.name.clone(), str::to_string);
517 let copy = SavedView {
518 id: id.clone(),
519 name: label.trim().to_string(),
520 scope: source.scope.clone(),
521 plan: source.plan.clone(),
522 extensions: source.extensions.clone(),
523 };
524 candidate.insert(copy)?;
525 candidate.commit(self.config, self.projects, self.path)?;
526
527 let stored = candidate
528 .get(&id)
529 .map_or_else(|| label.trim().to_string(), |view| view.name.clone());
530 Ok(ViewOutcome::accepted(
531 id.clone(),
532 ViewAuditEvent::new(ViewMutationKind::Duplicate, id, stored),
533 ))
534 }
535
536 /// Delete a view **definition** after explicit confirmation (T014/C-005).
537 ///
538 /// Only the definition is removed; issues, comments, relationships, and board
539 /// data are never touched. If the active marker named this view it is cleared,
540 /// and the outcome reports that so the caller can surface it. Deleting an
541 /// unknown reference is refused (nothing was stored to remove).
542 pub fn delete(
543 &mut self,
544 reference: &str,
545 _confirmation: DeleteConfirmed,
546 ) -> ViewMutation<DeletedView> {
547 let view = self.config.views.select(reference, self.projects)?.clone();
548
549 let mut candidate = self.config.views.clone();
550 let removed = candidate
551 .remove(&view.id)
552 .expect("the selected view is in the registry");
553 let marker_cleared = self
554 .active
555 .as_ref()
556 .is_some_and(|marker| marker.view_id() == &removed.id);
557
558 candidate.commit(self.config, self.projects, self.path)?;
559 if marker_cleared {
560 self.active = None;
561 }
562
563 let value = DeletedView {
564 view: removed.clone(),
565 marker_cleared,
566 };
567 Ok(ViewOutcome::accepted(
568 value,
569 ViewAuditEvent::new(
570 ViewMutationKind::Delete,
571 removed.id.clone(),
572 removed.name.clone(),
573 ),
574 ))
575 }
576
577 // -- internals --------------------------------------------------------
578
579 /// Validate a caller-supplied definition: a non-blank name, a
580 /// self-consistent scope, and a plan every field of which this board and
581 /// config accept.
582 fn validate_definition(&self, definition: &ViewDefinition) -> ViewResult<()> {
583 if definition.name.trim().is_empty() {
584 return Err(ViewDiagnostic::new(
585 ViewDiagnosticKind::InvalidView,
586 "a saved view needs a name; the id is not a substitute for a label",
587 )
588 .with_field("name")
589 .with_remedy("give the view a display name")
590 .into());
591 }
592 let scope_conflicts = definition.scope.conflicts();
593 if !scope_conflicts.is_empty() {
594 return Err(ViewRefusal::from_diagnostics(scope_conflicts));
595 }
596 self.validate_plan(definition.plan())
597 }
598
599 /// Validate a plan against the active board and category vocabulary.
600 fn validate_plan(&self, plan: &ViewPlan) -> ViewResult<()> {
601 let board = self.config.default_config().clone();
602 plan.validate(&board, self.config)
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 #[allow(clippy::wildcard_imports)]
609 use super::*;
610 use crate::search::StructuredFilter;
611 use std::collections::BTreeSet;
612 use std::fs;
613
614 fn projects(ids: &[&str]) -> BTreeSet<String> {
615 ids.iter().map(|id| (*id).to_string()).collect()
616 }
617
618 fn service_fixture(raw: &str) -> (tempfile::TempDir, Config, BTreeSet<String>) {
619 let dir = tempfile::TempDir::new().unwrap();
620 let path = dir.path().join("tix.toml");
621 fs::write(&path, raw).unwrap();
622 let config = Config::load(dir.path()).unwrap();
623 (dir, config, projects(&[]))
624 }
625
626 #[test]
627 fn create_then_show_round_trips_the_normalized_plan() {
628 let (dir, mut config, known) = service_fixture("issues_dir = \"docs/issues\"\n");
629 let path = dir.path().join("tix.toml");
630 let definition = ViewDefinition::new(
631 "Active",
632 ViewScope::repository(),
633 ViewPlan::from_parts(
634 StructuredFilter {
635 label: Some("urgent".into()),
636 ..StructuredFilter::default()
637 },
638 "sort:due",
639 ),
640 );
641
642 let mut service = ViewService::new(&mut config, &known, &path);
643 let outcome = service.create(definition.clone()).unwrap();
644 assert_eq!(outcome.audit().unwrap().kind(), ViewMutationKind::Create);
645 let id = outcome.into_value();
646
647 let detail = service.show(id.as_str()).unwrap();
648 assert_eq!(detail.plan().sort(), Some("due"));
649 assert_eq!(detail.plan().filter().label.as_deref(), Some("urgent"));
650 assert_eq!(detail.view().id, id);
651 }
652}