use enumset::{EnumSet, EnumSetType};
use std::fmt::{self, Display};
pub trait Apply {
fn apply(
&self,
target: &mut serde_json::Value,
options: EnumSet<ApplyOptions>,
) -> Result<ApplyReport, ApplyError>;
}
#[derive(EnumSetType, Debug)]
pub enum ApplyOptions {
ErrorOnZeroMatch,
ErrorOnMixedKindMatch,
}
#[cfg(feature = "clap")]
impl clap::ValueEnum for ApplyOptions {
fn value_variants<'a>() -> &'a [Self] {
&[
ApplyOptions::ErrorOnZeroMatch,
ApplyOptions::ErrorOnMixedKindMatch,
]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
let (name, help) = match self {
ApplyOptions::ErrorOnZeroMatch => (
"error-on-zero-match",
"Fail when an action's `target` selects zero nodes",
),
ApplyOptions::ErrorOnMixedKindMatch => (
"error-on-mixed-kind-match",
"Fail when `update` selects a mix of objects and arrays",
),
};
Some(clap::builder::PossibleValue::new(name).help(help))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ActionOutcome {
pub index: usize,
pub target: String,
pub operation: Operation,
pub matched: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Operation {
Update,
Remove,
Copy,
}
impl Display for Operation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Operation::Update => "update",
Operation::Remove => "remove",
Operation::Copy => "copy",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct ApplyReport {
pub actions: Vec<ActionOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ApplyError {
pub action_index: usize,
pub target: String,
pub kind: ApplyErrorKind,
}
impl Display for ApplyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"actions[{}] (target {:?}): {}",
self.action_index, self.target, self.kind
)
}
}
impl std::error::Error for ApplyError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApplyErrorKind {
InvalidJsonPath(String),
ZeroMatch,
MixedKindMatch,
PrimitiveActionTarget,
CopySourceNotFound(String),
CopySourceMultiple(String),
ConflictingMergeSources,
}
impl Display for ApplyErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ApplyErrorKind::InvalidJsonPath(msg) => write!(f, "invalid JSONPath: {msg}"),
ApplyErrorKind::ZeroMatch => {
f.write_str("target matched zero nodes (error-on-zero-match)")
}
ApplyErrorKind::MixedKindMatch => f.write_str(
"target matched nodes of mixed kind (objects and arrays) — \
error-on-mixed-kind-match",
),
ApplyErrorKind::PrimitiveActionTarget => f.write_str(
"action `target` must resolve to objects or arrays, \
not primitives or null",
),
ApplyErrorKind::CopySourceNotFound(s) => {
write!(f, "`copy` source {s:?} matched no node")
}
ApplyErrorKind::CopySourceMultiple(s) => write!(
f,
"`copy` source {s:?} matched multiple nodes; exactly one is required",
),
ApplyErrorKind::ConflictingMergeSources => {
f.write_str("action sets both `update` and `copy`; they are mutually exclusive")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operation_display_uses_lowercase_words() {
assert_eq!(Operation::Update.to_string(), "update");
assert_eq!(Operation::Remove.to_string(), "remove");
assert_eq!(Operation::Copy.to_string(), "copy");
}
#[test]
fn apply_error_display_includes_index_target_and_reason() {
let e = ApplyError {
action_index: 2,
target: "$.foo".into(),
kind: ApplyErrorKind::ZeroMatch,
};
let s = e.to_string();
assert!(s.contains("actions[2]"));
assert!(s.contains("$.foo"));
assert!(s.contains("zero nodes"));
}
#[test]
fn apply_error_kind_display_covers_every_variant() {
let cases = [
ApplyErrorKind::InvalidJsonPath("bad path".into()),
ApplyErrorKind::ZeroMatch,
ApplyErrorKind::MixedKindMatch,
ApplyErrorKind::PrimitiveActionTarget,
ApplyErrorKind::CopySourceNotFound("$.src".into()),
ApplyErrorKind::CopySourceMultiple("$.src".into()),
ApplyErrorKind::ConflictingMergeSources,
];
for k in cases {
assert!(
!k.to_string().is_empty(),
"Display impl for {k:?} produced empty string",
);
}
}
}
#[cfg(all(test, feature = "clap"))]
mod clap_tests {
use super::*;
use clap::ValueEnum;
#[test]
fn apply_options_value_enum_round_trips_through_kebab_case() {
for v in <ApplyOptions as ValueEnum>::value_variants() {
let pv = v.to_possible_value().expect("possible value");
let name = pv.get_name();
let parsed = <ApplyOptions as ValueEnum>::from_str(name, false).expect("parses");
assert_eq!(parsed, *v);
assert!(
name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'),
"name `{name}` must be kebab-case",
);
}
}
}