use crate::config::{BoardConfiguration, Config};
use crate::search::{Corpus, StructuredFilter};
use crate::ui::board::BoardState;
use crate::views::registry::{ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRefusal, ViewResult};
use crate::workflow::CategoryResolution;
use toml::{Table, Value};
pub const SORT_KEYS: &[&str] = &[
"created",
"priority",
"status",
"cycle",
"milestone",
"due",
"start",
"reviewer",
];
pub const GROUP_KEYS: &[&str] = &["cycle", "milestone"];
const FILTER_KEYS: &[&str] = &[
"assignee",
"label",
"status",
"priority",
"cycle",
"milestone",
"reviewer",
"start_date",
"due_date",
"overdue",
"parent",
"category",
"scope",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ViewVisibility {
hidden_columns: Vec<String>,
show_mismatch: Option<bool>,
}
impl ViewVisibility {
#[must_use]
pub fn new(hidden_columns: Vec<String>, show_mismatch: Option<bool>) -> Self {
let mut visibility = Self {
hidden_columns,
show_mismatch,
};
visibility.normalize();
visibility
}
#[must_use]
pub fn hidden_columns(&self) -> &[String] {
&self.hidden_columns
}
#[must_use]
pub const fn show_mismatch(&self) -> Option<bool> {
self.show_mismatch
}
pub fn normalize(&mut self) {
self.hidden_columns = normalize_columns(std::mem::take(&mut self.hidden_columns));
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ViewPlan {
filter: StructuredFilter,
text_query: Option<String>,
sort: Option<String>,
group: Option<String>,
visibility: Option<ViewVisibility>,
extensions: Table,
filter_extensions: Table,
visibility_extensions: Table,
}
impl ViewPlan {
#[must_use]
pub fn from_parts(filter: StructuredFilter, text_query: &str) -> Self {
let mut plan = Self {
filter,
..Self::default()
};
plan.absorb_control_tokens(text_query);
plan.normalize();
plan
}
#[must_use]
pub fn from_query(text_query: &str) -> Self {
Self::from_parts(StructuredFilter::default(), text_query)
}
#[must_use]
pub fn filter(&self) -> &StructuredFilter {
&self.filter
}
#[must_use]
pub fn text_query(&self) -> Option<&str> {
self.text_query.as_deref()
}
#[must_use]
pub fn sort(&self) -> Option<&str> {
self.sort.as_deref()
}
#[must_use]
pub fn group(&self) -> Option<&str> {
self.group.as_deref()
}
#[must_use]
pub fn visibility(&self) -> Option<&ViewVisibility> {
self.visibility.as_ref()
}
#[must_use]
pub fn extensions(&self) -> &Table {
&self.extensions
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.filter == StructuredFilter::default()
&& self.text_query.is_none()
&& self.sort.is_none()
&& self.group.is_none()
&& self.visibility.is_none()
&& self.extensions.is_empty()
&& self.filter_extensions.is_empty()
&& self.visibility_extensions.is_empty()
}
#[must_use]
pub fn with_filter(mut self, filter: StructuredFilter) -> Self {
self.filter = filter;
self.normalize();
self
}
#[must_use]
pub fn with_text_query(mut self, text_query: &str) -> Self {
self.text_query = Some(text_query.to_string());
self.absorb_control_tokens(&self.text_query.clone().unwrap_or_default());
self.normalize();
self
}
#[must_use]
pub fn with_sort(mut self, sort: &str) -> Self {
self.sort = Some(sort.to_string());
self.normalize();
self
}
#[must_use]
pub fn with_group(mut self, group: &str) -> Self {
self.group = Some(group.to_string());
self.normalize();
self
}
#[must_use]
pub fn with_visibility(mut self, visibility: ViewVisibility) -> Self {
self.visibility = Some(visibility);
self.normalize();
self
}
#[must_use]
pub fn with_extensions(mut self, extensions: Table) -> Self {
self.extensions = extensions;
self
}
pub fn normalize(&mut self) {
self.filter.normalize();
if self.filter.overdue == Some(false) {
self.filter.overdue = None;
}
self.filter.scope = self
.filter
.scope
.take()
.map(normalize_columns)
.filter(|columns| !columns.is_empty());
self.text_query = self
.text_query
.take()
.map(|query| query.trim().to_string())
.filter(|query| !query.is_empty());
self.sort = normalize_key(self.sort.take());
self.group = normalize_key(self.group.take());
if let Some(visibility) = &mut self.visibility {
visibility.normalize();
}
}
pub fn validate(&self, board: &BoardConfiguration, config: &Config) -> ViewResult<()> {
let mut diagnostics = Vec::new();
self.validate_filter(board, config, &mut diagnostics);
self.validate_sort(&mut diagnostics);
self.validate_group(&mut diagnostics);
self.validate_visibility(board, &mut diagnostics);
if diagnostics.is_empty() {
Ok(())
} else {
Err(ViewRefusal::from_diagnostics(diagnostics))
}
}
#[must_use]
pub fn diagnostics(&self) -> Vec<ViewDiagnostic> {
let mut diagnostics = Vec::new();
for (path, table) in [
("plan.extensions", &self.extensions),
("plan.filter.extensions", &self.filter_extensions),
("plan.visibility.extensions", &self.visibility_extensions),
] {
for key in table.keys() {
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::UnknownMetadata,
format!("plan carries an unknown field `{key}`, preserved as written"),
)
.with_field(format!("{path}.{key}")),
);
}
}
diagnostics
}
#[must_use]
pub fn to_toml(&self) -> Table {
let mut table = Table::new();
if let Some(query) = &self.text_query {
table.insert("text_query".to_string(), Value::String(query.clone()));
}
if let Some(sort) = &self.sort {
table.insert("sort".to_string(), Value::String(sort.clone()));
}
if let Some(group) = &self.group {
table.insert("group".to_string(), Value::String(group.clone()));
}
let filter = self.filter_to_toml();
if !filter.is_empty() {
table.insert("filter".to_string(), Value::Table(filter));
}
if let Some(visibility) = &self.visibility {
let mut visible = Table::new();
visible.insert(
"hidden_columns".to_string(),
Value::Array(
visibility
.hidden_columns
.iter()
.cloned()
.map(Value::String)
.collect(),
),
);
if let Some(show_mismatch) = visibility.show_mismatch {
visible.insert("show_mismatch".to_string(), Value::Boolean(show_mismatch));
}
for (key, value) in &self.visibility_extensions {
visible.insert(key.clone(), value.clone());
}
table.insert("visibility".to_string(), Value::Table(visible));
}
for (key, value) in &self.extensions {
table.insert(key.clone(), value.clone());
}
table
}
pub fn from_toml(table: &Table) -> ViewResult<Self> {
let mut plan = Self::default();
let mut diagnostics = Vec::new();
for (key, value) in table {
match key.as_str() {
"text_query" => {
if let Some(text) = string_value(
value,
"plan.text_query",
ViewDiagnosticKind::InvalidFilter,
&mut diagnostics,
) {
plan.text_query = Some(text);
}
}
"sort" => {
plan.sort = string_value(
value,
"plan.sort",
ViewDiagnosticKind::InvalidSort,
&mut diagnostics,
);
}
"group" => {
plan.group = string_value(
value,
"plan.group",
ViewDiagnosticKind::InvalidGroup,
&mut diagnostics,
);
}
"filter" => {
let Some(sub) = table_value(
value,
"plan.filter",
ViewDiagnosticKind::InvalidFilter,
&mut diagnostics,
) else {
continue;
};
let (filter, unknown, error) = parse_filter(sub);
plan.filter = filter;
plan.filter_extensions = unknown;
diagnostics.extend(error);
}
"visibility" => {
let Some(sub) = table_value(
value,
"plan.visibility",
ViewDiagnosticKind::InvalidVisibility,
&mut diagnostics,
) else {
continue;
};
let (visibility, unknown, errors) = parse_visibility(sub);
plan.visibility = Some(visibility);
plan.visibility_extensions = unknown;
diagnostics.extend(errors);
}
_ => {
plan.extensions.insert(key.clone(), value.clone());
}
}
}
if !diagnostics.is_empty() {
return Err(ViewRefusal::from_diagnostics(diagnostics));
}
if let Some(text) = plan.text_query.take() {
plan.absorb_control_tokens(&text);
}
plan.normalize();
Ok(plan)
}
pub fn apply(
&self,
board: &BoardConfiguration,
config: &Config,
corpus: &Corpus,
resolution: &CategoryResolution,
) -> ViewResult<ViewApplyResult> {
self.validate(board, config)?;
let effective = self.effective_board(board);
let query = self.effective_query();
let board_state = BoardState::build_filtered(
&effective,
config,
corpus,
&query,
&self.filter,
resolution,
);
let issue_numbers = collect_issue_numbers(&board_state);
Ok(ViewApplyResult {
view_id: None,
plan: self.clone(),
issue_numbers,
board: board_state,
diagnostics: self.diagnostics(),
})
}
pub fn apply_as(
&self,
view_id: &ViewId,
board: &BoardConfiguration,
config: &Config,
corpus: &Corpus,
resolution: &CategoryResolution,
) -> ViewResult<ViewApplyResult> {
let mut result = self.apply(board, config, corpus, resolution)?;
result.view_id = Some(view_id.clone());
Ok(result)
}
fn absorb_control_tokens(&mut self, text_query: &str) {
let mut kept: Vec<&str> = Vec::new();
let mut sort = None;
let mut group = None;
for token in text_query.split_whitespace() {
if let Some((key, value)) = token.split_once(':') {
let value = value.trim().to_string();
match key.to_ascii_lowercase().as_str() {
"sort" => {
if sort.is_none() {
sort = Some(value);
}
continue;
}
"group" => {
if group.is_none() {
group = Some(value);
}
continue;
}
_ => {}
}
}
kept.push(token);
}
if self.sort.is_none() {
self.sort = sort;
}
if self.group.is_none() {
self.group = group;
}
self.text_query = Some(kept.join(" "));
}
#[must_use]
pub fn effective_query(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(query) = &self.text_query {
parts.push(query.clone());
}
if let Some(sort) = &self.sort {
parts.push(format!("sort:{sort}"));
}
if let Some(group) = &self.group {
parts.push(format!("group:{group}"));
}
parts.join(" ")
}
#[must_use]
pub fn effective_board(&self, board: &BoardConfiguration) -> BoardConfiguration {
let mut effective = board.clone();
if let Some(visibility) = &self.visibility {
effective.hidden_columns = visibility.hidden_columns.clone();
if let Some(show_mismatch) = visibility.show_mismatch {
effective.show_mismatch = show_mismatch;
}
}
effective
}
fn filter_to_toml(&self) -> Table {
let mut table = Table::new();
let filter = &self.filter;
let strings = [
("assignee", &filter.assignee),
("label", &filter.label),
("status", &filter.status),
("priority", &filter.priority),
("cycle", &filter.cycle),
("milestone", &filter.milestone),
("reviewer", &filter.reviewer),
("start_date", &filter.start_date),
("due_date", &filter.due_date),
("parent", &filter.parent),
("category", &filter.category),
];
for (key, value) in strings {
if let Some(value) = value {
table.insert(key.to_string(), Value::String(value.clone()));
}
}
if filter.overdue == Some(true) {
table.insert("overdue".to_string(), Value::Boolean(true));
}
if let Some(scope) = &filter.scope {
table.insert(
"scope".to_string(),
Value::Array(scope.iter().cloned().map(Value::String).collect()),
);
}
for (key, value) in &self.filter_extensions {
table.insert(key.clone(), value.clone());
}
table
}
fn validate_filter(
&self,
board: &BoardConfiguration,
config: &Config,
diagnostics: &mut Vec<ViewDiagnostic>,
) {
if let Some(category) = &self.filter.category
&& !config.categories.contains(category)
{
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidFilter,
format!("`category:{category}` is not a known workflow category"),
)
.with_field("plan.filter.category")
.with_remedy(format!(
"this project's categories are: {}",
config.categories.order.join(", ")
)),
);
}
if let Some(parent) = &self.filter.parent
&& parent.parse::<u32>().is_err()
{
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidFilter,
format!(
"`parent:{parent}` is not a resolved issue number; a saved plan \
stores the number, not the selector"
),
)
.with_field("plan.filter.parent")
.with_remedy("resolve the selector to its issue number before saving"),
);
}
if let Some(scope) = &self.filter.scope {
for column in scope {
if !board.columns.contains(column) {
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidFilter,
format!("`scope` names column `{column}`, which this board does not configure"),
)
.with_field("plan.filter.scope")
.with_remedy(format!("configured columns: {}", board.columns.join(", "))),
);
}
}
}
}
fn validate_sort(&self, diagnostics: &mut Vec<ViewDiagnostic>) {
if let Some(sort) = &self.sort
&& !SORT_KEYS.contains(&sort.as_str())
{
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidSort,
format!("`sort:{sort}` is not a supported sort key"),
)
.with_field("plan.sort")
.with_remedy(format!("use one of: {}", SORT_KEYS.join(", "))),
);
}
}
fn validate_group(&self, diagnostics: &mut Vec<ViewDiagnostic>) {
if let Some(group) = &self.group
&& !GROUP_KEYS.contains(&group.as_str())
{
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidGroup,
format!("`group:{group}` is not a supported group key"),
)
.with_field("plan.group")
.with_remedy(format!("use one of: {}", GROUP_KEYS.join(", "))),
);
}
}
fn validate_visibility(
&self,
board: &BoardConfiguration,
diagnostics: &mut Vec<ViewDiagnostic>,
) {
let Some(visibility) = &self.visibility else {
return;
};
for column in &visibility.hidden_columns {
if !board.columns.contains(column) {
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidVisibility,
format!("visibility hides column `{column}`, which this board does not configure"),
)
.with_field("plan.visibility.hidden_columns")
.with_remedy(format!("configured columns: {}", board.columns.join(", "))),
);
}
}
}
}
#[derive(Debug, Clone)]
pub struct ViewApplyResult {
view_id: Option<ViewId>,
plan: ViewPlan,
issue_numbers: Vec<u32>,
board: BoardState,
diagnostics: Vec<ViewDiagnostic>,
}
impl ViewApplyResult {
#[must_use]
pub fn view_id(&self) -> Option<&ViewId> {
self.view_id.as_ref()
}
#[must_use]
pub fn plan(&self) -> &ViewPlan {
&self.plan
}
#[must_use]
pub fn issue_numbers(&self) -> &[u32] {
&self.issue_numbers
}
#[must_use]
pub const fn board(&self) -> &BoardState {
&self.board
}
#[must_use]
pub fn diagnostics(&self) -> &[ViewDiagnostic] {
&self.diagnostics
}
}
fn normalize_columns(columns: Vec<String>) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(columns.len());
for column in columns {
let column = column.trim();
if column.is_empty() || out.iter().any(|seen| seen == column) {
continue;
}
out.push(column.to_string());
}
out
}
fn normalize_key(key: Option<String>) -> Option<String> {
let key = key?.trim().to_ascii_lowercase();
if key.is_empty() { None } else { Some(key) }
}
fn collect_issue_numbers(board: &BoardState) -> Vec<u32> {
board
.columns
.iter()
.chain(board.hidden.iter())
.chain(std::iter::once(&board.mismatch))
.flat_map(|column| column.cards.iter().map(|card| card.number))
.collect()
}
fn string_value(
value: &Value,
field: &str,
kind: ViewDiagnosticKind,
diagnostics: &mut Vec<ViewDiagnostic>,
) -> Option<String> {
match value.clone().try_into::<String>() {
Ok(text) => Some(text),
Err(error) => {
diagnostics.push(
ViewDiagnostic::new(kind, format!("`{field}` must be a string: {error}"))
.with_field(field.to_string()),
);
None
}
}
}
fn table_value<'a>(
value: &'a Value,
field: &str,
kind: ViewDiagnosticKind,
diagnostics: &mut Vec<ViewDiagnostic>,
) -> Option<&'a Table> {
match value {
Value::Table(table) => Some(table),
other => {
diagnostics.push(
ViewDiagnostic::new(
kind,
format!("`{field}` must be a table, found {}", other.type_str()),
)
.with_field(field.to_string()),
);
None
}
}
}
fn parse_filter(table: &Table) -> (StructuredFilter, Table, Option<ViewDiagnostic>) {
let mut known = Table::new();
let mut unknown = Table::new();
for (key, value) in table {
if FILTER_KEYS.contains(&key.as_str()) {
known.insert(key.clone(), value.clone());
} else {
unknown.insert(key.clone(), value.clone());
}
}
match Value::Table(known).try_into::<StructuredFilter>() {
Ok(filter) => (filter, unknown, None),
Err(error) => (
StructuredFilter::default(),
unknown,
Some(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidFilter,
format!("`plan.filter` is malformed: {error}"),
)
.with_field("plan.filter"),
),
),
}
}
fn parse_visibility(table: &Table) -> (ViewVisibility, Table, Vec<ViewDiagnostic>) {
let mut visibility = ViewVisibility::default();
let mut unknown = Table::new();
let mut errors = Vec::new();
for (key, value) in table {
match key.as_str() {
"hidden_columns" => match value.clone().try_into::<Vec<String>>() {
Ok(columns) => visibility.hidden_columns = columns,
Err(found) => {
errors.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidVisibility,
format!(
"`plan.visibility.hidden_columns` must be a list of strings: {found}"
),
)
.with_field("plan.visibility.hidden_columns"),
);
}
},
"show_mismatch" => match value.clone().try_into::<bool>() {
Ok(show) => visibility.show_mismatch = Some(show),
Err(found) => {
errors.push(
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidVisibility,
format!("`plan.visibility.show_mismatch` must be a boolean: {found}"),
)
.with_field("plan.visibility.show_mismatch"),
);
}
},
_ => {
unknown.insert(key.clone(), value.clone());
}
}
}
visibility.normalize();
(visibility, unknown, errors)
}