use std::fs;
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{Map, Value};
use crate::time::now_iso8601;
#[derive(Debug, Clone, Serialize)]
pub struct Envelope {
pub capturer: String,
pub version: String,
pub captured_at: String,
pub args: Value,
pub result: Value,
}
impl Envelope {
pub fn new(capturer: &str, version: &str, args: Value, result: Value) -> Self {
Self {
capturer: capturer.to_string(),
version: version.to_string(),
captured_at: now_iso8601(),
args: canonicalize_value(args),
result: canonicalize_value(result),
}
}
pub fn to_canonical_json(&self) -> Result<String> {
let v = serde_json::to_value(self).context("serialize envelope")?;
let v = canonicalize_value(v);
serde_json::to_string_pretty(&v).context("pretty-print envelope")
}
pub fn write_to(&self, path: &Path) -> Result<()> {
let body = self.to_canonical_json()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create dir {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
{
let mut f =
fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
f.write_all(body.as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
f.write_all(b"\n").ok();
f.sync_all().ok();
}
fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
}
pub fn canonicalize_value(v: Value) -> Value {
match v {
Value::Object(map) => {
let mut entries: Vec<(String, Value)> = map.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = Map::with_capacity(entries.len());
for (k, v) in entries {
out.insert(k, canonicalize_value(v));
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize_value).collect()),
other => other,
}
}
pub fn sort_array_by_key(arr: &mut [Value], key: &str) {
arr.sort_by(|a, b| {
let ak = extract_sort_key(a, key);
let bk = extract_sort_key(b, key);
ak.cmp(&bk).then_with(|| {
let ja = serde_json::to_string(a).unwrap_or_default();
let jb = serde_json::to_string(b).unwrap_or_default();
ja.cmp(&jb)
})
});
}
fn extract_sort_key(v: &Value, key: &str) -> SortKey {
match v.get(key) {
Some(Value::String(s)) => SortKey::Present(s.clone()),
Some(Value::Number(n)) => {
if let Some(i) = n.as_i64() {
SortKey::Present(format!("{:020}", i))
} else {
SortKey::Present(n.to_string())
}
}
Some(Value::Bool(b)) => SortKey::Present(b.to_string()),
Some(Value::Null) | None => SortKey::Missing,
Some(other) => SortKey::Present(other.to_string()),
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
enum SortKey {
Present(String),
Missing,
}
pub fn strip_keys(value: &mut Value, keys: &[&str]) {
match value {
Value::Object(map) => {
for k in keys {
map.remove(*k);
}
for (_, v) in map.iter_mut() {
strip_keys(v, keys);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
strip_keys(v, keys);
}
}
_ => {}
}
}
pub fn strip_keys_matching(value: &mut Value, predicate: impl Fn(&str) -> bool + Copy) {
match value {
Value::Object(map) => {
let to_remove: Vec<String> = map
.keys()
.filter(|k| predicate(k.as_str()))
.cloned()
.collect();
for k in to_remove {
map.remove(&k);
}
for (_, v) in map.iter_mut() {
strip_keys_matching(v, predicate);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
strip_keys_matching(v, predicate);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn canonicalize_sorts_keys() {
let v = json!({ "z": 1, "a": 2, "m": { "y": 3, "b": 4 } });
let s = serde_json::to_string(&canonicalize_value(v)).unwrap();
assert_eq!(s, r#"{"a":2,"m":{"b":4,"y":3},"z":1}"#);
}
#[test]
fn sort_array_by_key_sorts_by_id() {
let mut arr = vec![
json!({ "id": "c", "v": 1 }),
json!({ "id": "a", "v": 2 }),
json!({ "id": "b", "v": 3 }),
];
sort_array_by_key(&mut arr, "id");
let ids: Vec<&str> = arr.iter().map(|v| v["id"].as_str().unwrap()).collect();
assert_eq!(ids, vec!["a", "b", "c"]);
}
#[test]
fn sort_array_by_numeric_key() {
let mut arr = vec![
json!({ "number": 10 }),
json!({ "number": 2 }),
json!({ "number": 100 }),
];
sort_array_by_key(&mut arr, "number");
let nums: Vec<i64> = arr.iter().map(|v| v["number"].as_i64().unwrap()).collect();
assert_eq!(nums, vec![2, 10, 100]);
}
#[test]
fn strip_keys_removes_ephemeral() {
let mut v = json!({
"id": 1,
"url": "https://x",
"nested": { "node_id": "abc", "keep": true },
});
strip_keys(&mut v, &["url", "node_id"]);
assert_eq!(v, json!({ "id": 1, "nested": { "keep": true } }));
}
#[test]
fn strip_keys_matching_suffix() {
let mut v = json!({
"id": 1,
"html_url": "x",
"issues_url": "y",
"name": "ok",
});
strip_keys_matching(&mut v, |k| k.ends_with("_url"));
assert_eq!(v, json!({ "id": 1, "name": "ok" }));
}
#[test]
fn envelope_is_byte_stable() {
let _g = crate::time::set_fixed_time_for_tests("2026-05-01T00:00:00Z");
let e1 = Envelope::new(
"test.thing",
"1",
json!({ "z": 1, "a": 2 }),
json!({ "items": [{"id": "b"}, {"id": "a"}] }),
);
let e2 = Envelope::new(
"test.thing",
"1",
json!({ "a": 2, "z": 1 }),
json!({ "items": [{"id": "b"}, {"id": "a"}] }),
);
assert_eq!(
e1.to_canonical_json().unwrap(),
e2.to_canonical_json().unwrap()
);
}
}