use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
pub fn write_and_manifest(results_envelope: &Value, path: &Path) -> anyhow::Result<Value> {
let results_arr = results_envelope
.get("results")
.and_then(Value::as_array)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let mut jsonl_bytes: Vec<u8> = Vec::new();
for row in results_arr {
let line =
serde_json::to_vec(row).map_err(|e| anyhow::anyhow!("serialize result row: {e}"))?;
jsonl_bytes.extend_from_slice(&line);
jsonl_bytes.push(b'\n');
}
write_atomic(path, &jsonl_bytes)?;
let rows = results_arr.len();
let mut null_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut seen_fields: BTreeSet<String> = BTreeSet::new();
for row in results_arr {
if let Some(Value::Object(obj)) = row.get("result") {
for (key, val) in obj {
seen_fields.insert(key.clone());
if val.is_null() {
*null_counts.entry(key.clone()).or_insert(0) += 1;
}
}
}
}
let schema_input = seen_fields.iter().cloned().collect::<Vec<_>>().join("|");
let schema_fingerprint = hex_sha256(schema_input.as_bytes());
let checksum = hex_sha256(&jsonl_bytes);
let abs_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let null_counts_val: Value = serde_json::to_value(&null_counts).unwrap_or_else(|_| json!({}));
Ok(json!({
"path": abs_path.to_string_lossy(),
"rows": rows,
"per_column_null_counts": null_counts_val,
"schema_fingerprint": schema_fingerprint,
"checksum": checksum,
}))
}
fn hex_sha256(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
format!("{:x}", h.finalize())
}
fn write_atomic(path: &Path, data: &[u8]) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("create parent dir {}: {e}", parent.display()))?;
}
}
let tmp_ext = match path.extension() {
Some(e) => format!("{}.tmp", e.to_string_lossy()),
None => "tmp".to_string(),
};
let tmp_path = path.with_extension(tmp_ext);
if std::fs::write(&tmp_path, data).is_ok() {
if std::fs::rename(&tmp_path, path).is_ok() {
return Ok(());
}
let _ = std::fs::remove_file(&tmp_path);
}
std::fs::write(path, data).map_err(|e| anyhow::anyhow!("write file {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn make_envelope(results: Vec<Value>) -> Value {
let total = results.len();
let succeeded = results
.iter()
.filter(|r| r.get("ok").and_then(Value::as_bool) == Some(true))
.count();
let failed = total - succeeded;
json!({
"results": results,
"summary": { "total": total, "succeeded": succeeded, "failed": failed, "aborted": 0 }
})
}
#[test]
fn writes_jsonl_and_manifest_fields_correct() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("out.jsonl");
let envelope = make_envelope(vec![
json!({ "ok": true, "tool": "stats", "result": { "entities": 5, "notes": null } }),
json!({ "ok": true, "tool": "list", "result": { "entities": 3, "notes": 2 } }),
]);
let manifest = write_and_manifest(&envelope, &path).unwrap();
assert!(path.exists());
assert_eq!(manifest["rows"], json!(2));
assert_eq!(manifest["per_column_null_counts"]["notes"], json!(1));
assert!(manifest["per_column_null_counts"]["entities"].is_null());
let fp = manifest["schema_fingerprint"].as_str().unwrap();
assert!(!fp.is_empty());
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
let ck = manifest["checksum"].as_str().unwrap();
assert!(!ck.is_empty());
assert!(ck.chars().all(|c| c.is_ascii_hexdigit()));
let file_bytes = std::fs::read(&path).unwrap();
let expected_ck = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&file_bytes);
format!("{:x}", h.finalize())
};
assert_eq!(ck, expected_ck);
let content = String::from_utf8(file_bytes).unwrap();
let lines: Vec<_> = content.lines().collect();
assert_eq!(lines.len(), 2);
for line in &lines {
serde_json::from_str::<Value>(line).expect("valid JSON line");
}
}
#[test]
fn empty_results_produces_valid_manifest() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("empty.jsonl");
let envelope = make_envelope(vec![]);
let manifest = write_and_manifest(&envelope, &path).unwrap();
assert_eq!(manifest["rows"], json!(0));
assert!(path.exists());
assert_eq!(std::fs::read(&path).unwrap(), b"");
}
#[test]
fn checksum_stable_across_calls() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("stable.jsonl");
let envelope = make_envelope(vec![
json!({ "ok": true, "tool": "get", "result": { "id": "abc", "name": "foo" } }),
]);
let m1 = write_and_manifest(&envelope, &path).unwrap();
let m2 = write_and_manifest(&envelope, &path).unwrap();
assert_eq!(m1["checksum"], m2["checksum"]);
assert_eq!(m1["schema_fingerprint"], m2["schema_fingerprint"]);
}
#[test]
fn extensionless_target_produces_clean_tmp_path() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("outfile");
let envelope = make_envelope(vec![
json!({ "ok": true, "tool": "stats", "result": { "n": 1 } }),
]);
write_and_manifest(&envelope, &path).unwrap();
assert!(path.exists());
let double_dot = tmp.path().join("outfile..tmp");
assert!(
!double_dot.exists(),
"double-dot tmp path must not be created"
);
}
#[test]
fn schema_fingerprint_differs_for_different_schemas() {
let tmp = TempDir::new().unwrap();
let p1 = tmp.path().join("a.jsonl");
let p2 = tmp.path().join("b.jsonl");
let e1 = make_envelope(vec![
json!({ "ok": true, "tool": "t", "result": { "foo": 1 } }),
]);
let e2 = make_envelope(vec![
json!({ "ok": true, "tool": "t", "result": { "bar": 1 } }),
]);
let m1 = write_and_manifest(&e1, &p1).unwrap();
let m2 = write_and_manifest(&e2, &p2).unwrap();
assert_ne!(m1["schema_fingerprint"], m2["schema_fingerprint"]);
}
}