1use enumset::{EnumSet, EnumSetType};
8use std::fmt::{self, Display};
9
10pub trait Apply {
15 fn apply(
16 &self,
17 target: &mut serde_json::Value,
18 options: EnumSet<ApplyOptions>,
19 ) -> Result<ApplyReport, ApplyError>;
20}
21
22#[derive(EnumSetType, Debug)]
24pub enum ApplyOptions {
25 ErrorOnZeroMatch,
30 ErrorOnMixedKindMatch,
35}
36
37#[cfg(feature = "clap")]
38impl clap::ValueEnum for ApplyOptions {
39 fn value_variants<'a>() -> &'a [Self] {
40 &[
41 ApplyOptions::ErrorOnZeroMatch,
42 ApplyOptions::ErrorOnMixedKindMatch,
43 ]
44 }
45
46 fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
47 let (name, help) = match self {
48 ApplyOptions::ErrorOnZeroMatch => (
49 "error-on-zero-match",
50 "Fail when an action's `target` selects zero nodes",
51 ),
52 ApplyOptions::ErrorOnMixedKindMatch => (
53 "error-on-mixed-kind-match",
54 "Fail when `update` selects a mix of objects and arrays",
55 ),
56 };
57 Some(clap::builder::PossibleValue::new(name).help(help))
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub struct ActionOutcome {
65 pub index: usize,
66 pub target: String,
67 pub operation: Operation,
68 pub matched: usize,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum Operation {
74 Update,
75 Remove,
76 Copy,
79}
80
81impl Display for Operation {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 f.write_str(match self {
84 Operation::Update => "update",
85 Operation::Remove => "remove",
86 Operation::Copy => "copy",
87 })
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Default)]
93#[non_exhaustive]
94pub struct ApplyReport {
95 pub actions: Vec<ActionOutcome>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub struct ApplyError {
103 pub action_index: usize,
104 pub target: String,
105 pub kind: ApplyErrorKind,
106}
107
108impl Display for ApplyError {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 write!(
111 f,
112 "actions[{}] (target {:?}): {}",
113 self.action_index, self.target, self.kind
114 )
115 }
116}
117
118impl std::error::Error for ApplyError {}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121#[non_exhaustive]
122pub enum ApplyErrorKind {
123 InvalidJsonPath(String),
125 ZeroMatch,
127 MixedKindMatch,
130 PrimitiveActionTarget,
135 CopySourceNotFound(String),
138 CopySourceMultiple(String),
141 ConflictingMergeSources,
145}
146
147impl Display for ApplyErrorKind {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 ApplyErrorKind::InvalidJsonPath(msg) => write!(f, "invalid JSONPath: {msg}"),
151 ApplyErrorKind::ZeroMatch => {
152 f.write_str("target matched zero nodes (error-on-zero-match)")
153 }
154 ApplyErrorKind::MixedKindMatch => f.write_str(
155 "target matched nodes of mixed kind (objects and arrays) — \
156 error-on-mixed-kind-match",
157 ),
158 ApplyErrorKind::PrimitiveActionTarget => f.write_str(
159 "action `target` must resolve to objects or arrays, \
160 not primitives or null",
161 ),
162 ApplyErrorKind::CopySourceNotFound(s) => {
163 write!(f, "`copy` source {s:?} matched no node")
164 }
165 ApplyErrorKind::CopySourceMultiple(s) => write!(
166 f,
167 "`copy` source {s:?} matched multiple nodes; exactly one is required",
168 ),
169 ApplyErrorKind::ConflictingMergeSources => {
170 f.write_str("action sets both `update` and `copy`; they are mutually exclusive")
171 }
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn operation_display_uses_lowercase_words() {
182 assert_eq!(Operation::Update.to_string(), "update");
183 assert_eq!(Operation::Remove.to_string(), "remove");
184 assert_eq!(Operation::Copy.to_string(), "copy");
185 }
186
187 #[test]
188 fn apply_error_display_includes_index_target_and_reason() {
189 let e = ApplyError {
190 action_index: 2,
191 target: "$.foo".into(),
192 kind: ApplyErrorKind::ZeroMatch,
193 };
194 let s = e.to_string();
195 assert!(s.contains("actions[2]"));
196 assert!(s.contains("$.foo"));
197 assert!(s.contains("zero nodes"));
198 }
199
200 #[test]
201 fn apply_error_kind_display_covers_every_variant() {
202 let cases = [
203 ApplyErrorKind::InvalidJsonPath("bad path".into()),
204 ApplyErrorKind::ZeroMatch,
205 ApplyErrorKind::MixedKindMatch,
206 ApplyErrorKind::PrimitiveActionTarget,
207 ApplyErrorKind::CopySourceNotFound("$.src".into()),
208 ApplyErrorKind::CopySourceMultiple("$.src".into()),
209 ApplyErrorKind::ConflictingMergeSources,
210 ];
211 for k in cases {
212 assert!(
213 !k.to_string().is_empty(),
214 "Display impl for {k:?} produced empty string",
215 );
216 }
217 }
218}
219
220#[cfg(all(test, feature = "clap"))]
221mod clap_tests {
222 use super::*;
223 use clap::ValueEnum;
224
225 #[test]
226 fn apply_options_value_enum_round_trips_through_kebab_case() {
227 for v in <ApplyOptions as ValueEnum>::value_variants() {
228 let pv = v.to_possible_value().expect("possible value");
229 let name = pv.get_name();
230 let parsed = <ApplyOptions as ValueEnum>::from_str(name, false).expect("parses");
231 assert_eq!(parsed, *v);
232 assert!(
233 name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'),
234 "name `{name}` must be kebab-case",
235 );
236 }
237 }
238}