use crate::bulk::plan::{BatchPlan, ItemDiagnostic};
use crate::bulk::{IssuePatch, PatchValue};
use crate::error::{Result, TixError};
use crate::model::HistoryEventKind;
use crate::storage::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ItemOutcome {
Applied {
filename: String,
},
NoOp,
Rejected(String),
Failed { item: ItemDiagnostic, error: String },
}
impl ItemOutcome {
pub fn as_line(&self, number: u32, title: &str) -> String {
match self {
Self::Applied { filename } => {
format!("#{number} {title} -> applied ({filename})")
}
Self::NoOp => format!("#{number} {title} -> already in that state"),
Self::Rejected(why) => format!("#{number} {title} -> refused: {why}"),
Self::Failed { item, error } => {
format!("#{number} {title} -> failed ({}): {error}", item.field)
}
}
}
pub fn is_applied(&self) -> bool {
matches!(self, Self::Applied { .. })
}
pub fn status(&self) -> &'static str {
match self {
Self::Applied { .. } => "applied",
Self::NoOp => "no-op",
Self::Rejected(_) => "rejected",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplyReport {
pub outcomes: Vec<(u32, ItemOutcome)>,
pub write_failures: Vec<ItemDiagnostic>,
}
impl ApplyReport {
pub fn applied(&self) -> usize {
self.outcomes.iter().filter(|(_, o)| o.is_applied()).count()
}
pub fn noop(&self) -> usize {
self.outcomes
.iter()
.filter(|(_, o)| matches!(o, ItemOutcome::NoOp))
.count()
}
pub fn rejected(&self) -> usize {
self.outcomes
.iter()
.filter(|(_, o)| matches!(o, ItemOutcome::Rejected(_)))
.count()
}
pub fn failed(&self) -> usize {
self.write_failures.len()
}
pub fn summary(&self) -> String {
let total = self.outcomes.len();
let mut parts = vec![
format!("{} of {total} applied", self.applied()),
format!("{} already in that state", self.noop()),
format!("{} refused", self.rejected()),
];
if self.failed() > 0 {
parts.push(format!("{} write failures", self.failed()));
}
parts.join("; ")
}
}
pub fn apply_fields(
store: &Store,
plan: &BatchPlan,
actor: &str,
override_reason: Option<&str>,
) -> Result<ApplyReport> {
if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
return Err(TixError::InvalidInput(
"a bulk apply needs a confirmation for exactly this plan — preview it \
first, and preview again if the selection or the patch changed"
.to_string(),
));
}
if plan.patch.relationship.is_some() {
return Err(TixError::InvalidInput(
"this plan carries a relationship operation, which WP04 applies — not \
the field path"
.to_string(),
));
}
if plan.patch.archive.is_some() {
return Err(TixError::InvalidInput(
"this plan carries an archive request, which WP05 applies — not the \
field path"
.to_string(),
));
}
let patch = &plan.patch;
let mut outcomes = Vec::with_capacity(plan.items.len());
let mut write_failures = Vec::new();
let wip_decisions: std::collections::BTreeMap<u32, crate::wip::ItemWip> = match &patch.status {
crate::bulk::patch::PatchValue::Set(_) => {
let issues = store.list()?;
let moves: Vec<(u32, String)> = plan
.items
.iter()
.map(|item| (item.number, target_status(patch)))
.collect();
crate::wip::evaluate_batch(
&store.config,
&issues,
&moves,
&std::collections::BTreeMap::default(),
)
.into_iter()
.map(|item| (item.number, item))
.collect()
}
_ => std::collections::BTreeMap::default(),
};
for item in &plan.items {
if let Some(diagnostic) = &item.diagnostic {
outcomes.push((
item.number,
ItemOutcome::Rejected(diagnostic.message.clone()),
));
continue;
}
if let Some(wip) = wip_decisions.get(&item.number)
&& !wip.is_committable()
{
outcomes.push((
item.number,
ItemOutcome::Rejected(crate::wip::render_decision(&wip.decision)),
));
continue;
}
let number = item.number;
match apply_one(store, patch, number, actor, override_reason) {
Ok(Outcome::NoOp) => outcomes.push((item.number, ItemOutcome::NoOp)),
Ok(Outcome::Applied { filename }) => {
outcomes.push((item.number, ItemOutcome::Applied { filename }))
}
Err((diagnostic, error)) => {
write_failures.push(diagnostic.clone());
outcomes.push((
number,
ItemOutcome::Failed {
item: diagnostic,
error: error.to_string(),
},
));
}
}
}
Ok(ApplyReport {
outcomes,
write_failures,
})
}
enum Outcome {
Applied { filename: String },
NoOp,
}
#[allow(clippy::type_complexity)]
fn apply_one(
store: &Store,
patch: &IssuePatch,
number: u32,
actor: &str,
override_reason: Option<&str>,
) -> std::result::Result<Outcome, (ItemDiagnostic, TixError)> {
let mut diagnostic: Option<ItemDiagnostic> = None;
let result = store.transaction(|| {
apply_one_body(store, patch, number, actor, override_reason).map_err(|(d, e)| {
diagnostic = Some(d);
e
})
});
result.map_err(|e| {
(
diagnostic.unwrap_or_else(|| failed_item(number, "write", e.to_string())),
e,
)
})
}
#[allow(clippy::type_complexity)]
fn apply_one_body(
store: &Store,
patch: &IssuePatch,
number: u32,
actor: &str,
override_reason: Option<&str>,
) -> std::result::Result<Outcome, (ItemDiagnostic, TixError)> {
let issue = store
.get(crate::storage::IssueSelector::Number(number))
.map_err(|e| (failed_item(number, "read", e.to_string()), e))?;
let fail = |field: &'static str, e: TixError| (failed_item(number, field, e.to_string()), e);
let merged = crate::bulk::field_apply::merge_issue(&issue, patch);
let changes_status = match (&patch.status, &issue.status) {
(PatchValue::Set(target), current) => current.display() != target,
_ => false,
};
let changes_fields = merged != issue;
let moves_file = crate::bulk::field_apply::moves_segment(&issue, patch);
if !changes_status && !changes_fields && !moves_file {
return Ok(Outcome::NoOp);
}
let mut filename = issue.filename.clone();
if moves_file {
let moved = store
.reassign(&issue.filename, merged.assignee.as_deref())
.map_err(|e| fail("assignee", e))?;
filename = moved.to_string_lossy().to_string();
}
if changes_fields {
let mut written = merged.clone();
written.filename = filename.clone();
if written != issue || filename != issue.filename {
written.bump_updated();
}
filename = store
.update(&filename, &written)
.map_err(|e| fail("write", e))?;
}
if changes_status {
let target = match &patch.status {
PatchValue::Set(t) => t.clone(),
_ => unreachable!("changes_status is only true for a Set"),
};
let outcome = store
.set_status(
&filename,
crate::storage::StatusChange::to(&target).with_override(override_reason),
)
.map_err(|e| fail("status", e))?;
filename = outcome.issue.filename.clone();
let _ = actor;
}
Ok(Outcome::Applied { filename })
}
fn target_status(patch: &IssuePatch) -> String {
match &patch.status {
crate::bulk::patch::PatchValue::Set(target) => target.clone(),
_ => String::new(),
}
}
fn failed_item(number: u32, field: &'static str, message: String) -> ItemDiagnostic {
ItemDiagnostic {
issue: number,
field,
message,
}
}
pub fn history_kind_for(patch: &IssuePatch) -> Option<HistoryEventKind> {
match patch.status {
PatchValue::Set(_) => Some(HistoryEventKind::TransitionAccepted),
_ => None,
}
}
pub fn predicted_history_events(patch: &IssuePatch) -> usize {
usize::from(history_kind_for(patch).is_some())
}
pub fn owns(patch: &IssuePatch) -> bool {
crate::bulk::field_apply::is_field_path(patch)
}
pub use crate::bulk::field_apply::{
FieldOutcome, destination_path, field_outcomes, merge_issue, moves_segment,
};