Skip to main content

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}