1use std::fs;
28use std::io::Write;
29use std::path::Path;
30
31use anyhow::{Context, Result};
32use serde::Serialize;
33use serde_json::{Map, Value};
34
35use crate::time::now_iso8601;
36
37#[derive(Debug, Clone, Serialize)]
39pub struct Envelope {
40 pub capturer: String,
41 pub version: String,
42 pub captured_at: String,
43 pub args: Value,
44 pub result: Value,
45}
46
47impl Envelope {
48 pub fn new(capturer: &str, version: &str, args: Value, result: Value) -> Self {
52 Self {
53 capturer: capturer.to_string(),
54 version: version.to_string(),
55 captured_at: now_iso8601(),
56 args: canonicalize_value(args),
57 result: canonicalize_value(result),
58 }
59 }
60
61 pub fn to_canonical_json(&self) -> Result<String> {
63 let v = serde_json::to_value(self).context("serialize envelope")?;
64 let v = canonicalize_value(v);
65 serde_json::to_string_pretty(&v).context("pretty-print envelope")
66 }
67
68 pub fn write_to(&self, path: &Path) -> Result<()> {
71 let body = self.to_canonical_json()?;
72 if let Some(parent) = path.parent() {
73 fs::create_dir_all(parent)
74 .with_context(|| format!("create dir {}", parent.display()))?;
75 }
76 let tmp = path.with_extension("json.tmp");
77 {
78 let mut f =
79 fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
80 f.write_all(body.as_bytes())
81 .with_context(|| format!("write {}", tmp.display()))?;
82 f.write_all(b"\n").ok();
83 f.sync_all().ok();
84 }
85 fs::rename(&tmp, path)
86 .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
87 Ok(())
88 }
89}
90
91pub fn canonicalize_value(v: Value) -> Value {
96 match v {
97 Value::Object(map) => {
98 let mut entries: Vec<(String, Value)> = map.into_iter().collect();
99 entries.sort_by(|a, b| a.0.cmp(&b.0));
100 let mut out = Map::with_capacity(entries.len());
101 for (k, v) in entries {
102 out.insert(k, canonicalize_value(v));
103 }
104 Value::Object(out)
105 }
106 Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize_value).collect()),
107 other => other,
108 }
109}
110
111pub fn sort_array_by_key(arr: &mut [Value], key: &str) {
116 arr.sort_by(|a, b| {
117 let ak = extract_sort_key(a, key);
118 let bk = extract_sort_key(b, key);
119 ak.cmp(&bk).then_with(|| {
120 let ja = serde_json::to_string(a).unwrap_or_default();
121 let jb = serde_json::to_string(b).unwrap_or_default();
122 ja.cmp(&jb)
123 })
124 });
125}
126
127fn extract_sort_key(v: &Value, key: &str) -> SortKey {
128 match v.get(key) {
129 Some(Value::String(s)) => SortKey::Present(s.clone()),
130 Some(Value::Number(n)) => {
131 if let Some(i) = n.as_i64() {
134 SortKey::Present(format!("{:020}", i))
135 } else {
136 SortKey::Present(n.to_string())
137 }
138 }
139 Some(Value::Bool(b)) => SortKey::Present(b.to_string()),
140 Some(Value::Null) | None => SortKey::Missing,
141 Some(other) => SortKey::Present(other.to_string()),
142 }
143}
144
145#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
146enum SortKey {
147 Present(String),
148 Missing,
149}
150
151pub fn strip_keys(value: &mut Value, keys: &[&str]) {
155 match value {
156 Value::Object(map) => {
157 for k in keys {
158 map.remove(*k);
159 }
160 for (_, v) in map.iter_mut() {
161 strip_keys(v, keys);
162 }
163 }
164 Value::Array(arr) => {
165 for v in arr.iter_mut() {
166 strip_keys(v, keys);
167 }
168 }
169 _ => {}
170 }
171}
172
173pub fn strip_keys_matching(value: &mut Value, predicate: impl Fn(&str) -> bool + Copy) {
175 match value {
176 Value::Object(map) => {
177 let to_remove: Vec<String> = map
178 .keys()
179 .filter(|k| predicate(k.as_str()))
180 .cloned()
181 .collect();
182 for k in to_remove {
183 map.remove(&k);
184 }
185 for (_, v) in map.iter_mut() {
186 strip_keys_matching(v, predicate);
187 }
188 }
189 Value::Array(arr) => {
190 for v in arr.iter_mut() {
191 strip_keys_matching(v, predicate);
192 }
193 }
194 _ => {}
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use serde_json::json;
202
203 #[test]
204 fn canonicalize_sorts_keys() {
205 let v = json!({ "z": 1, "a": 2, "m": { "y": 3, "b": 4 } });
206 let s = serde_json::to_string(&canonicalize_value(v)).unwrap();
207 assert_eq!(s, r#"{"a":2,"m":{"b":4,"y":3},"z":1}"#);
208 }
209
210 #[test]
211 fn sort_array_by_key_sorts_by_id() {
212 let mut arr = vec![
213 json!({ "id": "c", "v": 1 }),
214 json!({ "id": "a", "v": 2 }),
215 json!({ "id": "b", "v": 3 }),
216 ];
217 sort_array_by_key(&mut arr, "id");
218 let ids: Vec<&str> = arr.iter().map(|v| v["id"].as_str().unwrap()).collect();
219 assert_eq!(ids, vec!["a", "b", "c"]);
220 }
221
222 #[test]
223 fn sort_array_by_numeric_key() {
224 let mut arr = vec![
225 json!({ "number": 10 }),
226 json!({ "number": 2 }),
227 json!({ "number": 100 }),
228 ];
229 sort_array_by_key(&mut arr, "number");
230 let nums: Vec<i64> = arr.iter().map(|v| v["number"].as_i64().unwrap()).collect();
231 assert_eq!(nums, vec![2, 10, 100]);
232 }
233
234 #[test]
235 fn strip_keys_removes_ephemeral() {
236 let mut v = json!({
237 "id": 1,
238 "url": "https://x",
239 "nested": { "node_id": "abc", "keep": true },
240 });
241 strip_keys(&mut v, &["url", "node_id"]);
242 assert_eq!(v, json!({ "id": 1, "nested": { "keep": true } }));
243 }
244
245 #[test]
246 fn strip_keys_matching_suffix() {
247 let mut v = json!({
248 "id": 1,
249 "html_url": "x",
250 "issues_url": "y",
251 "name": "ok",
252 });
253 strip_keys_matching(&mut v, |k| k.ends_with("_url"));
254 assert_eq!(v, json!({ "id": 1, "name": "ok" }));
255 }
256
257 #[test]
258 fn envelope_is_byte_stable() {
259 let _g = crate::time::set_fixed_time_for_tests("2026-05-01T00:00:00Z");
260 let e1 = Envelope::new(
261 "test.thing",
262 "1",
263 json!({ "z": 1, "a": 2 }),
264 json!({ "items": [{"id": "b"}, {"id": "a"}] }),
265 );
266 let e2 = Envelope::new(
267 "test.thing",
268 "1",
269 json!({ "a": 2, "z": 1 }),
270 json!({ "items": [{"id": "b"}, {"id": "a"}] }),
271 );
272 assert_eq!(
273 e1.to_canonical_json().unwrap(),
274 e2.to_canonical_json().unwrap()
275 );
276 }
277}