use crate::config::Config;
use crate::model::{HistoryEventKind, Issue};
use crate::workflow::{self, TransitionOutcome};
use super::aggregate::{ColumnUsage, LimitState, WipSnapshot};
use super::config::{ColumnLimit, Estimate, LimitPolicy, WipDiagnostic, WipDiagnosticKind};
use super::r#override::OverrideRequest;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WipOutcome {
Allow,
Warn,
Block,
Override,
Rejected,
}
impl WipOutcome {
pub const fn as_str(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Warn => "warn",
Self::Block => "block",
Self::Override => "override",
Self::Rejected => "rejected",
}
}
pub const fn is_committable(self) -> bool {
matches!(self, Self::Allow | Self::Warn | Self::Override)
}
pub const fn requires_override(self) -> bool {
matches!(self, Self::Block)
}
}
pub struct MoveContext<'a> {
pub config: &'a Config,
pub issues: &'a [Issue],
}
impl<'a> MoveContext<'a> {
pub const fn new(config: &'a Config, issues: &'a [Issue]) -> Self {
Self { config, issues }
}
}
pub struct MoveRequest<'a> {
pub issue: &'a Issue,
pub to: &'a str,
pub transition_override: Option<&'a str>,
pub over: Option<&'a OverrideRequest>,
}
impl<'a> MoveRequest<'a> {
pub const fn new(issue: &'a Issue, to: &'a str) -> Self {
Self {
issue,
to,
transition_override: None,
over: None,
}
}
pub const fn because(mut self, reason: &'a str) -> Self {
self.transition_override = Some(reason);
self
}
pub const fn overriding(mut self, over: &'a OverrideRequest) -> Self {
self.over = Some(over);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryIntent {
pub kind: HistoryEventKind,
pub actor: String,
pub timestamp: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipDecision {
pub outcome: WipOutcome,
pub from: String,
pub to: String,
pub limit: Option<ColumnLimit>,
pub state_before: LimitState,
pub state_after: LimitState,
pub usage: Option<ColumnUsage>,
pub diagnostics: Vec<WipDiagnostic>,
pub history: Option<HistoryIntent>,
}
impl WipDecision {
pub const fn requires_override(&self) -> bool {
self.outcome.requires_override()
}
pub const fn as_str(&self) -> &'static str {
self.outcome.as_str()
}
pub fn bound_limit(&self) -> Option<super::r#override::BoundLimit> {
self.limit
.as_ref()
.map(|limit| super::r#override::BoundLimit::of(&self.to, limit))
}
pub const fn is_committable(&self) -> bool {
self.outcome.is_committable()
}
}
#[allow(clippy::too_many_lines)]
pub fn evaluate(context: &MoveContext<'_>, request: &MoveRequest<'_>) -> WipDecision {
let from = request.issue.status.display().to_string();
let to = request.to.to_string();
let allow = |state: LimitState, diagnostics| WipDecision {
outcome: WipOutcome::Allow,
from: from.clone(),
to: to.clone(),
limit: None,
state_before: state,
state_after: state,
usage: None,
diagnostics,
history: None,
};
let (_, resolution) = workflow::active_resolution(context.config);
match workflow::validate_transition(
request.issue,
request.to,
&resolution,
context.config,
request.transition_override,
) {
TransitionOutcome::Rejected(diagnostic) => {
return WipDecision {
outcome: WipOutcome::Rejected,
from,
to,
limit: None,
state_before: LimitState::Within,
state_after: LimitState::Within,
usage: None,
diagnostics: vec![WipDiagnostic::new(
WipDiagnosticKind::RejectedTransition,
diagnostic.message,
)],
history: None,
};
}
TransitionOutcome::Accepted { changed: false, .. } => {
return allow(LimitState::Within, Vec::new());
}
TransitionOutcome::Accepted { .. } => {}
}
let snapshot = WipSnapshot::build(context.config.default_config(), context.issues);
let Some(usage) = snapshot.column(&to).cloned() else {
return allow(LimitState::Within, Vec::new());
};
let Some(limit) = usage.limit.clone() else {
return allow(LimitState::Within, Vec::new());
};
let unit = usage.unit().unwrap_or_default().to_string();
let mut diagnostics = Vec::new();
let add_estimate = match request
.issue
.estimate
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
{
None => 0,
Some(raw) => match Estimate::parse_terms(raw).and_then(|terms| {
context
.config
.default_config()
.wip
.convert_terms(&terms, &unit)
}) {
Ok(value) => value,
Err(error) => {
let first = error.first();
diagnostics.push(
WipDiagnostic::new(
first.kind,
format!(
"issue #{} states `{raw}`, which this board cannot count in \
`{unit}`: {}",
request.issue.number, first.message
),
)
.because(
"the move is refused rather than counted as zero: an uncountable \
estimate must not pass a limit silently (C-003)",
),
);
return WipDecision {
outcome: WipOutcome::Rejected,
from,
to,
limit: Some(limit),
state_before: usage.state(),
state_after: LimitState::Unknown,
usage: Some(usage),
diagnostics,
history: None,
};
}
},
};
let state_before = usage.state();
let state_after = usage.prospective(1, add_estimate);
if state_after == LimitState::Unknown {
diagnostics.push(
WipDiagnostic::new(
WipDiagnosticKind::AmbiguousConversion,
format!(
"`{to}` has an estimate ceiling of {} that cannot be compared against its \
current contents",
limit.max_estimate.unwrap_or_default()
),
)
.because("fix the uncountable estimate first; the move is refused meanwhile"),
);
return WipDecision {
outcome: WipOutcome::Rejected,
from,
to,
limit: Some(limit),
state_before,
state_after,
usage: Some(usage),
diagnostics,
history: None,
};
}
if state_after == LimitState::Within {
return WipDecision {
outcome: WipOutcome::Allow,
from,
to,
limit: Some(limit),
state_before,
state_after,
usage: Some(usage),
diagnostics,
history: None,
};
}
let policy = limit.policy.unwrap_or(LimitPolicy::Warn);
let mut decision = WipDecision {
outcome: match policy {
LimitPolicy::Warn => WipOutcome::Warn,
LimitPolicy::Block => WipOutcome::Block,
},
from: from.clone(),
to: to.clone(),
limit: Some(limit.clone()),
state_before,
state_after,
usage: Some(usage.clone()),
diagnostics: vec![WipDiagnostic::new(
WipDiagnosticKind::OverLimit,
format!(
"`{to}` would hold {} against a limit of {} ({})",
describe_after(&usage, add_estimate),
describe_limit(&limit),
policy.as_str()
),
)],
history: None,
};
if policy == LimitPolicy::Warn {
return decision;
}
let Some(over) = request.over else {
decision.diagnostics.push(
WipDiagnostic::new(
WipDiagnosticKind::OverrideRequired,
format!("`{to}` is configured to block; an authorized override is required"),
)
.because("nothing was changed — supply a confirmed override to proceed"),
);
return decision;
};
let findings = over.findings(&to, &limit);
if !findings.is_empty() {
decision.outcome = WipOutcome::Rejected;
decision.diagnostics = findings;
return decision;
}
decision.outcome = WipOutcome::Override;
decision.history = Some(HistoryIntent {
kind: HistoryEventKind::TransitionAccepted,
actor: over.actor.clone(),
timestamp: over.timestamp.clone(),
detail: format!(
"wip override: `{from}` -> `{to}` against {}; {} -> {}: {}",
super::r#override::BoundLimit::of(&to, &limit).describe(),
usage.cell(),
describe_after(&usage, add_estimate),
over.reason.trim()
),
});
decision
}
fn describe_after(usage: &ColumnUsage, add_estimate: u32) -> String {
let items = usage.items.saturating_add(1);
match usage.max_estimate() {
Some(max) => format!(
"{items} cards, {}/{}",
usage
.estimate
.unwrap_or_default()
.saturating_add(add_estimate),
max
),
None => format!("{items} cards"),
}
}
fn describe_limit(limit: &ColumnLimit) -> String {
let mut parts = Vec::new();
if let Some(max) = limit.max_items {
parts.push(format!("max_items {max}"));
}
if let Some(max) = limit.max_estimate {
match limit.unit.as_deref() {
Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
None => parts.push(format!("max_estimate {max}")),
}
}
parts.join(", ")
}