use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::surface_audit::Surface;
use crate::error::CorpFinanceError;
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
pub struct ToolCallRecord {
pub tool_name: String,
pub input_hash: String,
pub output_hash: String,
pub ts: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
pub struct AuditManifest {
pub surface_audit_hash: String,
pub surface: Surface,
pub surface_event_id: String,
pub output_path: PathBuf,
pub output_sha256: String,
pub ts: DateTime<Utc>,
#[serde(default)]
pub tool_calls: Vec<ToolCallRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
pub fn output_path_to_audit_path(output: &Path) -> PathBuf {
let mut audit = output.as_os_str().to_owned();
audit.push(".audit.json");
PathBuf::from(audit)
}
pub fn write_audit_manifest(
output_path: &Path,
manifest: &AuditManifest,
) -> CorpFinanceResult<PathBuf> {
let audit_path = output_path_to_audit_path(output_path);
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| CorpFinanceError::SerializationError(e.to_string()))?;
fs::write(&audit_path, json).map_err(|e| io_to_cfe(&audit_path, e))?;
Ok(audit_path)
}
pub fn read_audit_manifest(audit_path: &Path) -> CorpFinanceResult<AuditManifest> {
let bytes = fs::read(audit_path).map_err(|e| io_to_cfe(audit_path, e))?;
let manifest: AuditManifest = serde_json::from_slice(&bytes)
.map_err(|e| CorpFinanceError::SerializationError(e.to_string()))?;
Ok(manifest)
}
fn io_to_cfe(path: &Path, e: io::Error) -> CorpFinanceError {
CorpFinanceError::SerializationError(format!("audit manifest io error at {path:?}: {e}"))
}
pub fn sha256_file(path: &Path) -> CorpFinanceResult<String> {
use sha2::{Digest, Sha256};
let bytes = fs::read(path).map_err(|e| io_to_cfe(path, e))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
let mut hex = String::with_capacity(64);
for b in digest.iter() {
hex.push_str(&format!("{b:02x}"));
}
Ok(hex)
}
pub fn validate_tool_call_ledger(ledger: &[ToolCallRecord]) -> CorpFinanceResult<()> {
for window in ledger.windows(2) {
if window[1].ts < window[0].ts {
return Err(CorpFinanceError::InvalidInput {
field: "tool_calls".to_string(),
reason: "ledger ts not monotonic non-decreasing".to_string(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::surface_audit::{compute_surface_audit_hash, SurfaceManifest};
use serde_json::json;
use std::env;
fn tmp_output(name: &str, contents: &[u8]) -> PathBuf {
let dir = env::temp_dir().join(format!("cfa-audit-test-{}", uuid::Uuid::now_v7()));
fs::create_dir_all(&dir).expect("create tmp dir");
let path = dir.join(name);
fs::write(&path, contents).expect("write tmp output");
path
}
fn sample_manifest_for(output_path: &Path, sha256: String) -> AuditManifest {
let surface_manifest = SurfaceManifest {
surface: Surface::Cli,
surface_event_id: "workflow.audit".to_string(),
command_args: json!({ "ticker": "AAPL" }),
output_paths: vec![output_path.display().to_string()],
};
let surface_hash = compute_surface_audit_hash(&surface_manifest);
AuditManifest {
surface_audit_hash: surface_hash,
surface: Surface::Cli,
surface_event_id: "workflow.audit".to_string(),
output_path: output_path.to_path_buf(),
output_sha256: sha256,
ts: Utc::now(),
tool_calls: vec![
ToolCallRecord {
tool_name: "fmp_quote".to_string(),
input_hash: "djb2:0x10aa10aa".to_string(),
output_hash: "djb2:0x55bc55bc".to_string(),
ts: Utc::now(),
},
ToolCallRecord {
tool_name: "dcf_model".to_string(),
input_hash: "djb2:0x44e144e1".to_string(),
output_hash: "djb2:0x90829082".to_string(),
ts: Utc::now(),
},
],
tenant_id: None,
}
}
#[test]
fn ruf_aud_001_audit_manifest_for_every_output() {
let output = tmp_output("coverage_report.md", b"## DCF\nFair value: $150\n");
let sha = sha256_file(&output).expect("sha256");
let manifest = sample_manifest_for(&output, sha);
let written = write_audit_manifest(&output, &manifest).expect("write manifest");
assert!(written.exists(), "audit manifest must exist after write");
assert_eq!(written, output_path_to_audit_path(&output));
}
#[test]
fn ruf_aud_002_audit_manifest_required_fields() {
let output = tmp_output("dcf_model.csv", b"ticker,fair_value\nAAPL,150\n");
let sha = sha256_file(&output).expect("sha256");
let manifest = sample_manifest_for(&output, sha.clone());
let written = write_audit_manifest(&output, &manifest).expect("write");
let loaded = read_audit_manifest(&written).expect("read");
assert!(!loaded.surface_audit_hash.is_empty());
assert_eq!(loaded.surface, Surface::Cli);
assert_eq!(loaded.surface_event_id, "workflow.audit");
assert_eq!(loaded.output_path, output);
assert_eq!(loaded.output_sha256, sha);
assert!(loaded.ts.timestamp() > 0);
let recomputed = sha256_file(&output).expect("sha256 recompute");
assert_eq!(
loaded.output_sha256, recomputed,
"output_sha256 must equal sha256(output_path)"
);
}
#[test]
fn ruf_aud_004_tool_call_ledger_ordered() {
let output = tmp_output("ledger.csv", b"x\n");
let sha = sha256_file(&output).expect("sha");
let mut manifest = sample_manifest_for(&output, sha);
validate_tool_call_ledger(&manifest.tool_calls).expect("canonical ledger valid");
let earlier_ts = manifest.tool_calls[0].ts - chrono::Duration::seconds(60);
manifest.tool_calls.push(ToolCallRecord {
tool_name: "out_of_order".to_string(),
input_hash: "djb2:0xaaaaaaaa".to_string(),
output_hash: "djb2:0xbbbbbbbb".to_string(),
ts: earlier_ts,
});
let res = validate_tool_call_ledger(&manifest.tool_calls);
assert!(res.is_err(), "non-monotonic ledger must be rejected");
}
#[test]
fn ruf_aud_005_round_trip_preserves_correlation_fields() {
let output = tmp_output("rt.csv", b"row1\n");
let sha = sha256_file(&output).expect("sha");
let manifest = sample_manifest_for(&output, sha);
let written = write_audit_manifest(&output, &manifest).expect("write");
let loaded = read_audit_manifest(&written).expect("read");
assert_eq!(loaded.surface_audit_hash, manifest.surface_audit_hash);
assert_eq!(loaded.surface_event_id, manifest.surface_event_id);
assert_eq!(loaded.tool_calls.len(), manifest.tool_calls.len());
for (a, b) in loaded.tool_calls.iter().zip(manifest.tool_calls.iter()) {
assert_eq!(a.tool_name, b.tool_name);
assert_eq!(a.input_hash, b.input_hash);
assert_eq!(a.output_hash, b.output_hash);
}
}
#[test]
fn ruf_aud_inv_001_audit_path_derivation() {
let cases = [
(
"out/coverage_report.md",
"out/coverage_report.md.audit.json",
),
("out/dcf_model.csv", "out/dcf_model.csv.audit.json"),
("a.txt", "a.txt.audit.json"),
("/abs/path/x.json", "/abs/path/x.json.audit.json"),
];
for (input, expected) in &cases {
let got = output_path_to_audit_path(Path::new(input));
assert_eq!(got, PathBuf::from(*expected), "mismatch for input {input}");
}
}
}