use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct ExplainPolicy {
pub policy_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub risk_level: Option<String>,
#[serde(default)]
pub allow_override: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct ExplainRule {
pub policy_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_on: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct DecisionExplanation {
pub decision_id: String,
pub timestamp: DateTime<Utc>,
#[serde(default)]
pub policy_matches: Vec<ExplainPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub matched_rules: Vec<ExplainRule>,
pub decision: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub risk_level: Option<String>,
#[serde(default)]
pub override_available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub override_existing_id: Option<String>,
#[serde(default)]
pub historical_hit_count_session: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_source_link: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "is_false")]
pub context_truncated: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct DecisionSummary {
pub decision_id: String,
pub timestamp: DateTime<Utc>,
pub decision: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ListDecisionsOptions {
pub since: Option<DateTime<Utc>>,
pub decision: Option<String>,
pub policy_id: Option<String>,
pub tool_signature: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct UpgradeInfo {
pub tier: String,
pub wording: String,
pub compare_url: String,
pub buy_url: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct RateLimitEnvelope {
pub error: String,
pub limit_type: String,
pub tier: String,
pub limit: u32,
pub remaining: u32,
pub upgrade: UpgradeInfo,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_context_round_trips() {
let json = r#"{
"decision_id": "dec-ctx",
"timestamp": "2026-05-30T12:00:00Z",
"decision": "deny",
"context": {
"x_ai_agent": "refund-bot",
"x_session_id": "sess-42",
"x_leader_identity": "ops-lead"
}
}"#;
let summary: DecisionSummary = serde_json::from_str(json).unwrap();
let ctx = summary.context.as_ref().expect("context present");
assert_eq!(ctx.len(), 3);
assert_eq!(
ctx.get("x_ai_agent").map(String::as_str),
Some("refund-bot")
);
let back: DecisionSummary =
serde_json::from_str(&serde_json::to_string(&summary).unwrap()).unwrap();
assert_eq!(
back.context
.unwrap()
.get("x_leader_identity")
.map(String::as_str),
Some("ops-lead")
);
}
#[test]
fn summary_context_absent_is_none_and_omitted() {
let json =
r#"{"decision_id":"dec-noctx","timestamp":"2026-05-30T12:00:00Z","decision":"allow"}"#;
let summary: DecisionSummary = serde_json::from_str(json).unwrap();
assert!(summary.context.is_none());
assert!(!serde_json::to_string(&summary).unwrap().contains("context"));
}
#[test]
fn explanation_full_context_and_truncated_flag() {
let json = r#"{
"decision_id": "dec-x",
"timestamp": "2026-05-30T12:00:00Z",
"decision": "deny",
"reason": "pii",
"policy_matches": [],
"context": {"x_ai_agent": "a", "x_session_id": "s"},
"context_truncated": true
}"#;
let exp: DecisionExplanation = serde_json::from_str(json).unwrap();
assert_eq!(exp.context.as_ref().unwrap().len(), 2);
assert!(exp.context_truncated);
assert!(serde_json::to_string(&exp)
.unwrap()
.contains("\"context_truncated\":true"));
}
#[test]
fn explanation_context_truncated_false_omitted() {
let exp = DecisionExplanation {
decision_id: "d".to_string(),
decision: "allow".to_string(),
..Default::default()
};
let json = serde_json::to_string(&exp).unwrap();
assert!(!json.contains("context_truncated"));
assert!(!json.contains("\"context\""));
}
}