Skip to main content

tix/views/
registry.rs

1//! The stable view registry and its scope metadata (WP01).
2//!
3//! # Storage
4//!
5//! View definitions live in `tix.toml` under `[views.<view-id>]`, beside
6//! `[teams.<label>]`, `[cycles.<name>]` and `[kanban.<name>]` — the existing
7//! repository-owned, ordered registries (C-001, research R5). The section is
8//! **omitted when empty**, so a repository that has never saved a view keeps
9//! round-tripping its legacy `tix.toml` unchanged. Record *order* is restored
10//! from the document's header order, exactly as the other registries do it
11//! (D-02: declaration order is the order a surface lists them in).
12//!
13//! ```toml
14//! [views.v-1f0c9a3d5b7e2411]
15//! id = "v-1f0c9a3d5b7e2411"
16//! name = "Platform active"
17//!
18//! [views.v-1f0c9a3d5b7e2411.scope]
19//! kind = "project"
20//! project_id = "p-platform"
21//!
22//! [views.v-1f0c9a3d5b7e2411.plan]
23//! sort = "due"
24//! ```
25//!
26//! # Identity
27//!
28//! The **id is the identity**; the name is a display label (C-002). An id is
29//! allocated once — [`ViewId::generate`] for a new definition, or an explicit id
30//! a caller chooses — and never changes afterwards. Renaming a view is a label
31//! change: [`ViewRegistry::rename`] cannot touch the id, the scope, or the plan,
32//! and a test pins that (T001/T006). Generated ids are 64 random bits drawn from
33//! the process hasher (`std::collections::hash_map::RandomState`), so no new
34//! dependency and no counter to lose: an id cannot be handed to a second view,
35//! because there is no allocator that could hand it out twice.
36//!
37//! A record's `id` and its `[views.<id>]` key must agree. The key *is* the table
38//! address, so a disagreement is an identity that points two ways; the registry
39//! refuses to deserialize it rather than pick a winner.
40//!
41//! # Scope
42//!
43//! `scope.kind` is a closed family (data-model §2):
44//!
45//! | kind | required metadata | precedence |
46//! |---|---|---|
47//! | `repository` | none | 1 |
48//! | `project` | `project_id`, resolved through [`ProjectDirectory`] | 2 |
49//! | `user_share` | `owner` and/or `shared_with` | 3 |
50//!
51//! Selection by name uses "most specific explicit scope wins": user/share over
52//! project over repository. Two candidates tied at the winning rank are a
53//! **conflict**, reported with both ids in a deterministic order — the caller
54//! selects by stable id instead (decision `01M3C93KZVGS0RCSY93E1B3NXY`). A scope
55//! whose own metadata contradicts itself (`kind = "repository"` carrying a
56//! `project_id`) is refused on the write path **and** at load: `tix.toml` never
57//! becomes a registry holding a view this build cannot describe, and the read-only
58//! [`ViewRegistry::diagnostics`] pass names the same conflicts for a registry a
59//! caller built in memory.
60//!
61//! A project id that the project registry does not know is **missing**, not
62//! corrupt: it loads (a deleted project leaves dangling references by design, so a
63//! view must survive it) and it is refused when a caller tries to *write* the
64//! registry, because writing a view that cannot resolve is what the atomicity
65//! rule in C-008 is about.
66//!
67//! # Unknown metadata
68//!
69//! A record carries [`SavedView::extensions`], and a scope carries
70//! [`ViewScope::extensions`]: every key the typed fields do not name. They are
71//! preserved verbatim through load/save (NFR-002) and reported as
72//! [`ViewDiagnosticKind::UnknownMetadata`] diagnostics — preserved *and*
73//! diagnosed, never silently discarded (data-model §7). The typed `plan` table is
74//! opaque here for the same reason: WP02 interprets it, the registry only keeps
75//! it byte-for-byte.
76//!
77//! # The write path
78//!
79//! [`ViewRegistry::commit`] is the only writer: it validates the complete
80//! candidate registry (structure, then scope resolution through the caller's
81//! project directory), validates the whole config, and only then calls the
82//! existing atomic `Config::save`. A rejection ([`ViewWriteError::Refused`])
83//! writes no `tix.toml` and appends no History; the caller's `Config` is not
84//! mutated either, because the assignment happens after the file is durable. An
85//! accepted change is idempotent — committing the same registry twice produces the
86//! same bytes.
87
88use std::collections::BTreeSet;
89use std::fmt;
90use std::hash::{BuildHasher, Hash, Hasher};
91use std::path::Path;
92use std::sync::atomic::{AtomicU64, Ordering};
93use std::time::{SystemTime, UNIX_EPOCH};
94
95use indexmap::IndexMap;
96use serde::{Deserialize, Serialize};
97
98use crate::config::Config;
99use crate::error::TixError;
100
101/// The result of a view-registry operation that can be **refused**.
102///
103/// A refusal is a decision, not a failure: the input was checked and rejected,
104/// with the diagnostics that say why. Every operation that returns this type
105/// guarantees the rejection left the registry it was given unchanged, and that no
106/// file was written.
107pub type ViewResult<T> = std::result::Result<T, ViewRefusal>;
108
109/// The result of a view-registry **write**.
110///
111/// Two genuinely different outcomes, kept apart because a caller reports them
112/// differently: [`ViewWriteError::Refused`] is the registry declining the
113/// candidate (nothing was written, and the diagnostics explain it), while
114/// [`ViewWriteError::Failed`] is infrastructure — the config could not be
115/// validated or the atomic save could not complete. Both leave `tix.toml` and the
116/// caller's in-memory `Config` untouched.
117pub type ViewWriteResult<T> = std::result::Result<T, ViewWriteError>;
118
119/// A write that was refused or that failed for an infrastructure reason.
120#[derive(Debug)]
121pub enum ViewWriteError {
122    /// The candidate registry was rejected before any write.
123    Refused(ViewRefusal),
124    /// The config could not be validated or written; the previous file is intact.
125    Failed(TixError),
126}
127
128impl ViewWriteError {
129    /// The refusal, when this was a decision rather than a failure.
130    #[must_use]
131    pub fn refusal(&self) -> Option<&ViewRefusal> {
132        match self {
133            Self::Refused(refusal) => Some(refusal),
134            Self::Failed(_) => None,
135        }
136    }
137
138    /// The infrastructure error, when this was not a refusal.
139    #[must_use]
140    pub fn failure(&self) -> Option<&TixError> {
141        match self {
142            Self::Refused(_) => None,
143            Self::Failed(error) => Some(error),
144        }
145    }
146}
147
148impl From<ViewRefusal> for ViewWriteError {
149    fn from(refusal: ViewRefusal) -> Self {
150        Self::Refused(refusal)
151    }
152}
153
154impl From<TixError> for ViewWriteError {
155    fn from(error: TixError) -> Self {
156        Self::Failed(error)
157    }
158}
159
160impl fmt::Display for ViewWriteError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        match self {
163            Self::Refused(refusal) => write!(f, "{refusal}"),
164            Self::Failed(error) => write!(f, "{error}"),
165        }
166    }
167}
168
169impl std::error::Error for ViewWriteError {}
170
171/// The stable names of a view diagnostic (data-model §7).
172///
173/// The set is the vocabulary surfaces render and match on, so the names are
174/// pinned by tests. `invalid_filter`, `invalid_sort`, `invalid_group` and
175/// `invalid_visibility` are plan-field diagnostics — WP02's `views::plan`
176/// constructs them, and they live here because a surface must not have to learn a
177/// second vocabulary for the plan half of the same record.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179pub enum ViewDiagnosticKind {
180    /// A record that cannot be a view at all: a blank or ill-formed id, a blank
181    /// name.
182    ///
183    /// WP01 adds this name to data-model §7's vocabulary because that list names
184    /// *field* problems (a filter, a sort key, a scope) and has no name for the
185    /// record-level checks T001 requires. Nothing else is folded in here: an id
186    /// problem is not a scope problem, and reporting it as one would make the kind
187    /// useless for matching.
188    InvalidView,
189    /// A filter criterion the existing grammar does not accept.
190    InvalidFilter,
191    /// A sort key outside the existing vocabulary.
192    InvalidSort,
193    /// A group key outside the existing vocabulary.
194    InvalidGroup,
195    /// A board visibility setting that does not exist.
196    InvalidVisibility,
197    /// Scope metadata that contradicts itself, or a tie selection cannot resolve.
198    ScopeConflict,
199    /// No view matches the selection.
200    UnknownView,
201    /// A field the registry does not interpret, preserved for the caller.
202    UnknownMetadata,
203}
204
205impl ViewDiagnosticKind {
206    /// The contract name of this kind (data-model §7).
207    #[must_use]
208    pub const fn as_str(self) -> &'static str {
209        match self {
210            Self::InvalidView => "invalid_view",
211            Self::InvalidFilter => "invalid_filter",
212            Self::InvalidSort => "invalid_sort",
213            Self::InvalidGroup => "invalid_group",
214            Self::InvalidVisibility => "invalid_visibility",
215            Self::ScopeConflict => "scope_conflict",
216            Self::UnknownView => "unknown_view",
217            Self::UnknownMetadata => "unknown_metadata",
218        }
219    }
220}
221
222impl fmt::Display for ViewDiagnosticKind {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.write_str(self.as_str())
225    }
226}
227
228/// One problem with one view, field, or scope.
229///
230/// `field` is the **record-relative** path the problem sits at (`"name"`,
231/// `"scope.project_id"`, `"extensions.colour"`), because the record the caller
232/// asked about is already known from `view_id`; the message is self-contained so
233/// a surface can render it without rebuilding the path.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ViewDiagnostic {
236    kind: ViewDiagnosticKind,
237    view_id: Option<ViewId>,
238    field: Option<String>,
239    message: String,
240    remedy: Option<String>,
241}
242
243impl ViewDiagnostic {
244    /// A diagnostic about a view as a whole.
245    #[must_use]
246    pub fn for_view(
247        kind: ViewDiagnosticKind,
248        view_id: &ViewId,
249        message: impl Into<String>,
250    ) -> Self {
251        Self {
252            kind,
253            view_id: Some(view_id.clone()),
254            field: None,
255            message: message.into(),
256            remedy: None,
257        }
258    }
259
260    /// A diagnostic about a definition that is not in the registry (yet).
261    #[must_use]
262    pub fn new(kind: ViewDiagnosticKind, message: impl Into<String>) -> Self {
263        Self {
264            kind,
265            view_id: None,
266            field: None,
267            message: message.into(),
268            remedy: None,
269        }
270    }
271
272    /// Name the record-relative field this diagnostic is about.
273    #[must_use]
274    pub fn with_field(mut self, field: impl Into<String>) -> Self {
275        self.field = Some(field.into());
276        self
277    }
278
279    /// State what the caller can do about it.
280    #[must_use]
281    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
282        self.remedy = Some(remedy.into());
283        self
284    }
285
286    /// The contract name of this diagnostic.
287    #[must_use]
288    pub const fn kind(&self) -> ViewDiagnosticKind {
289        self.kind
290    }
291
292    /// The view this is about, when the registry knows it.
293    #[must_use]
294    pub fn view_id(&self) -> Option<&ViewId> {
295        self.view_id.as_ref()
296    }
297
298    /// The record-relative field, when the problem is field-shaped.
299    #[must_use]
300    pub fn field(&self) -> Option<&str> {
301        self.field.as_deref()
302    }
303
304    /// The human-readable part.
305    #[must_use]
306    pub fn message(&self) -> &str {
307        &self.message
308    }
309
310    /// The actionable part, when there is one.
311    #[must_use]
312    pub fn remedy(&self) -> Option<&str> {
313        self.remedy.as_deref()
314    }
315}
316
317/// One or more diagnostics, gathered as a refusal.
318///
319/// A refusal renders **every** diagnostic through
320/// [`crate::error::render_diagnostics`], the one renderer `tix link` and
321/// `tix parent set` already share, so a user who learned one refusal format does
322/// not have to learn a second.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct ViewRefusal {
325    diagnostics: Vec<ViewDiagnostic>,
326}
327
328impl ViewRefusal {
329    /// Gather a non-empty diagnostic set.
330    ///
331    /// # Panics
332    ///
333    /// Panics on an empty set: a refusal with no diagnostic cannot explain
334    /// itself, and the callers that build one always have at least one reason.
335    #[must_use]
336    pub fn from_diagnostics(diagnostics: Vec<ViewDiagnostic>) -> Self {
337        assert!(
338            !diagnostics.is_empty(),
339            "a refusal carries at least one diagnostic"
340        );
341        Self { diagnostics }
342    }
343
344    /// Refuse with one diagnostic.
345    #[must_use]
346    pub fn from_diagnostic(diagnostic: ViewDiagnostic) -> Self {
347        Self {
348            diagnostics: vec![diagnostic],
349        }
350    }
351
352    /// Every diagnostic, in the order the operation produced them.
353    #[must_use]
354    pub fn diagnostics(&self) -> &[ViewDiagnostic] {
355        &self.diagnostics
356    }
357
358    /// The first diagnostic's kind — what a surface switches on for one line.
359    #[must_use]
360    pub fn kind(&self) -> ViewDiagnosticKind {
361        self.diagnostics[0].kind()
362    }
363
364    /// The first diagnostic's message.
365    #[must_use]
366    pub fn message(&self) -> &str {
367        self.diagnostics[0].message()
368    }
369
370    /// All diagnostics as one line, through the shared renderer.
371    #[must_use]
372    pub fn render(&self) -> String {
373        crate::error::render_diagnostics(
374            "E-tix-views",
375            self.diagnostics
376                .iter()
377                .map(|diagnostic| (diagnostic.kind().as_str(), diagnostic.message())),
378        )
379    }
380}
381
382impl From<ViewDiagnostic> for ViewRefusal {
383    fn from(diagnostic: ViewDiagnostic) -> Self {
384        Self::from_diagnostic(diagnostic)
385    }
386}
387
388impl fmt::Display for ViewRefusal {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        f.write_str(&self.render())
391    }
392}
393
394impl std::error::Error for ViewRefusal {}
395
396/// A stable view identity.
397///
398/// Never blank, never containing whitespace or a TOML-structural character, and
399/// never reused. The type exists so "this string is an id" is checked once, at
400/// the boundary, instead of at every lookup (T001).
401#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
402#[serde(try_from = "String", into = "String")]
403pub struct ViewId(String);
404
405impl ViewId {
406    /// The longest id the registry accepts. Long enough for a readable name, short
407    /// enough that a typo cannot turn into a paragraph in a table header.
408    pub const MAX_LEN: usize = 128;
409
410    /// Validate an explicit id.
411    ///
412    /// Ids are chosen by a caller (a `--id` flag) or generated. Either way the
413    /// same rules apply, because the id ends up as a TOML table header:
414    /// `restore_section_order` reads `[views.<id>]`, and an id containing `.` or a
415    /// bracket would read as a nested table or malformed document.
416    pub fn new(raw: &str) -> ViewResult<Self> {
417        if raw.trim().is_empty() {
418            return Err(ViewDiagnostic::new(
419                ViewDiagnosticKind::InvalidView,
420                "a view id must not be blank",
421            )
422            .with_field("id")
423            .with_remedy(
424                "choose an id such as `platform-active`, or omit it and let tix generate one",
425            )
426            .into());
427        }
428        if raw.trim() != raw {
429            return Err(ViewDiagnostic::new(
430                ViewDiagnosticKind::InvalidView,
431                format!("view id `{raw}` has leading or trailing whitespace"),
432            )
433            .with_field("id")
434            .into());
435        }
436        if raw.chars().count() > Self::MAX_LEN {
437            return Err(ViewDiagnostic::new(
438                ViewDiagnosticKind::InvalidView,
439                format!(
440                    "view id is {} characters; the limit is {}",
441                    raw.chars().count(),
442                    Self::MAX_LEN
443                ),
444            )
445            .with_field("id")
446            .into());
447        }
448        if let Some(bad) = raw.chars().find(|c| is_structural(*c)) {
449            return Err(ViewDiagnostic::new(
450                ViewDiagnosticKind::InvalidView,
451                format!("view id `{raw}` contains `{bad}`"),
452            )
453            .with_field("id")
454            .with_remedy("use letters, digits, `-`, `_`, or `:`")
455            .into());
456        }
457        Ok(Self(raw.to_string()))
458    }
459
460    /// Allocate a new id: 64 random bits from the process hasher, `v-`-prefixed.
461    ///
462    /// No dependency and no counter in `tix.toml`, so there is no allocator whose
463    /// state a delete could reset: two calls cannot collide in practice, and
464    /// [`ViewRegistry::allocate_id`] additionally re-draws against the ids already
465    /// present. The id is stable from the moment it is returned — persistence only
466    /// stores it, and no operation rewrites it.
467    #[must_use]
468    pub fn generate() -> Self {
469        static COUNTER: AtomicU64 = AtomicU64::new(0);
470
471        let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
472        std::process::id().hash(&mut hasher);
473        COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
474        SystemTime::now()
475            .duration_since(UNIX_EPOCH)
476            .map_or(0, |since| since.as_nanos())
477            .hash(&mut hasher);
478        let bits = hasher.finish();
479        // `v-` plus 16 hex digits is a valid id by construction, so the private
480        // constructor is used directly rather than re-validating the format.
481        Self(format!("v-{bits:016x}"))
482    }
483
484    /// The id as written.
485    #[must_use]
486    pub fn as_str(&self) -> &str {
487        &self.0
488    }
489}
490
491/// Characters that cannot appear in an id: whitespace, and the TOML/table syntax
492/// characters an id would otherwise be able to break out of.
493fn is_structural(c: char) -> bool {
494    c.is_whitespace()
495        || matches!(c, '.' | '"' | '\'' | '[' | ']' | '#' | '=' | '\\')
496        || c.is_control()
497}
498
499impl TryFrom<String> for ViewId {
500    type Error = ViewRefusal;
501
502    fn try_from(raw: String) -> ViewResult<Self> {
503        Self::new(&raw)
504    }
505}
506
507impl From<ViewId> for String {
508    fn from(id: ViewId) -> Self {
509        id.0
510    }
511}
512
513impl AsRef<str> for ViewId {
514    fn as_ref(&self) -> &str {
515        self.as_str()
516    }
517}
518
519impl fmt::Display for ViewId {
520    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
521        f.write_str(&self.0)
522    }
523}
524
525/// The closed scope family (data-model §2).
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum ViewScopeKind {
529    /// Visible to the whole repository. The default, so a legacy-shaped record
530    /// without a scope table is a repository view.
531    #[default]
532    Repository,
533    /// Visible to one project, resolved through the project registry.
534    Project,
535    /// Explicit owner/shared-with metadata. Metadata only — no permission model.
536    UserShare,
537}
538
539impl ViewScopeKind {
540    /// The serialized name.
541    #[must_use]
542    pub const fn as_str(self) -> &'static str {
543        match self {
544            Self::Repository => "repository",
545            Self::Project => "project",
546            Self::UserShare => "user_share",
547        }
548    }
549
550    /// How specific this scope is when two views compete for one name. The higher
551    /// rank wins; equal ranks are a conflict (decision
552    /// `01M3C93KZVGS0RCSY93E1B3NXY`).
553    #[must_use]
554    pub const fn precedence(self) -> u8 {
555        match self {
556            Self::Repository => 1,
557            Self::Project => 2,
558            Self::UserShare => 3,
559        }
560    }
561}
562
563impl fmt::Display for ViewScopeKind {
564    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565        f.write_str(self.as_str())
566    }
567}
568
569/// The derived state of a scope: whether it resolves, is missing a project, or
570/// contradicts itself (data-model §2). Never persisted — a stored resolution would
571/// be a second source of truth for a value that depends on the project registry.
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573pub enum ScopeResolution {
574    /// The scope's metadata is consistent and every reference exists.
575    Resolved,
576    /// The scope's project id is not in the project registry.
577    Missing,
578    /// The scope's own metadata contradicts itself.
579    Conflict,
580}
581
582impl ScopeResolution {
583    /// The contract name.
584    #[must_use]
585    pub const fn as_str(self) -> &'static str {
586        match self {
587            Self::Resolved => "resolved",
588            Self::Missing => "missing",
589            Self::Conflict => "conflict",
590        }
591    }
592}
593
594impl fmt::Display for ScopeResolution {
595    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596        f.write_str(self.as_str())
597    }
598}
599
600/// The project ids a view's project scope can be resolved against.
601///
602/// The seam exists because the project registry is **not this mission's to
603/// own**: `first-class-projects` stores `[projects.<project-id>]` in the same
604/// `tix.toml`, and when that lands its registry implements this trait. WP01 needs
605/// the question ("is `p-platform` a project here?"), not the answer's storage.
606/// Two implementations ship: the `[projects.<id>]` map shape, and a plain set for
607/// callers and tests that have only ids.
608pub trait ProjectDirectory {
609    /// Whether `project_id` names a project in the active repository.
610    fn contains(&self, project_id: &str) -> bool;
611}
612
613impl<T> ProjectDirectory for IndexMap<String, T> {
614    fn contains(&self, project_id: &str) -> bool {
615        self.contains_key(project_id)
616    }
617}
618
619impl ProjectDirectory for BTreeSet<String> {
620    fn contains(&self, project_id: &str) -> bool {
621        self.contains(project_id)
622    }
623}
624
625/// Explicit sharing/visibility metadata (data-model §2).
626///
627/// Every optional field is omitted from `tix.toml` when it is empty, so a
628/// repository view is two lines and a project view names exactly one project.
629/// Unknown keys are preserved in [`ViewScope::extensions`] rather than rejected:
630/// a *newer* `tix` may have written a scope field this build does not know, and
631/// dropping it would be the metadata loss the mission forbids (NFR-002).
632#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
633#[serde(default)]
634pub struct ViewScope {
635    /// Which scope family this is.
636    pub kind: ViewScopeKind,
637    /// The project this view belongs to; required for [`ViewScopeKind::Project`].
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub project_id: Option<String>,
640    /// The identity a user/share scope belongs to. A label, not an account.
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub owner: Option<String>,
643    /// Identities a user/share scope is shared with. Labels, not grants.
644    #[serde(skip_serializing_if = "Vec::is_empty")]
645    pub shared_with: Vec<String>,
646    /// Scope keys this build does not interpret, preserved verbatim.
647    #[serde(flatten)]
648    pub extensions: toml::Table,
649}
650
651impl ViewScope {
652    /// A repository-wide scope: no sharing metadata at all.
653    #[must_use]
654    pub fn repository() -> Self {
655        Self::default()
656    }
657
658    /// A project scope. The id is *not* resolved here — that needs the project
659    /// registry, and [`ViewScope::conflicts`] is the structural check.
660    #[must_use]
661    pub fn project(project_id: impl Into<String>) -> Self {
662        Self {
663            kind: ViewScopeKind::Project,
664            project_id: Some(project_id.into()),
665            ..Self::default()
666        }
667    }
668
669    /// A user/share scope. `owner` and `shared_with` are metadata only.
670    #[must_use]
671    pub fn user_share(owner: Option<String>, shared_with: Vec<String>) -> Self {
672        Self {
673            kind: ViewScopeKind::UserShare,
674            project_id: None,
675            owner,
676            shared_with,
677            extensions: toml::Table::new(),
678        }
679    }
680
681    /// The structural problems with this scope, independent of any project
682    /// registry. Empty when the scope is internally consistent.
683    ///
684    /// These are the rules a record cannot break *and* be meaningful under:
685    /// a project scope with no project, a repository scope that names one, share
686    /// metadata on a scope that is not a share, and blank or repeated identities.
687    #[must_use]
688    pub fn conflicts(&self) -> Vec<ViewDiagnostic> {
689        let mut diagnostics = Vec::new();
690        let mut conflict = |field: &str, message: String, remedy: &str| {
691            diagnostics.push(
692                ViewDiagnostic::new(ViewDiagnosticKind::ScopeConflict, message)
693                    .with_field(format!("scope.{field}"))
694                    .with_remedy(remedy),
695            );
696        };
697
698        match self.kind {
699            ViewScopeKind::Repository => {
700                if self.project_id.is_some() {
701                    conflict(
702                        "project_id",
703                        "a `repository` scope must not name a project".to_string(),
704                        "use `kind = \"project\"` for a project scope, or drop `project_id`",
705                    );
706                }
707            }
708            ViewScopeKind::Project => match self.project_id.as_deref() {
709                None => conflict(
710                    "project_id",
711                    "a `project` scope requires a project id".to_string(),
712                    "set `project_id`, or use `kind = \"repository\"`",
713                ),
714                Some(project_id) if project_id.trim().is_empty() => conflict(
715                    "project_id",
716                    "a `project` scope has a blank project id".to_string(),
717                    "set `project_id`, or use `kind = \"repository\"`",
718                ),
719                Some(project_id) if project_id.trim() != project_id => conflict(
720                    "project_id",
721                    format!("project id `{project_id}` has surrounding whitespace"),
722                    "remove the whitespace",
723                ),
724                Some(_) => {}
725            },
726            ViewScopeKind::UserShare => {
727                if self.project_id.is_some() {
728                    conflict(
729                        "project_id",
730                        "a `user_share` scope must not name a project".to_string(),
731                        "drop `project_id`, or use `kind = \"project\"`",
732                    );
733                }
734                if self.owner.is_none() && self.shared_with.is_empty() {
735                    conflict(
736                        "owner",
737                        "a `user_share` scope must name an owner or at least one shared-with identity"
738                            .to_string(),
739                        "set `owner` or `shared_with`, or use `kind = \"repository\"`",
740                    );
741                }
742            }
743        }
744
745        if self.kind != ViewScopeKind::UserShare {
746            if self.owner.is_some() {
747                conflict(
748                    "owner",
749                    format!("a `{}` scope must not carry an owner", self.kind.as_str()),
750                    "owner/shared-with metadata belongs to `kind = \"user_share\"`",
751                );
752            }
753            if !self.shared_with.is_empty() {
754                conflict(
755                    "shared_with",
756                    format!(
757                        "a `{}` scope must not carry shared-with identities",
758                        self.kind.as_str()
759                    ),
760                    "owner/shared-with metadata belongs to `kind = \"user_share\"`",
761                );
762            }
763        }
764
765        if let Some(owner) = self.owner.as_deref()
766            && owner.trim().is_empty()
767        {
768            conflict(
769                "owner",
770                "`owner` is present but blank".to_string(),
771                "remove `owner`, or name the identity it belongs to",
772            );
773        }
774
775        let mut seen: Vec<&str> = Vec::new();
776        for identity in &self.shared_with {
777            if identity.trim().is_empty() {
778                conflict(
779                    "shared_with",
780                    "`shared_with` contains a blank identity".to_string(),
781                    "remove the blank entry",
782                );
783            } else if seen.contains(&identity.as_str()) {
784                conflict(
785                    "shared_with",
786                    format!("`shared_with` names `{identity}` twice"),
787                    "keep one entry per identity",
788                );
789            } else {
790                seen.push(identity.as_str());
791            }
792        }
793
794        diagnostics
795    }
796
797    /// Resolve this scope against the project registry (data-model §2).
798    #[must_use]
799    pub fn resolution<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> ScopeResolution {
800        if !self.conflicts().is_empty() {
801            return ScopeResolution::Conflict;
802        }
803        match self.kind {
804            ViewScopeKind::Project => {
805                let known = self
806                    .project_id
807                    .as_deref()
808                    .is_some_and(|project_id| projects.contains(project_id.trim()));
809                if known {
810                    ScopeResolution::Resolved
811                } else {
812                    ScopeResolution::Missing
813                }
814            }
815            ViewScopeKind::Repository | ViewScopeKind::UserShare => ScopeResolution::Resolved,
816        }
817    }
818}
819
820/// One saved view definition (data-model §1).
821///
822/// `plan` is the **opaque** query plan table: WP02 owns its typed shape and
823/// validation, and persists it here without this module having to know a single
824/// field name. `extensions` holds record-level keys this build does not interpret.
825#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
826pub struct SavedView {
827    /// The stable identity. Set once; no operation in this module changes it.
828    pub id: ViewId,
829    /// The display label. Mutable — it is not the identity.
830    pub name: String,
831    /// The explicit scope metadata. Absent means a repository scope: a record
832    /// without a `[views.<id>.scope]` table is the default, not an unknown.
833    #[serde(default)]
834    pub scope: ViewScope,
835    /// The query/board plan, preserved verbatim until WP02 interprets it.
836    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
837    pub plan: toml::Table,
838    /// Record-level fields this build does not interpret, preserved verbatim.
839    #[serde(flatten)]
840    pub extensions: toml::Table,
841}
842
843impl SavedView {
844    /// A new definition with no plan. The name is trimmed, so "blank" means
845    /// blank rather than "whitespace".
846    pub fn new(id: ViewId, name: &str, scope: ViewScope) -> ViewResult<Self> {
847        let view = Self {
848            id,
849            name: name.trim().to_string(),
850            scope,
851            plan: toml::Table::new(),
852            extensions: toml::Table::new(),
853        };
854        view.check_structure()?;
855        Ok(view)
856    }
857
858    /// Attach the plan table (WP02).
859    #[must_use]
860    pub fn with_plan(mut self, plan: toml::Table) -> Self {
861        self.plan = plan;
862        self
863    }
864
865    /// Whether this record is structurally sound on its own — the checks that need
866    /// no registry and no project directory.
867    pub fn check_structure(&self) -> ViewResult<()> {
868        let mut diagnostics = Vec::new();
869        if self.name.trim().is_empty() {
870            diagnostics.push(
871                ViewDiagnostic::for_view(
872                    ViewDiagnosticKind::InvalidView,
873                    &self.id,
874                    format!("view `{}` has no name", self.id),
875                )
876                .with_field("name")
877                .with_remedy("set `name`; the id is not a substitute for a label"),
878            );
879        }
880        diagnostics.extend(
881            self.scope
882                .conflicts()
883                .into_iter()
884                .map(|diagnostic| attach_view(diagnostic, &self.id)),
885        );
886        if diagnostics.is_empty() {
887            Ok(())
888        } else {
889            Err(ViewRefusal::from_diagnostics(diagnostics))
890        }
891    }
892
893    /// The registry keys this build does not interpret, recorded as diagnostics
894    /// (data-model §7: unknown metadata is preserved *and* diagnosed).
895    fn unknown_metadata(&self) -> Vec<ViewDiagnostic> {
896        let mut diagnostics = Vec::new();
897        for key in self.extensions.keys() {
898            diagnostics.push(
899                ViewDiagnostic::for_view(
900                    ViewDiagnosticKind::UnknownMetadata,
901                    &self.id,
902                    format!(
903                        "view `{}` carries an unknown field `{key}`, preserved as written",
904                        self.id
905                    ),
906                )
907                .with_field(format!("extensions.{key}")),
908            );
909        }
910        for key in self.scope.extensions.keys() {
911            diagnostics.push(
912                ViewDiagnostic::for_view(
913                    ViewDiagnosticKind::UnknownMetadata,
914                    &self.id,
915                    format!(
916                        "view `{}` scope carries an unknown field `{key}`, preserved as written",
917                        self.id
918                    ),
919                )
920                .with_field(format!("scope.extensions.{key}")),
921            );
922        }
923        diagnostics
924    }
925}
926
927/// Re-point a diagnostic at the view it belongs to, keeping its field.
928fn attach_view(diagnostic: ViewDiagnostic, view_id: &ViewId) -> ViewDiagnostic {
929    let mut out = ViewDiagnostic::for_view(diagnostic.kind(), view_id, diagnostic.message());
930    if let Some(field) = diagnostic.field() {
931        out = out.with_field(field.to_string());
932    }
933    if let Some(remedy) = diagnostic.remedy() {
934        out = out.with_remedy(remedy.to_string());
935    }
936    out
937}
938
939/// The selected view, refused if its scope does not resolve.
940///
941/// Free rather than a method because it needs nothing from the registry — only
942/// the view it was handed and the project directory — and a method that ignores
943/// its receiver invites the reader to look for hidden state.
944fn selected<'a, P: ProjectDirectory + ?Sized>(
945    view: &'a SavedView,
946    projects: &P,
947) -> ViewResult<&'a SavedView> {
948    match view.scope.resolution(projects) {
949        ScopeResolution::Resolved => Ok(view),
950        ScopeResolution::Missing => Err(missing_project(view).into()),
951        ScopeResolution::Conflict => {
952            let diagnostics: Vec<ViewDiagnostic> = view
953                .scope
954                .conflicts()
955                .into_iter()
956                .map(|diagnostic| attach_view(diagnostic, &view.id))
957                .collect();
958            Err(ViewRefusal::from_diagnostics(diagnostics))
959        }
960    }
961}
962
963/// The one missing-project diagnostic: selection, the write check, and the
964/// read-only pass all report this condition through here, so a user who saw it
965/// once recognises it wherever it appears.
966fn missing_project(view: &SavedView) -> ViewDiagnostic {
967    ViewDiagnostic::for_view(
968        ViewDiagnosticKind::ScopeConflict,
969        &view.id,
970        format!(
971            "view `{}` is scoped to project `{}`, which is not in the project registry",
972            view.id,
973            view.scope.project_id.as_deref().unwrap_or("")
974        ),
975    )
976    .with_field("scope.project_id")
977    .with_remedy("create the project, or re-scope the view to the repository")
978}
979
980/// The repository-owned registry of saved views, keyed by stable id and stored in
981/// declaration order.
982///
983/// Serialized transparently as the TOML table `[views.<id>]`, omitted when empty.
984/// The key and [`SavedView::id`] must agree; a document where they disagree is
985/// refused at parse time rather than resolved by preference.
986#[derive(Debug, Clone, PartialEq)]
987pub struct ViewRegistry {
988    views: IndexMap<String, SavedView>,
989}
990
991impl Default for ViewRegistry {
992    fn default() -> Self {
993        Self::new()
994    }
995}
996
997impl ViewRegistry {
998    /// An empty registry.
999    #[must_use]
1000    pub fn new() -> Self {
1001        Self {
1002            views: IndexMap::new(),
1003        }
1004    }
1005
1006    /// How many views are stored.
1007    #[must_use]
1008    pub fn len(&self) -> usize {
1009        self.views.len()
1010    }
1011
1012    /// Whether the registry is empty — the condition that omits the section.
1013    #[must_use]
1014    pub fn is_empty(&self) -> bool {
1015        self.views.is_empty()
1016    }
1017
1018    /// The views in declaration order.
1019    pub fn iter(&self) -> impl Iterator<Item = &SavedView> {
1020        self.views.values()
1021    }
1022
1023    /// One view by stable id.
1024    #[must_use]
1025    pub fn get(&self, id: &ViewId) -> Option<&SavedView> {
1026        self.views.get(id.as_str())
1027    }
1028
1029    /// Whether the registry already holds this id.
1030    #[must_use]
1031    pub fn contains(&self, id: &ViewId) -> bool {
1032        self.views.contains_key(id.as_str())
1033    }
1034
1035    /// Allocate an id that no stored view uses.
1036    ///
1037    /// The loop is the belt to [`ViewId::generate`]'s braces: a generated id is
1038    /// 64 random bits, and this re-draws in the (astronomically unlikely) event of
1039    /// a collision with a stored id, so "never reused" holds against the ids this
1040    /// repository actually has.
1041    #[must_use]
1042    pub fn allocate_id(&self) -> ViewId {
1043        loop {
1044            let candidate = ViewId::generate();
1045            if !self.contains(&candidate) {
1046                return candidate;
1047            }
1048        }
1049    }
1050
1051    /// Add a validated definition. A duplicate id is a refusal, never an
1052    /// overwrite: identity is immutable, so "insert the same id" is a conflict
1053    /// rather than a silent replacement (T001).
1054    pub fn insert(&mut self, view: SavedView) -> ViewResult<()> {
1055        if let Some(existing) = self.views.get(view.id.as_str()) {
1056            return Err(ViewDiagnostic::for_view(
1057                ViewDiagnosticKind::InvalidView,
1058                &view.id,
1059                format!(
1060                    "view id `{}` is already taken by `{}`; ids are never reused",
1061                    view.id, existing.name
1062                ),
1063            )
1064            .with_field("id")
1065            .with_remedy("choose a different id, or edit the view that owns it")
1066            .into());
1067        }
1068        view.check_structure()?;
1069        self.views.insert(view.id.as_str().to_string(), view);
1070        Ok(())
1071    }
1072
1073    /// Change the display label. The id, the scope, and the plan are untouched —
1074    /// the rename contract is that identity survives (C-002, T001).
1075    pub fn rename(&mut self, id: &ViewId, name: &str) -> ViewResult<()> {
1076        let trimmed = name.trim();
1077        if trimmed.is_empty() {
1078            return Err(ViewDiagnostic::for_view(
1079                ViewDiagnosticKind::InvalidView,
1080                id,
1081                format!("view `{id}` cannot be renamed to a blank name"),
1082            )
1083            .with_field("name")
1084            .into());
1085        }
1086        let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
1087            ViewDiagnostic::new(
1088                ViewDiagnosticKind::UnknownView,
1089                format!("no saved view with id `{id}`"),
1090            )
1091            .with_field("id")
1092        })?;
1093        view.name = trimmed.to_string();
1094        Ok(())
1095    }
1096
1097    /// Replace the scope metadata, refusing a scope that contradicts itself.
1098    pub fn set_scope(&mut self, id: &ViewId, scope: ViewScope) -> ViewResult<()> {
1099        let diagnostics: Vec<ViewDiagnostic> = scope
1100            .conflicts()
1101            .into_iter()
1102            .map(|diagnostic| attach_view(diagnostic, id))
1103            .collect();
1104        if !diagnostics.is_empty() {
1105            return Err(ViewRefusal::from_diagnostics(diagnostics));
1106        }
1107        let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
1108            ViewDiagnostic::new(
1109                ViewDiagnosticKind::UnknownView,
1110                format!("no saved view with id `{id}`"),
1111            )
1112            .with_field("id")
1113        })?;
1114        view.scope = scope;
1115        Ok(())
1116    }
1117
1118    /// Replace the opaque plan table (WP02 hands one in after validating it).
1119    pub fn set_plan(&mut self, id: &ViewId, plan: toml::Table) -> ViewResult<()> {
1120        let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
1121            ViewDiagnostic::new(
1122                ViewDiagnosticKind::UnknownView,
1123                format!("no saved view with id `{id}`"),
1124            )
1125            .with_field("id")
1126        })?;
1127        view.plan = plan;
1128        Ok(())
1129    }
1130
1131    /// Drop a definition. Removing an unknown id is `None`, not an error: the
1132    /// caller decides whether that is a no-op or a refusal, which is how WP03's
1133    /// idempotent delete is expressible without a second lookup.
1134    ///
1135    /// The id is not remembered afterwards. There is no tombstone because
1136    /// data-model §1 stores none, so a caller that re-adds a *deleted explicit*
1137    /// id is declaring that identity again — a decision for the create surface
1138    /// (WP04), which can refuse it. A [`ViewId::generate`]d id cannot recur at
1139    /// all, which is what "never reused" rests on.
1140    pub fn remove(&mut self, id: &ViewId) -> Option<SavedView> {
1141        self.views.shift_remove(id.as_str())
1142    }
1143
1144    /// Select one view by **reference** — its stable id, or its display name.
1145    ///
1146    /// An id wins over a name, because ids are unique and unambiguous by
1147    /// construction. A name may be shared, and that is where scope precedence
1148    /// applies: the most specific scope wins, and a tie at the winning rank is a
1149    /// conflict naming every candidate in a deterministic (id-sorted) order. The
1150    /// caller then selects by stable id.
1151    ///
1152    /// **Sharing is never inferred.** There is no "current user" argument: the
1153    /// registry cannot prefer a `user_share` view because its `owner` happens to
1154    /// match whoever is running the command (C-003). `owner` and `shared_with` are
1155    /// stored data; they take no part in matching, ranking, or filtering.
1156    pub fn select<P: ProjectDirectory + ?Sized>(
1157        &self,
1158        reference: &str,
1159        projects: &P,
1160    ) -> ViewResult<&SavedView> {
1161        let reference = reference.trim();
1162        if let Some(view) = self.views.get(reference) {
1163            return selected(view, projects);
1164        }
1165
1166        let mut candidates: Vec<&SavedView> = self
1167            .views
1168            .values()
1169            .filter(|view| view.name == reference)
1170            .collect();
1171        if candidates.is_empty() {
1172            return Err(ViewDiagnostic::new(
1173                ViewDiagnosticKind::UnknownView,
1174                format!("no saved view matches `{reference}`"),
1175            )
1176            .with_field("id")
1177            .with_remedy("list the saved views and select one by id")
1178            .into());
1179        }
1180
1181        candidates.sort_by(|left, right| left.id.cmp(&right.id));
1182        let best = candidates
1183            .iter()
1184            .map(|view| view.scope.kind.precedence())
1185            .max()
1186            .unwrap_or(ViewScopeKind::Repository.precedence());
1187        let winners: Vec<&SavedView> = candidates
1188            .into_iter()
1189            .filter(|view| view.scope.kind.precedence() == best)
1190            .collect();
1191
1192        if winners.len() > 1 {
1193            let ids = winners
1194                .iter()
1195                .map(|view| format!("`{}`", view.id))
1196                .collect::<Vec<_>>()
1197                .join(", ");
1198            let kinds = winners
1199                .iter()
1200                .map(|view| view.scope.kind.as_str())
1201                .collect::<Vec<_>>()
1202                .join(", ");
1203            return Err(ViewDiagnostic::new(
1204                ViewDiagnosticKind::ScopeConflict,
1205                format!(
1206                    "view name `{reference}` is ambiguous: {ids} are all {} scope",
1207                    kinds
1208                ),
1209            )
1210            .with_field("name")
1211            .with_remedy("select the view by its stable id")
1212            .into());
1213        }
1214
1215        // `winners` is non-empty: `candidates` was, and `best` came from it.
1216        selected(winners[0], projects)
1217    }
1218
1219    /// Everything structurally wrong with this registry, before any project
1220    /// lookup — what `Config` validation can check without a project registry.
1221    pub fn check_structure(&self) -> ViewResult<()> {
1222        let mut diagnostics = Vec::new();
1223        for view in self.views.values() {
1224            if let Err(refusal) = view.check_structure() {
1225                diagnostics.extend(refusal.diagnostics().iter().cloned());
1226            }
1227        }
1228        if diagnostics.is_empty() {
1229            Ok(())
1230        } else {
1231            Err(ViewRefusal::from_diagnostics(diagnostics))
1232        }
1233    }
1234
1235    /// The full write-path check: structure plus project resolution.
1236    ///
1237    /// A view whose project does not exist cannot be written (C-008) but can be
1238    /// *loaded*: a deleted project leaves references that must stay diagnosable
1239    /// rather than unloadable, so resolution is deliberately not part of
1240    /// `Config` validation.
1241    pub fn check<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> ViewResult<()> {
1242        let mut diagnostics = Vec::new();
1243        if let Err(refusal) = self.check_structure() {
1244            diagnostics.extend(refusal.diagnostics().iter().cloned());
1245        }
1246        for view in self.views.values() {
1247            if let ScopeResolution::Missing = view.scope.resolution(projects) {
1248                diagnostics.push(missing_project(view));
1249            }
1250        }
1251        if diagnostics.is_empty() {
1252            Ok(())
1253        } else {
1254            Err(ViewRefusal::from_diagnostics(diagnostics))
1255        }
1256    }
1257
1258    /// Read-only diagnostics for every stored view: scope conflicts, missing
1259    /// projects, and preserved-but-uninterpreted fields.
1260    ///
1261    /// Ordered by declaration order, then by the order a view's own problems were
1262    /// found, so two runs over the same file report the same list. This is what a
1263    /// surface shows *without* refusing anything — unknown metadata is reported
1264    /// here precisely so it is never silent, while writing it back stays allowed.
1265    #[must_use]
1266    pub fn diagnostics<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> Vec<ViewDiagnostic> {
1267        let mut diagnostics = Vec::new();
1268        for view in self.views.values() {
1269            let conflicts = view.scope.conflicts();
1270            if conflicts.is_empty() {
1271                if let ScopeResolution::Missing = view.scope.resolution(projects) {
1272                    diagnostics.push(missing_project(view));
1273                }
1274            } else {
1275                // A conflicting scope also cannot be judged against the project
1276                // registry, so resolution is not attempted for it.
1277                diagnostics.extend(
1278                    conflicts
1279                        .into_iter()
1280                        .map(|diagnostic| attach_view(diagnostic, &view.id)),
1281                );
1282            }
1283            diagnostics.extend(view.unknown_metadata());
1284        }
1285        diagnostics
1286    }
1287
1288    /// The one write path for view definitions (C-008, T005).
1289    ///
1290    /// The order is the guarantee:
1291    ///
1292    /// 1. the **complete candidate registry** is checked — structure, then scope
1293    ///    resolution through `projects`. A refusal returns before any write;
1294    /// 2. the whole candidate `Config` is validated, so the existing config
1295    ///    invariants hold for what is about to be written;
1296    /// 3. the existing atomic `Config::save` writes it (temp file, fsync, rename);
1297    /// 4. only then is the caller's `Config` updated.
1298    ///
1299    /// So a rejected operation writes no `tix.toml` and appends no History, and a
1300    /// failed save leaves both the file and the caller's in-memory config as they
1301    /// were. Committing the same registry twice writes the same bytes, which is
1302    /// what makes an accepted change idempotent; the audit event for an accepted
1303    /// change is the caller's to append (WP03), because this path touches no issue
1304    /// file.
1305    pub fn commit<P: ProjectDirectory + ?Sized>(
1306        &self,
1307        config: &mut Config,
1308        projects: &P,
1309        path: &Path,
1310    ) -> ViewWriteResult<()> {
1311        self.check(projects).map_err(ViewWriteError::Refused)?;
1312
1313        let mut candidate = config.clone();
1314        candidate.views = self.clone();
1315        candidate.validate().map_err(ViewWriteError::Failed)?;
1316        candidate.save(path).map_err(ViewWriteError::Failed)?;
1317
1318        *config = candidate;
1319        Ok(())
1320    }
1321
1322    /// Restore the declaration order of `[views.<id>]` headers, which the TOML
1323    /// parser loses (it sorts table keys). Same D-02 rule as `[cycles]`/`[teams]`.
1324    pub(crate) fn restore_declaration_order(&mut self, contents: &str) {
1325        crate::config::restore_section_order(&mut self.views, contents, "views");
1326    }
1327}
1328
1329impl Serialize for ViewRegistry {
1330    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1331    where
1332        S: serde::Serializer,
1333    {
1334        self.views.serialize(serializer)
1335    }
1336}
1337
1338impl<'de> Deserialize<'de> for ViewRegistry {
1339    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1340    where
1341        D: serde::Deserializer<'de>,
1342    {
1343        let stored = IndexMap::<String, SavedView>::deserialize(deserializer)?;
1344        let mut registry = Self::new();
1345        for (key, view) in stored {
1346            if key != view.id.as_str() {
1347                return Err(serde::de::Error::custom(format!(
1348                    "the `[views.{key}]` record declares id `{}`: the table key is the stable \
1349                     identity and the two must agree",
1350                    view.id.as_str()
1351                )));
1352            }
1353            registry.views.insert(key, view);
1354        }
1355        Ok(registry)
1356    }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361    #[allow(clippy::wildcard_imports)]
1362    use super::*;
1363
1364    fn projects(ids: &[&str]) -> BTreeSet<String> {
1365        ids.iter().map(|id| (*id).to_string()).collect()
1366    }
1367
1368    fn view(id: &str, name: &str, scope: ViewScope) -> SavedView {
1369        SavedView::new(ViewId::new(id).unwrap(), name, scope).unwrap()
1370    }
1371
1372    #[test]
1373    fn blank_and_ill_formed_ids_are_refused() {
1374        assert!(ViewId::new("").is_err());
1375        assert!(ViewId::new("   ").is_err());
1376        assert!(ViewId::new(" padded").is_err());
1377        assert!(ViewId::new("a.b").is_err());
1378        assert!(ViewId::new("a[b]").is_err());
1379        assert!(ViewId::new("a b").is_err());
1380        assert!(ViewId::new(&"x".repeat(ViewId::MAX_LEN + 1)).is_err());
1381        assert_eq!(
1382            ViewId::new("platform-active").unwrap().as_str(),
1383            "platform-active"
1384        );
1385    }
1386
1387    #[test]
1388    fn generated_ids_are_unique_and_well_formed() {
1389        let first = ViewId::generate();
1390        let second = ViewId::generate();
1391        assert_ne!(first, second);
1392        assert!(first.as_str().starts_with("v-"));
1393        assert_eq!(first.as_str().len(), 18);
1394        assert!(ViewId::new(first.as_str()).is_ok());
1395    }
1396
1397    #[test]
1398    fn allocate_id_skips_stored_ids() {
1399        let mut registry = ViewRegistry::new();
1400        registry
1401            .insert(view("taken", "Taken", ViewScope::repository()))
1402            .unwrap();
1403        for _ in 0..32 {
1404            assert!(!registry.contains(&registry.allocate_id()));
1405        }
1406    }
1407
1408    #[test]
1409    fn duplicate_id_is_a_refusal_not_an_overwrite() {
1410        let mut registry = ViewRegistry::new();
1411        registry
1412            .insert(view("platform", "Platform", ViewScope::repository()))
1413            .unwrap();
1414        let refusal = registry
1415            .insert(view("platform", "Something else", ViewScope::repository()))
1416            .unwrap_err();
1417        assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
1418        assert_eq!(
1419            registry
1420                .get(&ViewId::new("platform").unwrap())
1421                .unwrap()
1422                .name,
1423            "Platform",
1424            "an existing definition is never overwritten by a duplicate id"
1425        );
1426    }
1427
1428    #[test]
1429    fn blank_names_are_refused_on_insert_and_rename() {
1430        let mut registry = ViewRegistry::new();
1431        let refusal = SavedView::new(
1432            ViewId::new("platform").unwrap(),
1433            "   ",
1434            ViewScope::repository(),
1435        )
1436        .unwrap_err();
1437        assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
1438
1439        registry
1440            .insert(view("platform", "Platform", ViewScope::repository()))
1441            .unwrap();
1442        let id = ViewId::new("platform").unwrap();
1443        assert!(registry.rename(&id, "  ").is_err());
1444        registry.rename(&id, "  Platform active  ").unwrap();
1445        assert_eq!(registry.get(&id).unwrap().name, "Platform active");
1446    }
1447
1448    #[test]
1449    fn scope_kinds_rank_by_specificity() {
1450        assert!(ViewScopeKind::UserShare.precedence() > ViewScopeKind::Project.precedence());
1451        assert!(ViewScopeKind::Project.precedence() > ViewScopeKind::Repository.precedence());
1452    }
1453
1454    #[test]
1455    fn repository_scope_must_not_name_a_project() {
1456        let scope = ViewScope {
1457            kind: ViewScopeKind::Repository,
1458            project_id: Some("p-platform".into()),
1459            ..ViewScope::default()
1460        };
1461        let conflicts = scope.conflicts();
1462        assert_eq!(conflicts.len(), 1);
1463        assert_eq!(conflicts[0].field(), Some("scope.project_id"));
1464        assert_eq!(
1465            scope.resolution(&projects(&["p-platform"])),
1466            ScopeResolution::Conflict
1467        );
1468    }
1469
1470    #[test]
1471    fn project_scope_requires_a_project_and_resolves_through_the_directory() {
1472        let empty = ViewScope::project("   ");
1473        assert_eq!(empty.conflicts().len(), 1);
1474
1475        let scope = ViewScope::project("p-platform");
1476        assert_eq!(
1477            scope.resolution(&projects(&["p-platform"])),
1478            ScopeResolution::Resolved
1479        );
1480        assert_eq!(scope.resolution(&projects(&[])), ScopeResolution::Missing);
1481    }
1482
1483    #[test]
1484    fn user_share_scope_needs_owner_or_shared_with_and_is_metadata_only() {
1485        assert_eq!(ViewScope::user_share(None, vec![]).conflicts().len(), 1);
1486        let scope = ViewScope::user_share(Some("alice".into()), vec!["bob".into()]);
1487        assert_eq!(
1488            scope.conflicts(),
1489            [] as [crate::views::registry::ViewDiagnostic; 0]
1490        );
1491        assert_eq!(scope.resolution(&projects(&[])), ScopeResolution::Resolved);
1492    }
1493
1494    #[test]
1495    fn user_share_scope_must_not_name_a_project() {
1496        // Symmetric with the `repository` arm: a `user_share` scope carrying a
1497        // `project_id` is self-contradictory and must be refused, not silently
1498        // carried with the field left inert (WP01 review finding).
1499        let scope = ViewScope {
1500            kind: ViewScopeKind::UserShare,
1501            project_id: Some("p-platform".into()),
1502            owner: Some("alice".into()),
1503            ..ViewScope::default()
1504        };
1505        let conflicts = scope.conflicts();
1506        assert_eq!(conflicts.len(), 1);
1507        assert_eq!(conflicts[0].field(), Some("scope.project_id"));
1508        assert_eq!(
1509            scope.resolution(&projects(&["p-platform"])),
1510            ScopeResolution::Conflict
1511        );
1512    }
1513
1514    #[test]
1515    fn share_metadata_on_a_non_share_scope_is_a_conflict() {
1516        let scope = ViewScope {
1517            kind: ViewScopeKind::Repository,
1518            owner: Some("alice".into()),
1519            shared_with: vec!["bob".into()],
1520            ..ViewScope::default()
1521        };
1522        let conflicts = scope.conflicts();
1523        let fields: Vec<Option<&str>> = conflicts.iter().map(|d| d.field()).collect();
1524        assert_eq!(fields, vec![Some("scope.owner"), Some("scope.shared_with")]);
1525    }
1526
1527    #[test]
1528    fn repeated_and_blank_shared_with_identities_are_refused() {
1529        let scope = ViewScope::user_share(Some("alice".into()), vec!["bob".into(), "bob".into()]);
1530        assert_eq!(scope.conflicts().len(), 1);
1531        let scope = ViewScope::user_share(Some("alice".into()), vec!["  ".into()]);
1532        assert_eq!(scope.conflicts().len(), 1);
1533    }
1534
1535    #[test]
1536    fn select_prefers_the_id_and_then_the_most_specific_scope() {
1537        let mut registry = ViewRegistry::new();
1538        registry
1539            .insert(view("repo", "Active", ViewScope::repository()))
1540            .unwrap();
1541        registry
1542            .insert(view("proj", "Active", ViewScope::project("p-platform")))
1543            .unwrap();
1544        let known = projects(&["p-platform"]);
1545        assert_eq!(registry.select("proj", &known).unwrap().id.as_str(), "proj");
1546        assert_eq!(
1547            registry.select("Active", &known).unwrap().id.as_str(),
1548            "proj"
1549        );
1550    }
1551
1552    #[test]
1553    fn user_share_tie_is_a_deterministic_conflict() {
1554        let mut registry = ViewRegistry::new();
1555        registry
1556            .insert(view(
1557                "zoe",
1558                "Mine",
1559                ViewScope::user_share(Some("zoe".into()), vec![]),
1560            ))
1561            .unwrap();
1562        registry
1563            .insert(view(
1564                "alice",
1565                "Mine",
1566                ViewScope::user_share(Some("alice".into()), vec![]),
1567            ))
1568            .unwrap();
1569        let known = projects(&[]);
1570        let first = registry.select("Mine", &known).unwrap_err();
1571        let second = registry.select("Mine", &known).unwrap_err();
1572        assert_eq!(first, second);
1573        assert_eq!(first.kind(), ViewDiagnosticKind::ScopeConflict);
1574        assert!(first.message().contains("`alice`"));
1575        assert!(first.message().contains("`zoe`"));
1576    }
1577
1578    #[test]
1579    fn owner_metadata_never_influences_selection() {
1580        let mut registry = ViewRegistry::new();
1581        registry
1582            .insert(view(
1583                "shared",
1584                "Mine",
1585                ViewScope::user_share(Some("someone-else".into()), vec!["another".into()]),
1586            ))
1587            .unwrap();
1588        let selected = registry.select("Mine", &projects(&[])).unwrap();
1589        assert_eq!(selected.id.as_str(), "shared");
1590
1591        // A second registry whose only difference is the owner value selects
1592        // exactly the same view: the metadata is inert as far as resolution goes.
1593        let mut other = ViewRegistry::new();
1594        other
1595            .insert(view(
1596                "shared",
1597                "Mine",
1598                ViewScope::user_share(Some("alice".into()), vec!["bob".into()]),
1599            ))
1600            .unwrap();
1601        assert_eq!(
1602            other.select("Mine", &projects(&[])).unwrap().id.as_str(),
1603            "shared"
1604        );
1605    }
1606
1607    #[test]
1608    fn select_reports_unknown_references_and_missing_projects() {
1609        let mut registry = ViewRegistry::new();
1610        registry
1611            .insert(view("proj", "Platform", ViewScope::project("p-gone")))
1612            .unwrap();
1613        let refusal = registry.select("nope", &projects(&[])).unwrap_err();
1614        assert_eq!(refusal.kind(), ViewDiagnosticKind::UnknownView);
1615
1616        let refusal = registry.select("proj", &projects(&[])).unwrap_err();
1617        assert_eq!(refusal.kind(), ViewDiagnosticKind::ScopeConflict);
1618        assert!(refusal.message().contains("p-gone"));
1619    }
1620
1621    #[test]
1622    fn unknown_metadata_is_reported_not_refused() {
1623        let mut registry = ViewRegistry::new();
1624        let mut record = view("platform", "Platform", ViewScope::repository());
1625        record
1626            .extensions
1627            .insert("colour".into(), toml::Value::String("red".into()));
1628        record
1629            .scope
1630            .extensions
1631            .insert("visibility".into(), toml::Value::String("team".into()));
1632        registry.insert(record).unwrap();
1633
1634        assert!(registry.check_structure().is_ok());
1635        let diagnostics = registry.diagnostics(&projects(&[]));
1636        let kinds: Vec<ViewDiagnosticKind> = diagnostics.iter().map(|d| d.kind()).collect();
1637        assert_eq!(
1638            kinds,
1639            vec![
1640                ViewDiagnosticKind::UnknownMetadata,
1641                ViewDiagnosticKind::UnknownMetadata
1642            ]
1643        );
1644        assert_eq!(registry.iter().next().unwrap().extensions.len(), 1);
1645    }
1646
1647    #[test]
1648    fn rename_keeps_identity_scope_and_plan() {
1649        let mut registry = ViewRegistry::new();
1650        let mut record = view("platform", "Platform", ViewScope::project("p-platform"));
1651        let mut plan = toml::Table::new();
1652        plan.insert("sort".into(), toml::Value::String("due".into()));
1653        record.plan = plan.clone();
1654        registry.insert(record).unwrap();
1655
1656        let id = ViewId::new("platform").unwrap();
1657        registry.rename(&id, "Platform active").unwrap();
1658        let stored = registry.get(&id).unwrap();
1659        assert_eq!(stored.id, id);
1660        assert_eq!(stored.name, "Platform active");
1661        assert_eq!(stored.scope, ViewScope::project("p-platform"));
1662        assert_eq!(stored.plan, plan);
1663    }
1664}