use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use serde::Serialize;
use serde_json::json;
use zad::error::{Result, ZadError};
use zad::permissions::signing;
use zad::service::{DryRunOp, DryRunSink};
#[derive(Debug, Clone, Serialize)]
pub struct EchoReason {
pub kind: &'static str,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
pub fn from_signing_error(err: &ZadError) -> Option<EchoReason> {
match err {
ZadError::NotTrusted { path, .. } => Some(EchoReason {
kind: "not_trusted",
reason: err.to_string(),
path: Some(path.display().to_string()),
}),
ZadError::SignatureInvalid { path, .. } => Some(EchoReason {
kind: "signature_invalid",
reason: err.to_string(),
path: Some(path.display().to_string()),
}),
ZadError::SignatureKeyMismatch { path, .. } => Some(EchoReason {
kind: "signature_key_mismatch",
reason: err.to_string(),
path: Some(path.display().to_string()),
}),
ZadError::TrustStoreTampered { path, .. } => Some(EchoReason {
kind: "trust_store_tampered",
reason: err.to_string(),
path: Some(path.display().to_string()),
}),
ZadError::SigningKeyMissing { .. } => Some(EchoReason {
kind: "signing_key_missing",
reason: err.to_string(),
path: None,
}),
_ => None,
}
}
static ECHO_STATE: OnceLock<Mutex<Option<EchoReason>>> = OnceLock::new();
static ECHO_SINK: OnceLock<Arc<EchoSink>> = OnceLock::new();
static ECHOED: AtomicBool = AtomicBool::new(false);
fn state() -> &'static Mutex<Option<EchoReason>> {
ECHO_STATE.get_or_init(|| Mutex::new(None))
}
fn shared_sink() -> &'static Arc<EchoSink> {
ECHO_SINK.get_or_init(|| {
Arc::new(EchoSink {
buf: Mutex::new(Vec::new()),
})
})
}
pub struct EchoSink {
buf: Mutex<Vec<DryRunOp>>,
}
impl DryRunSink for EchoSink {
fn record(&self, op: DryRunOp) {
self.buf.lock().expect("echo sink poisoned").push(op);
}
}
pub fn arm(reason: EchoReason) {
*state().lock().expect("echo state poisoned") = Some(reason);
}
pub fn load_effective_or_echo<P, F>(loader: F) -> Result<P>
where
P: Default,
F: FnOnce() -> Result<P>,
{
match loader() {
Ok(p) => Ok(p),
Err(e) if signing::is_signing_error(&e) => {
if let Some(reason) = from_signing_error(&e) {
arm(reason);
}
Ok(P::default())
}
Err(e) => Err(e),
}
}
pub fn echo_active() -> bool {
state().lock().expect("echo state poisoned").is_some()
}
pub fn dry_run_sink_for_echo() -> Arc<dyn DryRunSink> {
shared_sink().clone()
}
pub fn mark_echoed() {
ECHOED.store(true, Ordering::SeqCst);
}
pub fn was_echoed() -> bool {
ECHOED.load(Ordering::SeqCst)
}
#[doc(hidden)]
pub fn reset_for_test() {
*state().lock().expect("echo state poisoned") = None;
shared_sink()
.buf
.lock()
.expect("echo sink poisoned")
.clear();
ECHOED.store(false, Ordering::SeqCst);
}
#[derive(Debug, Serialize)]
struct EchoEnvelope<'a> {
echoed: &'a serde_json::Value,
error: &'a EchoReason,
}
pub fn render_and_clear(json: bool) {
let Some(reason) = state().lock().expect("echo state poisoned").take() else {
return;
};
let ops: Vec<DryRunOp> = {
let mut buf = shared_sink().buf.lock().expect("echo sink poisoned");
buf.drain(..).collect()
};
if json {
let payload = match ops.first() {
Some(op) => serde_json::to_value(EchoEnvelope {
echoed: &op.details,
error: &reason,
})
.unwrap_or_else(|_| json!({ "error": &reason })),
None => json!({
"echoed": null,
"error": &reason,
}),
};
match serde_json::to_string_pretty(&payload) {
Ok(rendered) => println!("{rendered}"),
Err(e) => eprintln!("echo: failed to render payload as JSON: {e}"),
}
} else {
if ops.is_empty() {
println!("would have run: (no transport call captured)");
} else {
for op in &ops {
println!("would have run: {}", op.summary);
}
}
println!(" reason: {}", reason.reason);
}
mark_echoed();
}