use crate::bulk::plan::PairPlan;
use crate::bulk::{IssuePatch, RelationshipOp};
use crate::error::{Result, TixError};
use crate::relationships::{CanonicalEdge, RelationKind};
use crate::storage::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairResolution {
pub edge: Option<CanonicalEdge>,
pub requested: RequestedPair,
pub from_number: Option<crate::id::IssueRef>,
pub to_number: Option<crate::id::IssueRef>,
pub diagnostic: Option<PairDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestedPair {
pub link: bool,
pub from: String,
pub to: String,
pub kind: RelationKind,
}
impl From<&RelationshipOp> for RequestedPair {
fn from(op: &RelationshipOp) -> Self {
Self {
link: op.link,
from: op.from.clone(),
to: op.to.clone(),
kind: op.kind,
}
}
}
impl RequestedPair {
pub fn verb(&self) -> &'static str {
if self.link { "link" } else { "unlink" }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairProblem {
Missing,
Ambiguous,
SelfLink,
Refused,
}
impl PairProblem {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing_target",
Self::Ambiguous => "ambiguous_target",
Self::SelfLink => "self_link",
Self::Refused => "refused",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairDiagnostic {
pub problem: PairProblem,
pub endpoint: &'static str,
pub message: String,
}
impl std::fmt::Display for PairDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.endpoint.is_empty() {
write!(f, "{}: {}", self.problem.as_str(), self.message)
} else {
write!(
f,
"{} on {}: {}",
self.problem.as_str(),
self.endpoint,
self.message
)
}
}
}
pub fn resolve_pair(store: &Store, op: &RelationshipOp) -> PairResolution {
let requested = RequestedPair::from(op);
let resolve = |raw: &str,
endpoint: &'static str|
-> (Option<crate::id::IssueRef>, Option<PairDiagnostic>) {
match crate::commands::parse_selector(store, raw) {
Err(e) => {
let problem = match &e {
TixError::InvalidInput(m)
if m.starts_with(crate::commands::AMBIGUITY_PREFIX) =>
{
PairProblem::Ambiguous
}
_ => PairProblem::Missing,
};
(
None,
Some(PairDiagnostic {
problem,
endpoint,
message: e.to_string(),
}),
)
}
Ok(selector) => {
match store.get(selector) {
Ok(issue) => (Some(crate::id::canonical_ref(&issue)), None),
Err(e) => (
None,
Some(PairDiagnostic {
problem: PairProblem::Missing,
endpoint,
message: e.to_string(),
}),
),
}
}
}
};
let (from, from_problem) = resolve(&op.from, "from");
let (to, to_problem) = resolve(&op.to, "to");
let problem = from_problem.or(to_problem);
if let Some(diagnostic) = problem {
return PairResolution {
edge: None,
from_number: from,
to_number: to,
requested,
diagnostic: Some(diagnostic),
};
}
let (from, to) = (from.expect("checked"), to.expect("checked"));
if from == to {
return PairResolution {
edge: None,
from_number: Some(from),
to_number: Some(to),
requested,
diagnostic: Some(PairDiagnostic {
problem: PairProblem::SelfLink,
endpoint: "",
message: format!(
"#{} {} itself — a relationship needs two issues",
from,
op.kind.as_str()
),
}),
};
}
let edge = CanonicalEdge::new(from, to, op.kind);
PairResolution {
edge: Some(edge),
from_number: Some(from),
to_number: Some(to),
requested,
diagnostic: None,
}
}
pub fn plan_for(store: &Store, op: &RelationshipOp) -> PairPlan {
let resolution = resolve_pair(store, op);
let end = |number: Option<crate::id::IssueRef>,
endpoint: &str,
diagnostic: &Option<PairDiagnostic>| match (
number,
diagnostic.as_ref().map(|d| d.endpoint),
) {
(Some(n), _) => Ok(n),
(None, Some(failed)) if failed == endpoint => {
Err(diagnostic.as_ref().expect("checked").message.clone())
}
_ => Err(String::new()),
};
match &resolution.diagnostic {
Some(diagnostic) => PairPlan::refused(
op.link,
end(resolution.from_number, "from", &resolution.diagnostic),
end(resolution.to_number, "to", &resolution.diagnostic),
diagnostic.clone(),
),
None => PairPlan::resolved(op.link, resolution.edge.expect("checked")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PairOutcome {
Applied {
from: crate::id::IssueRef,
to: crate::id::IssueRef,
identity: String,
files: Vec<String>,
},
NoOp { identity: String },
Refused(PairDiagnostic),
}
impl PairOutcome {
pub fn as_line(&self) -> String {
match self {
Self::Applied {
from,
to,
identity,
files,
} => format!(
"{} {} -> {} ({identity}); wrote {}",
if files.len() == 1 {
"linked"
} else {
"unlinked"
},
from.human(),
to.human(),
files.join(", ")
),
Self::NoOp { identity } => {
format!("{identity} was already in that state; nothing written")
}
Self::Refused(diagnostic) => format!("refused: {diagnostic}"),
}
}
pub fn status(&self) -> &'static str {
match self {
Self::Applied { .. } => "applied",
Self::NoOp { .. } => "no-op",
Self::Refused(_) => "rejected",
}
}
pub fn files(&self) -> &[String] {
match self {
Self::Applied { files, .. } => files,
_ => &[],
}
}
pub fn is_applied(&self) -> bool {
matches!(self, Self::Applied { .. })
}
}
pub fn apply_pair(store: &Store, plan: &crate::bulk::BatchPlan) -> Result<PairOutcome> {
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(),
));
}
let op = plan
.patch
.relationship
.as_ref()
.ok_or_else(|| TixError::InvalidInput("this plan carries no relationship".into()))?;
if !owns(&plan.patch) {
return Err(TixError::InvalidInput(
"this plan mixes a relationship with field changes, which have no \
agreed order — preview them as two plans"
.to_string(),
));
}
let resolution = resolve_pair(store, op);
if let Some(diagnostic) = resolution.diagnostic {
return Ok(PairOutcome::Refused(diagnostic));
}
let edge = resolution.edge.expect("checked");
let outcome = store.transaction(|| {
if op.link {
store.link(&op.from, &op.to, edge.kind)
} else {
store.unlink(&op.from, &op.to, edge.kind)
}
});
let outcome = match outcome {
Ok(outcome) => outcome,
Err(e) => {
return Ok(PairOutcome::Refused(PairDiagnostic {
problem: PairProblem::Refused,
endpoint: "",
message: e.to_string(),
}));
}
};
let identity = outcome.relationship.id.clone();
if !outcome.changed {
return Ok(PairOutcome::NoOp { identity });
}
let files = outcome
.written
.as_ref()
.map(|i| vec![i.filename.clone()])
.unwrap_or_default();
Ok(PairOutcome::Applied {
from: outcome.relationship.from,
to: outcome.relationship.to,
identity,
files,
})
}
pub fn summary(outcome: &PairOutcome) -> String {
match outcome {
PairOutcome::Applied { .. } => "1 relationship applied".to_string(),
PairOutcome::NoOp { .. } => "1 relationship already in that state".to_string(),
PairOutcome::Refused(d) => format!("1 relationship refused: {d}"),
}
}
pub fn owns(patch: &IssuePatch) -> bool {
crate::bulk::field_apply::is_pair_path(patch)
}