use std::path::Path;
use std::time::Duration;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use super::auth::acquire_service_auth;
use super::error::CliError;
use super::output::truncate;
use super::pds::PdsClient;
use super::session::SessionFile;
const LIST_AUDIT_LOG_LXM: &str = "tools.cairn.admin.listAuditLog";
const GET_AUDIT_LOG_LXM: &str = "tools.cairn.admin.getAuditLog";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuditEntry {
pub id: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
pub action: String,
#[serde(rename = "actorDid")]
pub actor_did: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub target: Option<String>,
#[serde(rename = "targetCid", skip_serializing_if = "Option::is_none", default)]
pub target_cid: Option<String>,
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub reason: Option<String>,
#[serde(rename = "prevHash", skip_serializing_if = "Option::is_none", default)]
pub prev_hash: Option<String>,
#[serde(rename = "rowHash", skip_serializing_if = "Option::is_none", default)]
pub row_hash: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AuditListResponse {
pub entries: Vec<AuditEntry>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AuditListInput {
pub actor: Option<String>,
pub action: Option<String>,
pub outcome: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub limit: Option<i64>,
pub cursor: Option<String>,
pub cairn_server_override: Option<String>,
}
pub async fn list(
session: &mut SessionFile,
session_path: &Path,
input: AuditListInput,
) -> Result<AuditListResponse, CliError> {
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.trim_end_matches('/')
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, LIST_AUDIT_LOG_LXM).await?;
let url = format!("{cairn_server}/xrpc/{LIST_AUDIT_LOG_LXM}");
let limit_owned = input.limit.map(|n| n.to_string());
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(a) = &input.actor {
query.push(("actor", a.as_str()));
}
if let Some(a) = &input.action {
query.push(("action", a.as_str()));
}
if let Some(o) = &input.outcome {
query.push(("outcome", o.as_str()));
}
if let Some(s) = &input.since {
query.push(("since", s.as_str()));
}
if let Some(u) = &input.until {
query.push(("until", u.as_str()));
}
if let Some(n) = &limit_owned {
query.push(("limit", n.as_str()));
}
if let Some(c) = &input.cursor {
query.push(("cursor", c.as_str()));
}
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest build");
let resp = client
.get(&url)
.bearer_auth(&token)
.query(&query)
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::CairnStatus { url, status, body });
}
let bytes = resp.bytes().await.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
serde_json::from_slice::<AuditListResponse>(&bytes)
.map_err(|source| CliError::MalformedResponse { url, source })
}
pub fn format_list_human(resp: &AuditListResponse) -> String {
use std::fmt::Write;
if resp.entries.is_empty() {
let mut s = "(no audit entries)".to_string();
if let Some(c) = &resp.cursor {
let _ = write!(s, "\nnext cursor: {c}");
}
return s;
}
let id_w = resp
.entries
.iter()
.map(|e| e.id.to_string().len())
.max()
.unwrap_or(2)
.max(2);
let action_w = resp
.entries
.iter()
.map(|e| e.action.len().min(28))
.max()
.unwrap_or(6)
.max(6);
let actor_w = resp
.entries
.iter()
.map(|e| e.actor_did.len().min(40))
.max()
.unwrap_or(8)
.max(8);
let mut s = String::new();
let _ = writeln!(
s,
"{:>id_w$} {:<24} {:<action_w$} {:<actor_w$} {:<7}",
"ID",
"CREATED_AT",
"ACTION",
"ACTOR_DID",
"OUTCOME",
id_w = id_w,
action_w = action_w,
actor_w = actor_w,
);
for e in &resp.entries {
let action = truncate(&e.action, 28);
let actor = truncate(&e.actor_did, 40);
let _ = writeln!(
s,
"{:>id_w$} {:<24} {:<action_w$} {:<actor_w$} {:<7}",
e.id,
e.created_at,
action,
actor,
e.outcome,
id_w = id_w,
action_w = action_w,
actor_w = actor_w,
);
}
if let Some(c) = &resp.cursor {
let _ = write!(s, "next cursor: {c}");
} else if s.ends_with('\n') {
s.pop();
}
s
}
pub fn format_list_json(resp: &AuditListResponse) -> String {
serde_json::to_string_pretty(resp).expect("AuditListResponse serializes")
}
#[derive(Debug, Clone)]
pub struct AuditShowInput {
pub id: i64,
pub cairn_server_override: Option<String>,
}
pub async fn show(
session: &mut SessionFile,
session_path: &Path,
input: AuditShowInput,
) -> Result<AuditEntry, CliError> {
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.trim_end_matches('/')
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, GET_AUDIT_LOG_LXM).await?;
let url = format!("{cairn_server}/xrpc/{GET_AUDIT_LOG_LXM}");
let id_str = input.id.to_string();
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest build");
let resp = client
.get(&url)
.bearer_auth(&token)
.query(&[("id", id_str.as_str())])
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::CairnStatus { url, status, body });
}
let bytes = resp.bytes().await.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
serde_json::from_slice::<AuditEntry>(&bytes)
.map_err(|source| CliError::MalformedResponse { url, source })
}
pub fn format_show_human(entry: &AuditEntry) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "Audit entry {}", entry.id);
let _ = writeln!(s, " created_at: {}", entry.created_at);
let _ = writeln!(s, " action: {}", entry.action);
let _ = writeln!(s, " actor_did: {}", entry.actor_did);
let _ = writeln!(s, " outcome: {}", entry.outcome);
if let Some(t) = &entry.target {
let _ = writeln!(s, " target: {t}");
}
if let Some(c) = &entry.target_cid {
let _ = writeln!(s, " target_cid: {c}");
}
if let Some(r) = &entry.reason {
let _ = writeln!(s, " reason: {r}");
}
let _ = writeln!(
s,
" prev_hash: {}",
entry
.prev_hash
.as_deref()
.unwrap_or(PRE_ATTESTATION_DISPLAY)
);
let _ = write!(
s,
" row_hash: {}",
entry.row_hash.as_deref().unwrap_or(PRE_ATTESTATION_DISPLAY)
);
s
}
const PRE_ATTESTATION_DISPLAY: &str = "(pre-attestation)";
pub fn format_show_json(entry: &AuditEntry) -> String {
serde_json::to_string_pretty(entry).expect("AuditEntry serializes")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(reason: Option<&str>) -> AuditEntry {
AuditEntry {
id: 42,
created_at: "2026-04-23T00:00:00.000Z".into(),
action: "label_applied".into(),
actor_did: "did:plc:moderator0000000000000000".into(),
target: Some("at://did:plc:target/col/rec".into()),
target_cid: Some("bafytest".into()),
outcome: "success".into(),
reason: reason.map(str::to_string),
prev_hash: Some("a".repeat(64)),
row_hash: Some("b".repeat(64)),
}
}
#[test]
fn format_show_human_includes_all_present_fields() {
let s = format_show_human(&sample(Some(r#"{"val":"spam"}"#)));
assert!(s.contains("Audit entry 42"));
assert!(s.contains("created_at: 2026-04-23T00:00:00.000Z"));
assert!(s.contains("action: label_applied"));
assert!(s.contains("actor_did: did:plc:moderator0000000000000000"));
assert!(s.contains("outcome: success"));
assert!(s.contains("target: at://did:plc:target/col/rec"));
assert!(s.contains("target_cid: bafytest"));
assert!(s.contains(r#"reason: {"val":"spam"}"#));
assert!(s.contains(&format!("prev_hash: {}", "a".repeat(64))));
assert!(s.contains(&format!("row_hash: {}", "b".repeat(64))));
}
#[test]
fn format_show_human_omits_absent_optionals() {
let mut e = sample(None);
e.target = None;
e.target_cid = None;
let s = format_show_human(&e);
assert!(!s.contains("target:"));
assert!(!s.contains("target_cid:"));
assert!(!s.contains("reason:"));
}
#[test]
fn format_show_human_pre_attestation_row_renders_sentinel() {
let mut e = sample(None);
e.prev_hash = None;
e.row_hash = None;
let s = format_show_human(&e);
assert!(
s.contains("prev_hash: (pre-attestation)"),
"missing pre-attestation sentinel for prev_hash: {s}"
);
assert!(
s.contains("row_hash: (pre-attestation)"),
"missing pre-attestation sentinel for row_hash: {s}"
);
}
#[test]
fn format_show_human_genesis_row_renders_zero_sentinel_verbatim() {
let mut e = sample(None);
e.id = 1;
e.prev_hash = Some("0".repeat(64));
e.row_hash = Some("c".repeat(64));
let s = format_show_human(&e);
assert!(s.contains("Audit entry 1"));
assert!(s.contains(&format!("prev_hash: {}", "0".repeat(64))));
assert!(s.contains(&format!("row_hash: {}", "c".repeat(64))));
assert!(
!s.contains("genesis"),
"genesis row must not be special-cased in display: {s}"
);
}
#[test]
fn format_show_json_round_trips_with_hashes() {
let e = sample(Some(r#"{"val":"spam","neg":false}"#));
let json = format_show_json(&e);
let parsed: AuditEntry = serde_json::from_str(&json).expect("round trip");
assert_eq!(parsed.id, 42);
assert_eq!(parsed.action, "label_applied");
assert_eq!(
parsed.target.as_deref(),
Some("at://did:plc:target/col/rec")
);
assert_eq!(
parsed.reason.as_deref(),
Some(r#"{"val":"spam","neg":false}"#)
);
assert_eq!(parsed.prev_hash.as_deref(), Some("a".repeat(64).as_str()));
assert_eq!(parsed.row_hash.as_deref(), Some("b".repeat(64).as_str()));
assert!(
json.contains("\"prevHash\""),
"wire JSON must use camelCase prevHash: {json}"
);
assert!(
json.contains("\"rowHash\""),
"wire JSON must use camelCase rowHash: {json}"
);
}
#[test]
fn format_show_json_pre_attestation_row_omits_hash_fields() {
let mut e = sample(None);
e.prev_hash = None;
e.row_hash = None;
let json = format_show_json(&e);
assert!(
!json.contains("prevHash"),
"pre-attestation: prevHash must be field-absent: {json}"
);
assert!(
!json.contains("rowHash"),
"pre-attestation: rowHash must be field-absent: {json}"
);
}
}