Skip to main content

tix/views/
plan.rs

1//! The normalized view plan: the typed, validated query/board definition a saved
2//! view applies (WP02; mission `saved-issue-views-01M3C926`).
3//!
4//! A [`ViewPlan`] is the answer to "what does this saved view ask for?" as a
5//! *typed value* rather than a query string: the existing [`StructuredFilter`],
6//! an optional text query, a supported sort key, a supported group key, and
7//! board visibility settings. It is deliberately **not** a query engine.
8//!
9//! # No second query language (C-002/C-004)
10//!
11//! The plan adds no grammar and reimplements no matching. Filtering is the
12//! existing [`StructuredFilter`] predicate; the text query is the existing
13//! substring/fuzzy/control-token grammar; sorting is the existing `sort:` control
14//! token; grouping is the existing `group:` control token; visibility is the
15//! existing board `hidden_columns`/`show_mismatch` pair. [`ViewPlan::apply`]
16//! reconstructs the *existing* query string and calls the *existing*
17//! [`BoardState::build_filtered`] once — it never walks the corpus itself and
18//! never mutates an issue (C-005/C-007).
19//!
20//! # Normalization, validation, diagnostics
21//!
22//! [`ViewPlan::normalize`] trims every textual value, drops the empty ones,
23//! folds `overdue: false` to unset, and de-duplicates column lists.
24//! [`ViewPlan::validate`] then reports *every* unsupported field as a
25//! [`ViewDiagnostic`] the registry already defines — `invalid_filter`,
26//! `invalid_sort`, `invalid_group`, `invalid_visibility` — with a
27//! record-relative path (`plan.sort`, `plan.filter.category`, …), so a surface
28//! renders the plan half of a record through the same vocabulary as its scope
29//! half (T008/T009).
30//!
31//! Validation is a pure function of the plan, the active
32//! [`BoardConfiguration`], and the project's configured category vocabulary
33//! ([`Config::categories`]); it is called by [`ViewPlan::apply`] *before* the
34//! board is built, and by the write path *before* persistence, so a rejected
35//! plan produces no board and no `tix.toml` (T009/C-008).
36//!
37//! # Unknown metadata (T010/NFR-002)
38//!
39//! A plan round-trips through TOML — the shape `SavedView::plan` persists — and
40//! every key this build does not interpret is preserved verbatim: at the plan
41//! level, inside `[plan.filter]`, and inside `[plan.visibility]`. Those keys are
42//! *never* fed to a matcher; they are reported as
43//! [`ViewDiagnosticKind::UnknownMetadata`] diagnostics by [`ViewPlan::diagnostics`]
44//! and carried back out on the next serialization. A newer `tix` that adds a
45//! filter criterion writes it into `[plan.filter]`, and this build keeps it.
46
47use crate::config::{BoardConfiguration, Config};
48use crate::search::{Corpus, StructuredFilter};
49use crate::ui::board::BoardState;
50use crate::views::registry::{ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRefusal, ViewResult};
51use crate::workflow::CategoryResolution;
52use toml::{Table, Value};
53
54/// The sort keys the existing `sort:` control token accepts
55/// ([`crate::search::query_matches`]). The list is pinned here rather than
56/// re-derived, because the token grammar is the authority and this is its
57/// published spelling.
58pub const SORT_KEYS: &[&str] = &[
59    "created",
60    "priority",
61    "status",
62    "cycle",
63    "milestone",
64    "due",
65    "start",
66    "reviewer",
67];
68
69/// The group keys the existing `group:` control token accepts.
70pub const GROUP_KEYS: &[&str] = &["cycle", "milestone"];
71
72/// Every key of the persisted `[plan.filter]` table that this build interprets.
73const FILTER_KEYS: &[&str] = &[
74    "assignee",
75    "label",
76    "status",
77    "priority",
78    "cycle",
79    "milestone",
80    "reviewer",
81    "start_date",
82    "due_date",
83    "overdue",
84    "parent",
85    "category",
86    "scope",
87];
88
89/// The board visibility part of a plan (data-model §3).
90///
91/// The two settings the board surface already has: the columns hidden from
92/// render, and whether the computed "Mismatched" pseudo-column is shown. A
93/// *present* [`ViewVisibility`] is an **absolute** snapshot — `hidden_columns`
94/// replaces the live board's hidden set on apply — while the enclosing
95/// [`ViewPlan::visibility`] being `None` means "do not constrain visibility,
96/// inherit the live board". That distinction is what lets an acceptance run
97/// restore "nothing hidden" rather than inherit someone else's hidden columns.
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct ViewVisibility {
100    hidden_columns: Vec<String>,
101    show_mismatch: Option<bool>,
102}
103
104impl ViewVisibility {
105    /// A visibility setting, normalized: column names trimmed, blanks dropped,
106    /// duplicates collapsed to their first position.
107    #[must_use]
108    pub fn new(hidden_columns: Vec<String>, show_mismatch: Option<bool>) -> Self {
109        let mut visibility = Self {
110            hidden_columns,
111            show_mismatch,
112        };
113        visibility.normalize();
114        visibility
115    }
116
117    /// The columns hidden from render, in declaration order.
118    #[must_use]
119    pub fn hidden_columns(&self) -> &[String] {
120        &self.hidden_columns
121    }
122
123    /// Whether the mismatched pseudo-column is shown, when the plan says.
124    ///
125    /// `None` means "leave the board's own setting alone"; `Some(false)` is a
126    /// real override that hides the column on a board configured to show it.
127    #[must_use]
128    pub const fn show_mismatch(&self) -> Option<bool> {
129        self.show_mismatch
130    }
131
132    /// Trim, drop blanks and collapse duplicates in the hidden-column list.
133    pub fn normalize(&mut self) {
134        self.hidden_columns = normalize_columns(std::mem::take(&mut self.hidden_columns));
135    }
136}
137
138/// The normalized query/board plan (data-model §3).
139///
140/// Construct one with [`ViewPlan::from_parts`] (the adapter from a filter modal's
141/// [`StructuredFilter`] plus a raw query), [`ViewPlan::from_query`] (the raw
142/// query alone), the `with_*` builders, or [`ViewPlan::from_toml`] (the persisted
143/// shape). Whichever route, the value is normalized before it is returned.
144#[derive(Debug, Clone, Default, PartialEq)]
145pub struct ViewPlan {
146    filter: StructuredFilter,
147    text_query: Option<String>,
148    sort: Option<String>,
149    group: Option<String>,
150    visibility: Option<ViewVisibility>,
151    /// Plan-level keys this build does not interpret, preserved verbatim.
152    extensions: Table,
153    /// Unknown keys found inside `[plan.filter]`, preserved verbatim.
154    filter_extensions: Table,
155    /// Unknown keys found inside `[plan.visibility]`, preserved verbatim.
156    visibility_extensions: Table,
157}
158
159impl ViewPlan {
160    /// Adapt the two inputs every surface already has into one normalized plan
161    /// (T007).
162    ///
163    /// `filter` is the typed modal state; `text_query` is the raw query string.
164    /// Any `sort:`/`group:` control token in the query is **lifted** into the
165    /// typed fields (first occurrence wins, matching
166    /// [`crate::search::control_token`]) and removed from the text query, so the
167    /// plan has exactly one place a sort or group lives. A typed field the caller
168    /// already set wins over the token; the token is dropped either way.
169    #[must_use]
170    pub fn from_parts(filter: StructuredFilter, text_query: &str) -> Self {
171        let mut plan = Self {
172            filter,
173            ..Self::default()
174        };
175        plan.absorb_control_tokens(text_query);
176        plan.normalize();
177        plan
178    }
179
180    /// A plan from a raw query string alone, with no structured criteria.
181    #[must_use]
182    pub fn from_query(text_query: &str) -> Self {
183        Self::from_parts(StructuredFilter::default(), text_query)
184    }
185
186    /// The structured criteria.
187    #[must_use]
188    pub fn filter(&self) -> &StructuredFilter {
189        &self.filter
190    }
191
192    /// The remaining text query, with control tokens lifted out. `None` when the
193    /// plan carries no free text.
194    #[must_use]
195    pub fn text_query(&self) -> Option<&str> {
196        self.text_query.as_deref()
197    }
198
199    /// The supported sort key, or `None` for the board's default ordering.
200    #[must_use]
201    pub fn sort(&self) -> Option<&str> {
202        self.sort.as_deref()
203    }
204
205    /// The supported group key, or `None` for no grouping.
206    #[must_use]
207    pub fn group(&self) -> Option<&str> {
208        self.group.as_deref()
209    }
210
211    /// The visibility snapshot, or `None` when the plan leaves visibility to the
212    /// live board.
213    #[must_use]
214    pub fn visibility(&self) -> Option<&ViewVisibility> {
215        self.visibility.as_ref()
216    }
217
218    /// Plan-level keys this build does not interpret, preserved verbatim.
219    #[must_use]
220    pub fn extensions(&self) -> &Table {
221        &self.extensions
222    }
223
224    /// Whether the plan asks for nothing at all — no criteria, text, sort,
225    /// group, visibility, or unknown metadata.
226    #[must_use]
227    pub fn is_empty(&self) -> bool {
228        self.filter == StructuredFilter::default()
229            && self.text_query.is_none()
230            && self.sort.is_none()
231            && self.group.is_none()
232            && self.visibility.is_none()
233            && self.extensions.is_empty()
234            && self.filter_extensions.is_empty()
235            && self.visibility_extensions.is_empty()
236    }
237
238    /// Replace the structured criteria, normalizing the result.
239    #[must_use]
240    pub fn with_filter(mut self, filter: StructuredFilter) -> Self {
241        self.filter = filter;
242        self.normalize();
243        self
244    }
245
246    /// Replace the text query, lifting any control tokens it carries.
247    #[must_use]
248    pub fn with_text_query(mut self, text_query: &str) -> Self {
249        self.text_query = Some(text_query.to_string());
250        self.absorb_control_tokens(&self.text_query.clone().unwrap_or_default());
251        self.normalize();
252        self
253    }
254
255    /// Set the sort key.
256    #[must_use]
257    pub fn with_sort(mut self, sort: &str) -> Self {
258        self.sort = Some(sort.to_string());
259        self.normalize();
260        self
261    }
262
263    /// Set the group key.
264    #[must_use]
265    pub fn with_group(mut self, group: &str) -> Self {
266        self.group = Some(group.to_string());
267        self.normalize();
268        self
269    }
270
271    /// Set the visibility snapshot.
272    #[must_use]
273    pub fn with_visibility(mut self, visibility: ViewVisibility) -> Self {
274        self.visibility = Some(visibility);
275        self.normalize();
276        self
277    }
278
279    /// Attach plan-level unknown metadata (already normalized by the caller's
280    /// round trip; kept verbatim here).
281    #[must_use]
282    pub fn with_extensions(mut self, extensions: Table) -> Self {
283        self.extensions = extensions;
284        self
285    }
286
287    /// Canonicalize every value (T008).
288    ///
289    /// Trims text, drops the empty ones, folds `overdue: false` to "unset", and
290    /// normalizes column lists. Unsupported sort or group keys are **kept** —
291    /// normalization is not validation, and [`Self::validate`] is what reports
292    /// them. An explicit sort/group key is kept too: `sort:created` is a
293    /// different plan from "no sort", because the board's own `order_by` is not
294    /// the sorted path.
295    pub fn normalize(&mut self) {
296        self.filter.normalize();
297        if self.filter.overdue == Some(false) {
298            self.filter.overdue = None;
299        }
300        self.filter.scope = self
301            .filter
302            .scope
303            .take()
304            .map(normalize_columns)
305            .filter(|columns| !columns.is_empty());
306        self.text_query = self
307            .text_query
308            .take()
309            .map(|query| query.trim().to_string())
310            .filter(|query| !query.is_empty());
311        self.sort = normalize_key(self.sort.take());
312        self.group = normalize_key(self.group.take());
313        if let Some(visibility) = &mut self.visibility {
314            visibility.normalize();
315        }
316    }
317
318    /// Report every unsupported field in the plan, against `board` and the
319    /// project's category vocabulary (T009).
320    ///
321    /// Returns `Ok(())` only when every field is representable on this board.
322    /// Otherwise the refusal carries one diagnostic per problem, in a stable
323    /// order (filter, then sort, then group, then visibility), each with its
324    /// record-relative field path. `config` is consulted for the **configured**
325    /// category vocabulary — the same
326    /// [`Config::categories`] the live `category:` token path validates against
327    /// — so a plan cannot validate here and then match nothing on apply.
328    pub fn validate(&self, board: &BoardConfiguration, config: &Config) -> ViewResult<()> {
329        let mut diagnostics = Vec::new();
330        self.validate_filter(board, config, &mut diagnostics);
331        self.validate_sort(&mut diagnostics);
332        self.validate_group(&mut diagnostics);
333        self.validate_visibility(board, &mut diagnostics);
334        if diagnostics.is_empty() {
335            Ok(())
336        } else {
337            Err(ViewRefusal::from_diagnostics(diagnostics))
338        }
339    }
340
341    /// Unknown-metadata notices for this plan (T010).
342    ///
343    /// Informational, never a refusal: the keys were preserved, and this is how a
344    /// surface tells the user they exist. The fields are
345    /// `plan.extensions.<key>`, `plan.filter.extensions.<key>`, and
346    /// `plan.visibility.extensions.<key>`.
347    #[must_use]
348    pub fn diagnostics(&self) -> Vec<ViewDiagnostic> {
349        let mut diagnostics = Vec::new();
350        for (path, table) in [
351            ("plan.extensions", &self.extensions),
352            ("plan.filter.extensions", &self.filter_extensions),
353            ("plan.visibility.extensions", &self.visibility_extensions),
354        ] {
355            for key in table.keys() {
356                diagnostics.push(
357                    ViewDiagnostic::new(
358                        ViewDiagnosticKind::UnknownMetadata,
359                        format!("plan carries an unknown field `{key}`, preserved as written"),
360                    )
361                    .with_field(format!("{path}.{key}")),
362                );
363            }
364        }
365        diagnostics
366    }
367
368    /// Serialize to the persisted `[plan]` table shape (T010).
369    ///
370    /// Only fields that are set are written, so an empty plan is an empty table.
371    /// Unknown keys captured at any level are merged back into their original
372    /// position, unchanged.
373    #[must_use]
374    pub fn to_toml(&self) -> Table {
375        let mut table = Table::new();
376        if let Some(query) = &self.text_query {
377            table.insert("text_query".to_string(), Value::String(query.clone()));
378        }
379        if let Some(sort) = &self.sort {
380            table.insert("sort".to_string(), Value::String(sort.clone()));
381        }
382        if let Some(group) = &self.group {
383            table.insert("group".to_string(), Value::String(group.clone()));
384        }
385
386        let filter = self.filter_to_toml();
387        if !filter.is_empty() {
388            table.insert("filter".to_string(), Value::Table(filter));
389        }
390
391        if let Some(visibility) = &self.visibility {
392            let mut visible = Table::new();
393            // Always written, even empty: a present `[plan.visibility]` is an
394            // absolute snapshot, and an empty one means "show every column". An
395            // omitted `hidden_columns` would let the serializer drop an empty
396            // table and turn an explicit setting into "inherit".
397            visible.insert(
398                "hidden_columns".to_string(),
399                Value::Array(
400                    visibility
401                        .hidden_columns
402                        .iter()
403                        .cloned()
404                        .map(Value::String)
405                        .collect(),
406                ),
407            );
408            if let Some(show_mismatch) = visibility.show_mismatch {
409                visible.insert("show_mismatch".to_string(), Value::Boolean(show_mismatch));
410            }
411            for (key, value) in &self.visibility_extensions {
412                visible.insert(key.clone(), value.clone());
413            }
414            table.insert("visibility".to_string(), Value::Table(visible));
415        }
416
417        // Last, so a captured key can never be shadowed by the writer above;
418        // `from_toml` guarantees the two sets are disjoint.
419        for (key, value) in &self.extensions {
420            table.insert(key.clone(), value.clone());
421        }
422        table
423    }
424
425    /// Parse the persisted `[plan]` table shape (T010).
426    ///
427    /// Structural problems (a field of the wrong TOML type) are refusals; an
428    /// unsupported *value* (`sort = "bogus"`) is not — that is
429    /// [`Self::validate`]'s job, so a plan written by a newer build with a sort
430    /// key this build does not know still loads and still round-trips.
431    pub fn from_toml(table: &Table) -> ViewResult<Self> {
432        let mut plan = Self::default();
433        let mut diagnostics = Vec::new();
434        for (key, value) in table {
435            match key.as_str() {
436                "text_query" => {
437                    if let Some(text) = string_value(
438                        value,
439                        "plan.text_query",
440                        ViewDiagnosticKind::InvalidFilter,
441                        &mut diagnostics,
442                    ) {
443                        plan.text_query = Some(text);
444                    }
445                }
446                "sort" => {
447                    plan.sort = string_value(
448                        value,
449                        "plan.sort",
450                        ViewDiagnosticKind::InvalidSort,
451                        &mut diagnostics,
452                    );
453                }
454                "group" => {
455                    plan.group = string_value(
456                        value,
457                        "plan.group",
458                        ViewDiagnosticKind::InvalidGroup,
459                        &mut diagnostics,
460                    );
461                }
462                "filter" => {
463                    let Some(sub) = table_value(
464                        value,
465                        "plan.filter",
466                        ViewDiagnosticKind::InvalidFilter,
467                        &mut diagnostics,
468                    ) else {
469                        continue;
470                    };
471                    let (filter, unknown, error) = parse_filter(sub);
472                    plan.filter = filter;
473                    plan.filter_extensions = unknown;
474                    diagnostics.extend(error);
475                }
476                "visibility" => {
477                    let Some(sub) = table_value(
478                        value,
479                        "plan.visibility",
480                        ViewDiagnosticKind::InvalidVisibility,
481                        &mut diagnostics,
482                    ) else {
483                        continue;
484                    };
485                    let (visibility, unknown, errors) = parse_visibility(sub);
486                    plan.visibility = Some(visibility);
487                    plan.visibility_extensions = unknown;
488                    diagnostics.extend(errors);
489                }
490                _ => {
491                    plan.extensions.insert(key.clone(), value.clone());
492                }
493            }
494        }
495        if !diagnostics.is_empty() {
496            return Err(ViewRefusal::from_diagnostics(diagnostics));
497        }
498        // A persisted `text_query` may itself carry `sort:`/`group:` tokens — a
499        // plan written before the typed fields existed, or by a surface that
500        // only had the raw query. Lift them exactly as `from_parts` does, so the
501        // typed fields are authoritative and a token cannot be silently
502        // overridden by the empty typed field on the next write.
503        if let Some(text) = plan.text_query.take() {
504            plan.absorb_control_tokens(&text);
505        }
506        plan.normalize();
507        Ok(plan)
508    }
509
510    /// Apply the plan: build the board **once** through the existing filter path
511    /// and return a read-only result (T011/C-005).
512    ///
513    /// Validates first, so an unsupported field returns a refusal and no board is
514    /// built. The board's *visibility* comes from the plan when the plan sets it
515    /// and from `board` otherwise; the *sort* and *group* are reconstructed as
516    /// the existing control tokens, so the existing
517    /// [`BoardState::build_filtered`] ordering and grouping apply unchanged.
518    pub fn apply(
519        &self,
520        board: &BoardConfiguration,
521        config: &Config,
522        corpus: &Corpus,
523        resolution: &CategoryResolution,
524    ) -> ViewResult<ViewApplyResult> {
525        self.validate(board, config)?;
526        let effective = self.effective_board(board);
527        let query = self.effective_query();
528        let board_state = BoardState::build_filtered(
529            &effective,
530            config,
531            corpus,
532            &query,
533            &self.filter,
534            resolution,
535        );
536        let issue_numbers = collect_issue_numbers(&board_state);
537        Ok(ViewApplyResult {
538            view_id: None,
539            plan: self.clone(),
540            issue_numbers,
541            board: board_state,
542            diagnostics: self.diagnostics(),
543        })
544    }
545
546    /// [`Self::apply`] for a plan reached through a stable view id, so the result
547    /// carries the identity its surface selected.
548    pub fn apply_as(
549        &self,
550        view_id: &ViewId,
551        board: &BoardConfiguration,
552        config: &Config,
553        corpus: &Corpus,
554        resolution: &CategoryResolution,
555    ) -> ViewResult<ViewApplyResult> {
556        let mut result = self.apply(board, config, corpus, resolution)?;
557        result.view_id = Some(view_id.clone());
558        Ok(result)
559    }
560
561    /// Lift `sort:`/`group:` control tokens out of `text_query` into the typed
562    /// fields.
563    ///
564    /// The first occurrence wins, as [`crate::search::control_token`] does, and
565    /// **every** occurrence is removed from the text query so the plan never
566    /// carries two spellings of the same setting. A typed field the caller
567    /// already set is left alone; the query's token is still dropped, because a
568    /// plan has one sort and one group.
569    fn absorb_control_tokens(&mut self, text_query: &str) {
570        let mut kept: Vec<&str> = Vec::new();
571        let mut sort = None;
572        let mut group = None;
573        for token in text_query.split_whitespace() {
574            if let Some((key, value)) = token.split_once(':') {
575                let value = value.trim().to_string();
576                match key.to_ascii_lowercase().as_str() {
577                    "sort" => {
578                        if sort.is_none() {
579                            sort = Some(value);
580                        }
581                        continue;
582                    }
583                    "group" => {
584                        if group.is_none() {
585                            group = Some(value);
586                        }
587                        continue;
588                    }
589                    _ => {}
590                }
591            }
592            kept.push(token);
593        }
594        if self.sort.is_none() {
595            self.sort = sort;
596        }
597        if self.group.is_none() {
598            self.group = group;
599        }
600        self.text_query = Some(kept.join(" "));
601    }
602
603    /// The query string the existing matcher and board builder consume: the free
604    /// text plus the typed sort/group re-spelled as their control tokens.
605    ///
606    /// Public because a surface that installs a plan into **live** filter state
607    /// (the TUI, WP05) must hand the *same* query to the *same*
608    /// [`BoardState::build_filtered`] path [`Self::apply`] uses. Re-spelling the
609    /// tokens at the call site would be a second, drift-prone spelling of the
610    /// control-token protocol (C-002/C-007/NFR-003).
611    #[must_use]
612    pub fn effective_query(&self) -> String {
613        let mut parts: Vec<String> = Vec::new();
614        if let Some(query) = &self.text_query {
615            parts.push(query.clone());
616        }
617        if let Some(sort) = &self.sort {
618            parts.push(format!("sort:{sort}"));
619        }
620        if let Some(group) = &self.group {
621            parts.push(format!("group:{group}"));
622        }
623        parts.join(" ")
624    }
625
626    /// The board configuration a view is applied over: the live board, with its
627    /// visibility replaced when the plan carries a snapshot.
628    ///
629    /// Public for the same reason as [`Self::effective_query`]: a surface that
630    /// keeps the applied plan as live state (the TUI, WP05) rebuilds the board
631    /// through the existing path, and the visibility it must use is exactly the
632    /// one [`Self::apply`] used.
633    #[must_use]
634    pub fn effective_board(&self, board: &BoardConfiguration) -> BoardConfiguration {
635        let mut effective = board.clone();
636        if let Some(visibility) = &self.visibility {
637            effective.hidden_columns = visibility.hidden_columns.clone();
638            if let Some(show_mismatch) = visibility.show_mismatch {
639                effective.show_mismatch = show_mismatch;
640            }
641        }
642        effective
643    }
644
645    /// The `[plan.filter]` table for the typed criteria, plus preserved unknowns.
646    fn filter_to_toml(&self) -> Table {
647        let mut table = Table::new();
648        let filter = &self.filter;
649        let strings = [
650            ("assignee", &filter.assignee),
651            ("label", &filter.label),
652            ("status", &filter.status),
653            ("priority", &filter.priority),
654            ("cycle", &filter.cycle),
655            ("milestone", &filter.milestone),
656            ("reviewer", &filter.reviewer),
657            ("start_date", &filter.start_date),
658            ("due_date", &filter.due_date),
659            ("parent", &filter.parent),
660            ("category", &filter.category),
661        ];
662        for (key, value) in strings {
663            if let Some(value) = value {
664                table.insert(key.to_string(), Value::String(value.clone()));
665            }
666        }
667        if filter.overdue == Some(true) {
668            table.insert("overdue".to_string(), Value::Boolean(true));
669        }
670        if let Some(scope) = &filter.scope {
671            table.insert(
672                "scope".to_string(),
673                Value::Array(scope.iter().cloned().map(Value::String).collect()),
674            );
675        }
676        for (key, value) in &self.filter_extensions {
677            table.insert(key.clone(), value.clone());
678        }
679        table
680    }
681
682    fn validate_filter(
683        &self,
684        board: &BoardConfiguration,
685        config: &Config,
686        diagnostics: &mut Vec<ViewDiagnostic>,
687    ) {
688        if let Some(category) = &self.filter.category
689            && !config.categories.contains(category)
690        {
691            diagnostics.push(
692                ViewDiagnostic::new(
693                    ViewDiagnosticKind::InvalidFilter,
694                    format!("`category:{category}` is not a known workflow category"),
695                )
696                .with_field("plan.filter.category")
697                .with_remedy(format!(
698                    "this project's categories are: {}",
699                    config.categories.order.join(", ")
700                )),
701            );
702        }
703        if let Some(parent) = &self.filter.parent
704            && parent.parse::<u32>().is_err()
705        {
706            diagnostics.push(
707                ViewDiagnostic::new(
708                    ViewDiagnosticKind::InvalidFilter,
709                    format!(
710                        "`parent:{parent}` is not a resolved issue number; a saved plan \
711                         stores the number, not the selector"
712                    ),
713                )
714                .with_field("plan.filter.parent")
715                .with_remedy("resolve the selector to its issue number before saving"),
716            );
717        }
718        if let Some(scope) = &self.filter.scope {
719            for column in scope {
720                if !board.columns.contains(column) {
721                    diagnostics.push(
722                        ViewDiagnostic::new(
723                            ViewDiagnosticKind::InvalidFilter,
724                            format!("`scope` names column `{column}`, which this board does not configure"),
725                        )
726                        .with_field("plan.filter.scope")
727                        .with_remedy(format!("configured columns: {}", board.columns.join(", "))),
728                    );
729                }
730            }
731        }
732    }
733
734    fn validate_sort(&self, diagnostics: &mut Vec<ViewDiagnostic>) {
735        if let Some(sort) = &self.sort
736            && !SORT_KEYS.contains(&sort.as_str())
737        {
738            diagnostics.push(
739                ViewDiagnostic::new(
740                    ViewDiagnosticKind::InvalidSort,
741                    format!("`sort:{sort}` is not a supported sort key"),
742                )
743                .with_field("plan.sort")
744                .with_remedy(format!("use one of: {}", SORT_KEYS.join(", "))),
745            );
746        }
747    }
748
749    fn validate_group(&self, diagnostics: &mut Vec<ViewDiagnostic>) {
750        if let Some(group) = &self.group
751            && !GROUP_KEYS.contains(&group.as_str())
752        {
753            diagnostics.push(
754                ViewDiagnostic::new(
755                    ViewDiagnosticKind::InvalidGroup,
756                    format!("`group:{group}` is not a supported group key"),
757                )
758                .with_field("plan.group")
759                .with_remedy(format!("use one of: {}", GROUP_KEYS.join(", "))),
760            );
761        }
762    }
763
764    fn validate_visibility(
765        &self,
766        board: &BoardConfiguration,
767        diagnostics: &mut Vec<ViewDiagnostic>,
768    ) {
769        let Some(visibility) = &self.visibility else {
770            return;
771        };
772        for column in &visibility.hidden_columns {
773            if !board.columns.contains(column) {
774                diagnostics.push(
775                    ViewDiagnostic::new(
776                        ViewDiagnosticKind::InvalidVisibility,
777                        format!("visibility hides column `{column}`, which this board does not configure"),
778                    )
779                    .with_field("plan.visibility.hidden_columns")
780                    .with_remedy(format!("configured columns: {}", board.columns.join(", "))),
781                );
782            }
783        }
784    }
785}
786
787/// The read-only result of applying a plan (data-model §6).
788///
789/// `issue_numbers` is the applied issue set in board order (visible columns, then
790/// hidden columns, then the mismatched pseudo-column); `board` is the existing
791/// board summary the filter path produced. Nothing here can write: the corpus and
792/// config were borrowed, and the result owns copies only.
793#[derive(Debug, Clone)]
794pub struct ViewApplyResult {
795    view_id: Option<ViewId>,
796    plan: ViewPlan,
797    issue_numbers: Vec<u32>,
798    board: BoardState,
799    diagnostics: Vec<ViewDiagnostic>,
800}
801
802impl ViewApplyResult {
803    /// The stable view that was applied, when the caller selected one.
804    #[must_use]
805    pub fn view_id(&self) -> Option<&ViewId> {
806        self.view_id.as_ref()
807    }
808
809    /// The normalized plan that produced this result.
810    #[must_use]
811    pub fn plan(&self) -> &ViewPlan {
812        &self.plan
813    }
814
815    /// The applied issue set, in board order.
816    #[must_use]
817    pub fn issue_numbers(&self) -> &[u32] {
818        &self.issue_numbers
819    }
820
821    /// The board the existing filter path built.
822    #[must_use]
823    pub const fn board(&self) -> &BoardState {
824        &self.board
825    }
826
827    /// Unknown-metadata notices carried out of the plan.
828    #[must_use]
829    pub fn diagnostics(&self) -> &[ViewDiagnostic] {
830        &self.diagnostics
831    }
832}
833
834/// Normalize a list of column names: trimmed, blanks dropped, duplicates
835/// collapsed to their first occurrence (declaration order preserved).
836fn normalize_columns(columns: Vec<String>) -> Vec<String> {
837    let mut out: Vec<String> = Vec::with_capacity(columns.len());
838    for column in columns {
839        let column = column.trim();
840        if column.is_empty() || out.iter().any(|seen| seen == column) {
841            continue;
842        }
843        out.push(column.to_string());
844    }
845    out
846}
847
848/// Trim and lower-case a control key; `None` for blank.
849///
850/// An explicit key is **never** folded to unset: an explicit `sort:created` is
851/// not the same plan as no sort at all. With no sort token the board applies its
852/// own `order_by` per column (default newest-first), while `sort:created` takes
853/// the sorted path and orders by creation date **ascending**. Folding the two
854/// would make applying a stored plan produce the opposite order from the query
855/// that saved it.
856fn normalize_key(key: Option<String>) -> Option<String> {
857    let key = key?.trim().to_ascii_lowercase();
858    if key.is_empty() { None } else { Some(key) }
859}
860
861/// Every issue number the board holds, in board order.
862fn collect_issue_numbers(board: &BoardState) -> Vec<u32> {
863    board
864        .columns
865        .iter()
866        .chain(board.hidden.iter())
867        .chain(std::iter::once(&board.mismatch))
868        .flat_map(|column| column.cards.iter().map(|card| card.number))
869        .collect()
870}
871
872/// A TOML value as a string, or a `field`-aware type diagnostic of `kind`.
873fn string_value(
874    value: &Value,
875    field: &str,
876    kind: ViewDiagnosticKind,
877    diagnostics: &mut Vec<ViewDiagnostic>,
878) -> Option<String> {
879    match value.clone().try_into::<String>() {
880        Ok(text) => Some(text),
881        Err(error) => {
882            diagnostics.push(
883                ViewDiagnostic::new(kind, format!("`{field}` must be a string: {error}"))
884                    .with_field(field.to_string()),
885            );
886            None
887        }
888    }
889}
890
891/// A TOML value as a table, or a `field`-aware type diagnostic.
892fn table_value<'a>(
893    value: &'a Value,
894    field: &str,
895    kind: ViewDiagnosticKind,
896    diagnostics: &mut Vec<ViewDiagnostic>,
897) -> Option<&'a Table> {
898    match value {
899        Value::Table(table) => Some(table),
900        other => {
901            diagnostics.push(
902                ViewDiagnostic::new(
903                    kind,
904                    format!("`{field}` must be a table, found {}", other.type_str()),
905                )
906                .with_field(field.to_string()),
907            );
908            None
909        }
910    }
911}
912
913/// Split `[plan.filter]` into the typed criteria this build knows and the keys it
914/// preserves.
915fn parse_filter(table: &Table) -> (StructuredFilter, Table, Option<ViewDiagnostic>) {
916    let mut known = Table::new();
917    let mut unknown = Table::new();
918    for (key, value) in table {
919        if FILTER_KEYS.contains(&key.as_str()) {
920            known.insert(key.clone(), value.clone());
921        } else {
922            unknown.insert(key.clone(), value.clone());
923        }
924    }
925    match Value::Table(known).try_into::<StructuredFilter>() {
926        Ok(filter) => (filter, unknown, None),
927        Err(error) => (
928            StructuredFilter::default(),
929            unknown,
930            Some(
931                ViewDiagnostic::new(
932                    ViewDiagnosticKind::InvalidFilter,
933                    format!("`plan.filter` is malformed: {error}"),
934                )
935                .with_field("plan.filter"),
936            ),
937        ),
938    }
939}
940
941/// Split `[plan.visibility]` into the typed settings this build knows and the
942/// keys it preserves.
943fn parse_visibility(table: &Table) -> (ViewVisibility, Table, Vec<ViewDiagnostic>) {
944    let mut visibility = ViewVisibility::default();
945    let mut unknown = Table::new();
946    let mut errors = Vec::new();
947    for (key, value) in table {
948        match key.as_str() {
949            "hidden_columns" => match value.clone().try_into::<Vec<String>>() {
950                Ok(columns) => visibility.hidden_columns = columns,
951                Err(found) => {
952                    errors.push(
953                        ViewDiagnostic::new(
954                            ViewDiagnosticKind::InvalidVisibility,
955                            format!(
956                                "`plan.visibility.hidden_columns` must be a list of strings: {found}"
957                            ),
958                        )
959                        .with_field("plan.visibility.hidden_columns"),
960                    );
961                }
962            },
963            "show_mismatch" => match value.clone().try_into::<bool>() {
964                Ok(show) => visibility.show_mismatch = Some(show),
965                Err(found) => {
966                    errors.push(
967                        ViewDiagnostic::new(
968                            ViewDiagnosticKind::InvalidVisibility,
969                            format!("`plan.visibility.show_mismatch` must be a boolean: {found}"),
970                        )
971                        .with_field("plan.visibility.show_mismatch"),
972                    );
973                }
974            },
975            _ => {
976                unknown.insert(key.clone(), value.clone());
977            }
978        }
979    }
980    visibility.normalize();
981    (visibility, unknown, errors)
982}