use vta_sdk::prelude::*;
use vta_sdk::protocols::audit_management::list::{AuditEnvelope, ListAuditLogsBody};
use crate::render::{BOLD, CYAN, DIM, RESET, is_json_output, print_json};
pub const MEMORY_ACTIONS: [&str; 4] = ["memory.put", "memory.delete", "memory.list", "memory.get"];
const PAGE: u64 = 200;
#[derive(Debug, Default, Clone)]
pub struct HistoryFilter {
pub actor: Option<String>,
pub from: Option<chrono::DateTime<chrono::Utc>>,
pub to: Option<chrono::DateTime<chrono::Utc>>,
pub limit: usize,
}
pub async fn cmd_memory_history(
client: &VtaClient,
context: &str,
filter: &HistoryFilter,
) -> Result<(), Box<dyn std::error::Error>> {
let mut rows = Vec::new();
for action in MEMORY_ACTIONS {
let mut cursor = None;
let mut fetched = 0usize;
loop {
let page = client
.list_audit_logs(&ListAuditLogsBody {
from: filter.from,
to: filter.to,
action: Some(action.to_string()),
actor: filter.actor.clone(),
outcome: None,
context_id: Some(context.to_string()),
cursor,
limit: Some(PAGE),
})
.await?;
fetched += page.entries.len();
rows.extend(page.entries);
match page.next_cursor {
Some(next) if fetched < filter.limit => cursor = Some(next),
_ => break,
}
}
}
let rows = newest_first(rows, filter.limit);
if is_json_output() {
print_json(&rows)?;
return Ok(());
}
if rows.is_empty() {
println!("No memory activity recorded for context '{context}'.");
return Ok(());
}
println!();
println!("{BOLD}Memory activity in context '{context}'{RESET} {DIM}(newest first){RESET}");
println!();
for r in &rows {
println!(
" {DIM}{}{RESET} {CYAN}{:<13}{RESET} {} {}",
r.recorded_at,
r.action,
r.actor.as_deref().unwrap_or("(erased)"),
summary(r),
);
}
println!();
println!(
"{DIM}Items are named by commitment, never by key (VTI-AUD-005); the same item \
carries the same commitment.{RESET}"
);
Ok(())
}
fn newest_first(mut rows: Vec<AuditEnvelope>, limit: usize) -> Vec<AuditEnvelope> {
rows.sort_by(|a, b| {
b.recorded_at
.cmp(&a.recorded_at)
.then_with(|| b.event_id.cmp(&a.event_id))
});
rows.truncate(limit);
rows
}
fn summary(r: &AuditEnvelope) -> String {
let detail = r
.detail
.get("reason")
.and_then(|d| d.as_str())
.unwrap_or("");
let items = detail
.split_whitespace()
.find_map(|w| w.strip_prefix("items="));
match (r.action.as_str(), items) {
("memory.list" | "memory.get", Some(n)) => format!("read {n} item(s)"),
(_, _) => match r.target.as_deref() {
Some(t) => format!("item {}", short(t)),
None => String::new(),
},
}
}
fn short(reference: &str) -> String {
match reference.rsplit_once(':') {
Some((_, hex)) if reference.starts_with("hmac-sha256:") => {
format!("#{}", &hex[..hex.len().min(12)])
}
_ => reference.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn row(at: &str, action: &str, target: Option<&str>, detail: Option<&str>) -> AuditEnvelope {
let mut d = serde_json::Map::new();
if let Some(x) = detail {
d.insert("reason".into(), json!(x));
}
AuditEnvelope {
event_id: format!("{at}-{action}"),
recorded_at: at.into(),
action: action.into(),
outcome: Some("success".into()),
actor: Some("did:key:zAgent".into()),
target: target.map(str::to_string),
context_id: Some("ctx".into()),
detail: d,
}
}
#[test]
fn rows_from_every_action_merge_newest_first_and_stop_at_the_limit() {
let rows = newest_first(
vec![
row("2026-11-09T10:00:00Z", "memory.put", None, None),
row("2026-11-09T12:00:00Z", "memory.get", None, None),
row("2026-11-09T11:00:00Z", "memory.list", None, None),
],
2,
);
assert_eq!(
rows.iter().map(|r| r.action.as_str()).collect::<Vec<_>>(),
["memory.get", "memory.list"]
);
}
#[test]
fn a_read_says_how_many_items_and_a_write_which_item_by_commitment() {
let get = row(
"t",
"memory.get",
None,
Some("items=2 refs=hmac-sha256:k:aaaa,hmac-sha256:k:bbbb"),
);
assert_eq!(summary(&get), "read 2 item(s)");
let put = row(
"t",
"memory.put",
Some(
"hmac-sha256:3f0e1c2a-0000-4000-8000-000000000000:0123456789abcdef0123456789abcdef",
),
None,
);
assert_eq!(summary(&put), "item #0123456789ab");
}
}