1use std::collections::BTreeSet;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DeltaField {
31 pub key: String,
32 pub old: String,
33 pub new: String,
34}
35
36#[derive(Debug, Clone, Copy)]
38pub struct NearRefConfig {
39 pub max_delta_bytes: usize,
41 pub min_body_bytes: usize,
45}
46
47impl Default for NearRefConfig {
48 fn default() -> Self {
49 Self {
50 max_delta_bytes: 50,
56 min_body_bytes: 500,
57 }
58 }
59}
60
61pub fn extract_delta(
65 old_body: &str,
66 new_body: &str,
67 config: &NearRefConfig,
68) -> Option<Vec<DeltaField>> {
69 if new_body.len() < config.min_body_bytes {
70 return None;
71 }
72 let old: serde_json::Value = serde_json::from_str(old_body.trim_start()).ok()?;
73 let new: serde_json::Value = serde_json::from_str(new_body.trim_start()).ok()?;
74 let old_obj = old.as_object()?;
75 let new_obj = new.as_object()?;
76
77 let old_keys: BTreeSet<&str> = old_obj.keys().map(|s| s.as_str()).collect();
80 let new_keys: BTreeSet<&str> = new_obj.keys().map(|s| s.as_str()).collect();
81 if old_keys != new_keys {
82 return None;
83 }
84
85 let mut deltas = Vec::new();
86 for (k, new_val) in new_obj {
87 let old_val = match old_obj.get(k) {
88 Some(v) => v,
89 None => unreachable!("key sets are equal"),
90 };
91 if old_val == new_val {
92 continue;
93 }
94 if !is_scalar(old_val) || !is_scalar(new_val) {
98 return None;
99 }
100 deltas.push(DeltaField {
101 key: k.clone(),
102 old: scalar_to_string(old_val),
103 new: scalar_to_string(new_val),
104 });
105 }
106
107 if rendered_delta_bytes(&deltas) > config.max_delta_bytes {
112 return None;
113 }
114
115 if deltas.is_empty() {
119 return None;
120 }
121 Some(deltas)
122}
123
124fn rendered_delta_bytes(deltas: &[DeltaField]) -> usize {
129 let mut frag = String::new();
130 for d in deltas {
131 frag.push_str(", ");
132 frag.push_str(&d.key);
133 frag.push_str(": ");
134 frag.push_str(&d.old);
135 frag.push('→');
136 frag.push_str(&d.new);
137 }
138 frag.len()
139}
140
141fn is_scalar(v: &serde_json::Value) -> bool {
142 matches!(
143 v,
144 serde_json::Value::Null
145 | serde_json::Value::Bool(_)
146 | serde_json::Value::Number(_)
147 | serde_json::Value::String(_)
148 )
149}
150
151fn scalar_to_string(v: &serde_json::Value) -> String {
152 match v {
153 serde_json::Value::Null => String::new(),
154 serde_json::Value::Bool(b) => b.to_string(),
155 serde_json::Value::Number(n) => n.to_string(),
156 serde_json::Value::String(s) => s.clone(),
157 _ => v.to_string(),
158 }
159}
160
161pub fn render_near_ref_hint(reference_id: &str, deltas: &[DeltaField]) -> String {
165 let mut out = String::new();
166 out.push_str("> [near-ref: ");
167 out.push_str(reference_id);
168 for d in deltas {
169 out.push_str(", ");
170 out.push_str(&d.key);
171 out.push_str(": ");
172 out.push_str(&d.old);
173 out.push('→');
174 out.push_str(&d.new);
175 }
176 out.push(']');
177 out
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn cfg() -> NearRefConfig {
185 NearRefConfig::default()
186 }
187
188 fn long_pipeline_body(status: &str, duration: u64) -> String {
189 format!(
191 r#"{{"id":42,"name":"{}","status":"{}","duration":{},"url":"https://example.com/pipelines/42","commit_sha":"deadbeefdeadbeefdeadbeefdeadbeef","ref":"refs/heads/main","author":"alice","triggered_by":"webhook","preview":"{}"}}"#,
192 "p".repeat(20),
193 status,
194 duration,
195 "x".repeat(400)
196 )
197 }
198
199 #[test]
200 fn extracts_status_and_duration_delta_for_pipeline_polling() {
201 let a = long_pipeline_body("pending", 12);
202 let b = long_pipeline_body("success", 34);
203 let deltas = extract_delta(&a, &b, &cfg()).unwrap();
204 assert_eq!(deltas.len(), 2);
205 let keys: BTreeSet<_> = deltas.iter().map(|d| d.key.as_str()).collect();
206 assert!(keys.contains("status"));
207 assert!(keys.contains("duration"));
208 }
209
210 #[test]
211 fn refuses_when_too_short() {
212 let a = r#"{"a":1,"b":2}"#;
214 let b = r#"{"a":1,"b":3}"#;
215 assert!(extract_delta(a, b, &cfg()).is_none());
216 }
217
218 #[test]
219 fn refuses_when_keys_differ() {
220 let a = format!(r#"{{"a":1,"long":"{}"}}"#, "x".repeat(600));
221 let b = format!(r#"{{"a":1,"different":"{}"}}"#, "x".repeat(600));
222 assert!(extract_delta(&a, &b, &cfg()).is_none());
223 }
224
225 #[test]
226 fn refuses_when_nested_value_changes() {
227 let a = format!(r#"{{"meta":{{"k":1}},"pad":"{}"}}"#, "x".repeat(600));
228 let b = format!(r#"{{"meta":{{"k":2}},"pad":"{}"}}"#, "x".repeat(600));
229 assert!(extract_delta(&a, &b, &cfg()).is_none());
230 }
231
232 #[test]
233 fn refuses_when_delta_blob_too_large() {
234 let a = format!(
236 r#"{{"x":"{}","pad":"{}"}}"#,
237 "a".repeat(80),
238 "p".repeat(600)
239 );
240 let b = format!(
241 r#"{{"x":"{}","pad":"{}"}}"#,
242 "b".repeat(80),
243 "p".repeat(600)
244 );
245 assert!(extract_delta(&a, &b, &cfg()).is_none());
246 }
247
248 #[test]
249 fn returns_none_for_byte_identical_inputs() {
250 let a = long_pipeline_body("ok", 10);
253 assert!(extract_delta(&a, &a, &cfg()).is_none());
254 }
255
256 #[test]
257 fn render_format_matches_paper_spec() {
258 let deltas = vec![
259 DeltaField {
260 key: "status".into(),
261 old: "pending".into(),
262 new: "success".into(),
263 },
264 DeltaField {
265 key: "duration".into(),
266 old: "12".into(),
267 new: "34".into(),
268 },
269 ];
270 let s = render_near_ref_hint("tc_42", &deltas);
271 assert_eq!(
272 s,
273 "> [near-ref: tc_42, status: pending→success, duration: 12→34]"
274 );
275 }
276
277 #[test]
278 fn hint_size_under_paper_budget() {
279 let deltas = vec![
283 DeltaField {
284 key: "status".into(),
285 old: "pending".into(),
286 new: "success".into(),
287 },
288 DeltaField {
289 key: "duration".into(),
290 old: "12".into(),
291 new: "34".into(),
292 },
293 ];
294 let s = render_near_ref_hint("tc_42", &deltas);
295 assert!(s.len() <= 70, "near-ref hint too long: {} bytes", s.len());
296 }
297}