1use crate::core::capabilities::{check_capabilities, role_capabilities};
13use crate::core::events::{EventKind, LeanCtxEvent};
14use serde_json::{Value, json};
15
16pub fn export_event(event: &LeanCtxEvent) -> Option<Value> {
23 let kind = export_event_kind(&event.kind)?;
24 Some(json!({
25 "id": event.id,
26 "timestamp": event.timestamp,
27 "kind": kind,
28 }))
29}
30
31fn export_event_kind(kind: &EventKind) -> Option<Value> {
32 match kind {
33 EventKind::ToolCall {
34 tool,
35 tokens_original,
36 tokens_saved,
37 mode,
38 duration_ms,
39 path,
40 } => Some(json!({
41 "type": "tool_call",
42 "tool": tool,
43 "tokens_original": tokens_original,
44 "tokens_saved": tokens_saved,
45 "mode": mode,
46 "duration_ms": duration_ms,
47 "path": path,
48 })),
49 EventKind::AgentAction {
50 agent_id,
51 action,
52 tool,
53 } => Some(json!({
54 "type": "agent_action",
55 "agent_id": agent_id,
56 "action": action,
57 "tool": tool,
58 })),
59 EventKind::KnowledgeUpdate {
60 category,
61 key,
62 action,
63 } => Some(json!({
64 "type": "knowledge_update",
65 "category": category,
66 "key": key,
67 "action": action,
68 })),
69 EventKind::BudgetWarning {
70 role,
71 dimension,
72 used,
73 limit,
74 percent,
75 } => Some(json!({
76 "type": "budget_warning",
77 "role": role,
78 "dimension": dimension,
79 "used": used,
80 "limit": limit,
81 "percent": percent,
82 })),
83 EventKind::BudgetExhausted {
84 role,
85 dimension,
86 used,
87 limit,
88 } => Some(json!({
89 "type": "budget_exhausted",
90 "role": role,
91 "dimension": dimension,
92 "used": used,
93 "limit": limit,
94 })),
95 EventKind::PolicyViolation { role, tool, reason } => Some(json!({
96 "type": "policy_violation",
97 "role": role,
98 "tool": tool,
99 "reason": reason,
100 })),
101 EventKind::RoleChanged { from, to } => Some(json!({
102 "type": "role_changed",
103 "from": from,
104 "to": to,
105 })),
106 _ => None,
107 }
108}
109
110pub fn capability_grant_set(role_name: &str) -> Value {
112 let mut caps: Vec<&'static str> = role_capabilities(role_name)
113 .into_iter()
114 .map(|c| c.display_name())
115 .collect();
116 caps.sort_unstable();
117 json!({ "subject": role_name, "capabilities": caps })
118}
119
120pub fn capability_check_result(role_name: &str, tool_name: &str) -> Value {
122 let result = check_capabilities(role_name, tool_name);
123 let missing: Vec<&'static str> = result
124 .missing
125 .iter()
126 .map(super::capabilities::Capability::display_name)
127 .collect();
128 json!({ "allowed": result.allowed, "missing": missing })
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn governance_events_export_with_snake_case_tags() {
137 let event = LeanCtxEvent {
138 id: 1,
139 timestamp: chrono::Utc::now().to_rfc3339(),
140 kind: EventKind::PolicyViolation {
141 role: "reviewer".into(),
142 tool: "ctx_shell".into(),
143 reason: "denied".into(),
144 },
145 };
146 let exported = export_event(&event).expect("governance event must export");
147 assert_eq!(exported["kind"]["type"], "policy_violation");
148 }
149
150 #[test]
151 fn non_governance_events_are_not_exported() {
152 let event = LeanCtxEvent {
153 id: 2,
154 timestamp: chrono::Utc::now().to_rfc3339(),
155 kind: EventKind::CacheHit {
156 path: "src/lib.rs".into(),
157 saved_tokens: 10,
158 },
159 };
160 assert!(export_event(&event).is_none());
161 }
162
163 #[test]
164 fn grant_set_uses_registry_identifiers() {
165 let grant = capability_grant_set("admin");
166 let caps = grant["capabilities"].as_array().unwrap();
167 assert!(caps.iter().any(|c| c == "exec:unrestricted"));
168 assert!(caps.iter().any(|c| c == "fs:read"));
169 }
170
171 #[test]
172 fn check_result_lists_missing_on_denial() {
173 let denied = capability_check_result("minimal", "ctx_shell");
174 assert_eq!(denied["allowed"], false);
175 assert!(!denied["missing"].as_array().unwrap().is_empty());
176
177 let allowed = capability_check_result("admin", "ctx_shell");
178 assert_eq!(allowed["allowed"], true);
179 assert!(allowed["missing"].as_array().unwrap().is_empty());
180 }
181}