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}