use anyhow::{Context, bail};
use ownserp_core::{Overrides, Settings};
use crate::cli::{AuditAction, AuditArgs};
use crate::output::emit;
pub(crate) fn run(args: AuditArgs) -> anyhow::Result<()> {
match args.action {
AuditAction::Verify { ledger_file } => {
let given = ledger_file.is_some();
let path = match ledger_file {
Some(path) => path,
None => {
let settings =
Settings::load(Overrides::default()).context("resolving settings")?;
settings.ledger_dir().join("calls.jsonl")
}
};
if !path.exists() {
if !given {
emit(&format!("no ledger yet at {}", path.display()))?;
return Ok(());
}
bail!("no ledger at {}", path.display());
}
let rows = ownserp_core::verify(&path).context("verifying the ledger")?;
emit(&format!("ledger verified: {rows} rows, hash chain intact"))?;
Ok(())
}
AuditAction::Rotate { ledger_file } => {
let path = match ledger_file {
Some(path) => path,
None => {
let settings =
Settings::load(Overrides::default()).context("resolving settings")?;
settings.ledger_dir().join("calls.jsonl")
}
};
let (rows, archive) = ownserp_core::rotate(&path).context("rotating the ledger")?;
emit(&format!("verified {rows} rows, hash chain intact"))?;
emit(&format!("archived to {}", archive.display()))?;
emit(&next_write_message(&path))?;
Ok(())
}
}
}
fn next_write_message(path: &std::path::Path) -> String {
format!("the next write starts a fresh chain at {}", path.display())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{AuditAction, AuditArgs};
use ownserp_core::{CallRecord, Ledger};
fn temp_dir(label: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("ownserp-audit-{}-{label}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
fn record() -> CallRecord {
CallRecord {
tool: "serper_search".to_owned(),
endpoint: "/search".to_owned(),
args_hash: "0".repeat(64),
credits_estimated: 1,
credits_actual: None,
batch_multiplier: 1,
duration_ms: 2,
status: "ok".to_owned(),
response_bytes: 10,
tokens: 2,
tokens_saved: 1,
cache_hit: false,
}
}
#[test]
fn verify_then_rotate_moves_the_file_and_leaves_the_chain_valid() {
let dir = temp_dir("rotate");
let path = dir.join("calls.jsonl");
let _ = std::fs::remove_file(&path);
{
let ledger = Ledger::open(&path).expect("ledger");
ledger.append(record()).expect("append");
}
run(AuditArgs {
action: AuditAction::Verify {
ledger_file: Some(path.clone()),
},
})
.expect("verify");
run(AuditArgs {
action: AuditAction::Rotate {
ledger_file: Some(path.clone()),
},
})
.expect("rotate");
assert!(!path.exists(), "live ledger is archived");
let archived = std::fs::read_dir(&dir)
.expect("dir")
.filter_map(Result::ok)
.any(|entry| entry.file_name() != "calls.jsonl");
assert!(archived, "an archive must exist");
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn the_rotation_message_names_the_next_write() {
let message = next_write_message(std::path::Path::new("/tmp/calls.jsonl"));
assert!(message.contains("next write"));
assert!(!message.contains("next start"));
}
#[test]
fn verifying_a_missing_named_ledger_under_the_data_directory_is_an_error() {
let path = ownserp_core::default_data_dir()
.join("ledger")
.join(format!("ownserp-missing-{}.jsonl", std::process::id()));
let outcome = run(AuditArgs {
action: AuditAction::Verify {
ledger_file: Some(path),
},
});
assert!(outcome.is_err());
}
#[test]
fn verifying_a_missing_named_ledger_is_an_error() {
let path = temp_dir("missing-verify").join("calls.jsonl");
let outcome = run(AuditArgs {
action: AuditAction::Verify {
ledger_file: Some(path),
},
});
assert!(outcome.is_err());
}
#[test]
fn rotating_a_missing_ledger_reports_an_error() {
let path = temp_dir("missing").join("calls.jsonl");
let outcome = run(AuditArgs {
action: AuditAction::Rotate {
ledger_file: Some(path),
},
});
assert!(outcome.is_err());
}
}