use crate::commands::bulk::PatchFlags;
use crate::error::{Result, TixError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BulkOperation {
Edit,
Link,
Unlink,
Archive,
}
impl BulkOperation {
pub const ALL: &'static [Self] = &[Self::Edit, Self::Link, Self::Unlink, Self::Archive];
pub fn as_str(self) -> &'static str {
match self {
Self::Edit => "edit",
Self::Link => "link",
Self::Unlink => "unlink",
Self::Archive => "archive",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|op| op.as_str() == name)
}
pub fn needs_target(self) -> bool {
matches!(self, Self::Link | Self::Unlink)
}
pub fn needs_kind(self) -> bool {
matches!(self, Self::Link | Self::Unlink)
}
pub fn is_field_patch(self) -> bool {
matches!(self, Self::Edit)
}
pub fn availability(self) -> &'static str {
match self {
Self::Edit | Self::Link | Self::Unlink | Self::Archive => "available",
}
}
pub fn is_applicable(self) -> bool {
match self {
Self::Edit | Self::Link | Self::Unlink | Self::Archive => true,
}
}
pub fn summary(self) -> &'static str {
match self {
Self::Edit => "change issue fields through a typed patch",
Self::Link => "add a relationship from each selected issue to a target",
Self::Unlink => "remove a relationship from each selected issue",
Self::Archive => {
"archive each selected issue by delegating to the store's archive \
service — the file is renamed in place to a dot-prefixed name, and one \
History event records it"
}
}
}
}
pub struct PatchField {
pub name: &'static str,
pub clearable: bool,
pub summary: &'static str,
}
pub const PATCH_FIELDS: &[PatchField] = &[
PatchField {
name: "status",
clearable: false,
summary: "column, validated through the workflow service at apply time",
},
PatchField {
name: "priority",
clearable: true,
summary: "one of the three strict words `low`, `medium`, `high`",
},
PatchField {
name: "assignee",
clearable: true,
summary: "handle or full identity; changing it moves the file into the segment",
},
PatchField {
name: "labels",
clearable: true,
summary: "comma-separated; replaces the whole set",
},
PatchField {
name: "start-date",
clearable: true,
summary: "`YYYY-MM-DD`",
},
PatchField {
name: "due-date",
clearable: true,
summary: "`YYYY-MM-DD`; must be `>= start-date`",
},
PatchField {
name: "estimate",
clearable: true,
summary: "free text; a blank value is **unchanged**, not empty",
},
PatchField {
name: "cycles",
clearable: true,
summary: "comma-separated cycle names; replaces the whole set",
},
PatchField {
name: "milestones",
clearable: true,
summary: "comma-separated milestone names; replaces the whole set",
},
];
pub const OUTCOME_STATUSES: &[&str] = &["applied", "no-op", "rejected", "failed"];
pub const SELECTION_GRAMMAR: &[&str] = &[
"explicit: a comma-separated list of selectors — a number, a filename fragment, or a title fragment",
"filter: the existing structured filter grammar, e.g. `status:todo label:bug`",
"search: an existing search token, e.g. `search:fix crash`",
];
pub const PREVIEW_SELECTION_LIMIT: usize = 10_000;
pub const UNSUPPORTED_OPERATION: &str = "unsupported_operation";
pub fn unsupported(operation: &str, reason: &str) -> TixError {
TixError::InvalidInput(format!(
"unsupported_operation: `{operation}` is not available in this build — {reason}"
))
}
pub fn unsupported_field(field: &str) -> TixError {
TixError::InvalidInput(format!(
"unsupported_operation: `{field}` is not a batch field — the whitelist is: {}. \
Bulk `title` and `body` rewrites are deliberately out of scope: a batch title \
rewrite is the one operation nobody has asked for and everybody would regret.",
field_names().join(", ")
))
}
pub fn field_names() -> Vec<&'static str> {
PATCH_FIELDS.iter().map(|f| f.name).collect()
}
pub fn clearable_field_names() -> Vec<&'static str> {
PATCH_FIELDS
.iter()
.filter(|f| f.clearable)
.map(|f| f.name)
.collect()
}
pub fn is_supported_field(field: &str) -> bool {
PATCH_FIELDS.iter().any(|f| f.name == field)
}
pub fn check_field(field: &str) -> Result<()> {
if is_supported_field(field) {
Ok(())
} else {
Err(unsupported_field(field))
}
}
pub fn check_operation(operation: &str) -> Result<()> {
match BulkOperation::from_name(operation) {
Some(_) => Ok(()),
None => Err(unsupported(
operation,
"a bulk operation is one of: edit, link, unlink, archive",
)),
}
}
pub fn check_applicable(operation: BulkOperation) -> Result<()> {
if operation.is_applicable() {
Ok(())
} else {
Err(unsupported(
operation.as_str(),
"this build understands the operation but cannot perform it",
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct BulkCapabilities {
pub operations: Vec<OperationCapability>,
pub fields: Vec<FieldCapability>,
pub outcome_statuses: Vec<String>,
pub selection_grammar: Vec<String>,
pub preview_is_read_only: bool,
pub apply_requires_confirmation: bool,
pub progress: Option<ProgressReporting>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ProgressReporting {
pub preview_selection_limit: usize,
pub reports_bounded_progress: bool,
pub fallback: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct OperationCapability {
pub name: String,
pub summary: String,
pub availability: &'static str,
pub requires: Vec<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct FieldCapability {
pub name: String,
pub summary: String,
pub clearable: bool,
}
pub fn capabilities() -> &'static BulkCapabilities {
use std::sync::OnceLock;
static CAPS: OnceLock<BulkCapabilities> = OnceLock::new();
CAPS.get_or_init(|| BulkCapabilities {
operations: BulkOperation::ALL
.iter()
.map(|op| {
let mut requires = Vec::new();
if op.needs_target() {
requires.push("target");
}
if op.needs_kind() {
requires.push("kind");
}
OperationCapability {
name: op.as_str().to_string(),
summary: op.summary().to_string(),
availability: op.availability(),
requires,
}
})
.collect(),
fields: PATCH_FIELDS
.iter()
.map(|f| FieldCapability {
name: f.name.to_string(),
summary: f.summary.to_string(),
clearable: f.clearable,
})
.collect(),
outcome_statuses: OUTCOME_STATUSES.iter().map(|s| s.to_string()).collect(),
selection_grammar: SELECTION_GRAMMAR.iter().map(|s| s.to_string()).collect(),
preview_is_read_only: true,
apply_requires_confirmation: true,
progress: Some(ProgressReporting {
preview_selection_limit: PREVIEW_SELECTION_LIMIT,
reports_bounded_progress: true,
fallback: "A preview over this many issues reports bounded progress \
(every Nth item) rather than going silent, and a surface with no \
progress channel — a one-shot CLI run — reports it as a line \
before the plan and after it."
.to_string(),
}),
})
}
pub fn capabilities_json() -> serde_json::Value {
use serde_json::json;
let caps = capabilities();
json!({
"operations": caps.operations,
"fields": caps.fields,
"outcome_statuses": caps.outcome_statuses,
"selection_grammar": caps.selection_grammar,
"preview_is_read_only": caps.preview_is_read_only,
"apply_requires_confirmation": caps.apply_requires_confirmation,
})
}
pub fn disagreements_with_flags() -> Vec<String> {
let mut problems = Vec::new();
let flag_rows: Vec<&str> = PatchFlags::field_rows()
.iter()
.copied()
.filter(|row| *row != "clear")
.collect();
for name in &flag_rows {
if !is_supported_field(name) {
problems.push(format!(
"PatchFlags carries `{name}`, the whitelist does not"
));
}
}
for name in field_names() {
if !flag_rows.contains(&name) {
problems.push(format!(
"the whitelist carries `{name}`, PatchFlags does not"
));
}
}
let clearable = clearable_field_names();
for name in PatchFlags::clearable() {
if !clearable.contains(name) {
problems.push(format!(
"PatchFlags calls `{name}` clearable, the whitelist does not"
));
}
}
problems
}