battlecommand_forge/
enterprise.rs1use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::Path;
9
10const AUDIT_LOG: &str = ".battlecommand/audit.jsonl";
11const COSTS_LOG: &str = ".battlecommand/costs.jsonl";
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditEntry {
17 pub timestamp: String,
18 pub actor: String,
19 pub action: String,
20 pub resource: String,
21 pub details: String,
22}
23
24pub fn audit_log(action: &str, resource: &str, details: &str) -> Result<()> {
26 use std::io::Write;
27
28 let entry = AuditEntry {
29 timestamp: chrono::Utc::now().to_rfc3339(),
30 actor: whoami(),
31 action: action.to_string(),
32 resource: resource.to_string(),
33 details: details.to_string(),
34 };
35
36 let json = serde_json::to_string(&entry)?;
37
38 let path = Path::new(AUDIT_LOG);
39 crate::secrets::ensure_secret_file(path)?;
40 let mut file = fs::OpenOptions::new().append(true).open(path)?;
41 writeln!(file, "{}", json)?;
42 Ok(())
43}
44
45pub fn read_audit_log(limit: usize) -> Result<Vec<AuditEntry>> {
47 let content = fs::read_to_string(AUDIT_LOG).unwrap_or_default();
48 let entries: Vec<AuditEntry> = content
49 .lines()
50 .filter(|l| !l.is_empty())
51 .filter_map(|l| serde_json::from_str(l).ok())
52 .collect();
53
54 Ok(entries.into_iter().rev().take(limit).collect())
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct CostEntry {
61 pub timestamp: String,
62 pub mission_id: String,
63 pub model: String,
64 pub role: String,
65 pub input_tokens: u64,
66 pub output_tokens: u64,
67 pub cost_usd: f64,
68}
69
70fn model_pricing(model: &str) -> (f64, f64) {
72 let lower = model.to_lowercase();
73 if lower.contains("claude") && lower.contains("opus") {
74 (5.0, 25.0) } else if lower.contains("claude") && lower.contains("sonnet") {
76 (3.0, 15.0) } else if lower.contains("claude") && lower.contains("haiku") {
78 (0.25, 1.25) } else if lower.contains("grok") {
80 (3.0, 15.0) } else {
82 (0.0, 0.0) }
84}
85
86pub fn log_cost(
88 mission_id: &str,
89 model: &str,
90 role: &str,
91 input_tokens: u64,
92 output_tokens: u64,
93) -> Result<()> {
94 use std::io::Write;
95
96 let (input_price, output_price) = model_pricing(model);
97 let cost =
98 (input_tokens as f64 * input_price + output_tokens as f64 * output_price) / 1_000_000.0;
99
100 if cost == 0.0 {
102 return Ok(());
103 }
104
105 let entry = CostEntry {
106 timestamp: chrono::Utc::now().to_rfc3339(),
107 mission_id: mission_id.to_string(),
108 model: model.to_string(),
109 role: role.to_string(),
110 input_tokens,
111 output_tokens,
112 cost_usd: cost,
113 };
114
115 let json = serde_json::to_string(&entry)?;
116
117 let path = Path::new(COSTS_LOG);
118 crate::secrets::ensure_secret_file(path)?;
119 let mut file = fs::OpenOptions::new().append(true).open(path)?;
120 writeln!(file, "{}", json)?;
121 Ok(())
122}
123
124pub fn total_cost() -> Result<f64> {
126 let content = fs::read_to_string(COSTS_LOG).unwrap_or_default();
127 let total: f64 = content
128 .lines()
129 .filter(|l| !l.is_empty())
130 .filter_map(|l| serde_json::from_str::<CostEntry>(l).ok())
131 .map(|e| e.cost_usd)
132 .sum();
133 Ok(total)
134}
135
136#[derive(Debug, Clone, PartialEq)]
139pub enum Role {
140 Admin,
141 Developer,
142 Viewer,
143}
144
145impl Role {
146 pub fn can_create_mission(&self) -> bool {
147 matches!(self, Role::Admin | Role::Developer)
148 }
149
150 pub fn can_view_audit(&self) -> bool {
151 matches!(self, Role::Admin | Role::Viewer)
152 }
153
154 pub fn can_manage_models(&self) -> bool {
155 matches!(self, Role::Admin)
156 }
157
158 pub fn cost_budget_usd(&self) -> f64 {
159 match self {
160 Role::Admin => 100.0,
161 Role::Developer => 10.0,
162 Role::Viewer => 0.0,
163 }
164 }
165}
166
167fn whoami() -> String {
168 std::env::var("USER")
169 .or_else(|_| std::env::var("USERNAME"))
170 .unwrap_or_else(|_| "unknown".into())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_model_pricing() {
179 let (inp, out) = model_pricing("claude-sonnet-4-20250514");
180 assert_eq!(inp, 3.0);
181 assert_eq!(out, 15.0);
182
183 let (inp, out) = model_pricing("claude-opus-4-6");
184 assert_eq!(inp, 5.0);
185 assert_eq!(out, 25.0);
186
187 let (inp, out) = model_pricing("grok-4.20-reasoning");
188 assert_eq!(inp, 3.0);
189 assert_eq!(out, 15.0);
190
191 let (inp, out) = model_pricing("qwen2.5-coder:32b");
192 assert_eq!(inp, 0.0);
193 assert_eq!(out, 0.0);
194 }
195
196 #[test]
197 fn test_rbac() {
198 assert!(Role::Admin.can_create_mission());
199 assert!(Role::Developer.can_create_mission());
200 assert!(!Role::Viewer.can_create_mission());
201 assert!(Role::Admin.can_manage_models());
202 assert!(!Role::Developer.can_manage_models());
203 }
204
205 #[test]
206 fn test_total_cost_empty() {
207 let cost = total_cost().unwrap();
208 assert!(cost >= 0.0);
209 }
210}