1use crate::cli::Cli;
9use crate::errors::{EnvVaultError, Result};
10
11#[cfg(feature = "audit-log")]
13pub fn execute(cli: &Cli, last: usize, since: Option<&str>) -> Result<()> {
14 use crate::audit::AuditLog;
15 use crate::cli::output;
16
17 let cwd = std::env::current_dir()?;
18 let vault_dir = cwd.join(&cli.vault_dir);
19
20 let audit = AuditLog::open(&vault_dir)
21 .ok_or_else(|| EnvVaultError::AuditError("failed to open audit database".into()))?;
22
23 let since_dt = match since {
24 Some(s) => Some(parse_duration(s)?),
25 None => None,
26 };
27
28 let entries = audit.query(last, since_dt)?;
29
30 if entries.is_empty() {
31 output::info("No audit entries found.");
32 return Ok(());
33 }
34
35 print_audit_table(&entries);
36
37 Ok(())
38}
39
40#[cfg(not(feature = "audit-log"))]
42pub fn execute(_cli: &Cli, _last: usize, _since: Option<&str>) -> Result<()> {
43 Err(EnvVaultError::AuditError(
44 "audit log not available — rebuild with `cargo build --features audit-log`".into(),
45 ))
46}
47
48#[cfg(feature = "audit-log")]
54pub fn execute_export(cli: &Cli, format: &str, output: Option<&str>) -> Result<()> {
55 use crate::audit::{AuditEntryExport, AuditLog};
56 use crate::cli::output as out;
57
58 let cwd = std::env::current_dir()?;
59 let vault_dir = cwd.join(&cli.vault_dir);
60
61 let audit = AuditLog::open(&vault_dir)
62 .ok_or_else(|| EnvVaultError::AuditError("failed to open audit database".into()))?;
63
64 let entries = audit.query(i64::MAX as usize, None)?;
66
67 if entries.is_empty() {
68 out::info("No audit entries to export.");
69 return Ok(());
70 }
71
72 let exports: Vec<AuditEntryExport> = entries.iter().map(AuditEntryExport::from).collect();
73
74 let content = match format {
75 "csv" => format_as_csv(&exports),
76 _ => serde_json::to_string_pretty(&exports)
77 .map_err(|e| EnvVaultError::AuditError(format!("JSON serialization failed: {e}")))?,
78 };
79
80 match output {
81 Some(path) => {
82 std::fs::write(path, &content)?;
83 out::success(&format!(
84 "Exported {} entries to {} ({})",
85 exports.len(),
86 path,
87 format
88 ));
89 }
90 None => {
91 println!("{content}");
92 }
93 }
94
95 Ok(())
96}
97
98#[cfg(not(feature = "audit-log"))]
100pub fn execute_export(_cli: &Cli, _format: &str, _output: Option<&str>) -> Result<()> {
101 Err(EnvVaultError::AuditError(
102 "audit log not available — rebuild with `cargo build --features audit-log`".into(),
103 ))
104}
105
106#[cfg(feature = "audit-log")]
108fn format_as_csv(entries: &[crate::audit::AuditEntryExport]) -> String {
109 let mut buf = String::from("id,timestamp,operation,environment,key_name,details,user,pid\n");
110 for e in entries {
111 buf.push_str(&format!(
112 "{},{},{},{},{},{},{},{}\n",
113 e.id,
114 csv_escape(&e.timestamp),
115 csv_escape(&e.operation),
116 csv_escape(&e.environment),
117 csv_escape(e.key_name.as_deref().unwrap_or("")),
118 csv_escape(e.details.as_deref().unwrap_or("")),
119 csv_escape(e.user.as_deref().unwrap_or("")),
120 e.pid.map_or(String::new(), |p| p.to_string()),
121 ));
122 }
123 buf
124}
125
126#[cfg(feature = "audit-log")]
128fn csv_escape(value: &str) -> String {
129 if value.contains(',') || value.contains('"') || value.contains('\n') {
130 format!("\"{}\"", value.replace('"', "\"\""))
131 } else {
132 value.to_string()
133 }
134}
135
136#[cfg(feature = "audit-log")]
142pub fn execute_purge(cli: &Cli, older_than: &str) -> Result<()> {
143 use crate::audit::AuditLog;
144 use crate::cli::output as out;
145
146 let cwd = std::env::current_dir()?;
147 let vault_dir = cwd.join(&cli.vault_dir);
148
149 let audit = AuditLog::open(&vault_dir)
150 .ok_or_else(|| EnvVaultError::AuditError("failed to open audit database".into()))?;
151
152 let before = parse_duration(older_than)?;
153 let deleted = audit.purge(before)?;
154
155 out::success(&format!(
156 "Purged {} audit entries older than {}",
157 deleted, older_than
158 ));
159
160 Ok(())
161}
162
163#[cfg(not(feature = "audit-log"))]
165pub fn execute_purge(_cli: &Cli, _older_than: &str) -> Result<()> {
166 Err(EnvVaultError::AuditError(
167 "audit log not available — rebuild with `cargo build --features audit-log`".into(),
168 ))
169}
170
171pub fn parse_duration(input: &str) -> Result<chrono::DateTime<chrono::Utc>> {
173 use chrono::Utc;
174
175 let input = input.trim();
176
177 let (num_str, unit) = if let Some(s) = input.strip_suffix('d') {
178 (s, 'd')
179 } else if let Some(s) = input.strip_suffix('h') {
180 (s, 'h')
181 } else if let Some(s) = input.strip_suffix('m') {
182 (s, 'm')
183 } else {
184 return Err(EnvVaultError::CommandFailed(format!(
185 "invalid duration '{input}' — use format like 7d, 24h, or 30m"
186 )));
187 };
188
189 let num: i64 = num_str.parse().map_err(|_| {
190 EnvVaultError::CommandFailed(format!(
191 "invalid duration '{input}' — number part is not valid"
192 ))
193 })?;
194
195 let duration = match unit {
196 'd' => chrono::Duration::days(num),
197 'h' => chrono::Duration::hours(num),
198 'm' => chrono::Duration::minutes(num),
199 _ => unreachable!(),
200 };
201
202 Ok(Utc::now() - duration)
203}
204
205#[cfg(feature = "audit-log")]
207pub fn print_audit_table(entries: &[crate::audit::AuditEntry]) {
208 use comfy_table::{ContentArrangement, Table};
209 use console::style;
210
211 let mut table = Table::new();
212 table.set_content_arrangement(ContentArrangement::Dynamic);
213 table.set_header(vec!["Time", "Operation", "Environment", "Key", "Details"]);
214
215 for entry in entries {
216 let time = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
217 let op = colorize_operation(&entry.operation);
218 let key = entry.key_name.as_deref().unwrap_or("-");
219 let details = entry.details.as_deref().unwrap_or("-");
220
221 table.add_row(vec![
222 time,
223 op,
224 entry.environment.clone(),
225 key.to_string(),
226 details.to_string(),
227 ]);
228 }
229
230 println!(
231 "{}",
232 style(format!("{} audit entries:", entries.len())).bold()
233 );
234 println!("{table}");
235}
236
237#[cfg(feature = "audit-log")]
239fn colorize_operation(op: &str) -> String {
240 use console::style;
241
242 match op {
243 "init" | "env-clone" => style(op).green().to_string(),
244 "set" | "edit" => style(op).blue().to_string(),
245 "delete" | "env-delete" => style(op).red().to_string(),
246 "rotate-key" => style(op).yellow().to_string(),
247 "export" | "import" => style(op).cyan().to_string(),
248 "diff" => style(op).magenta().to_string(),
249 _ => op.to_string(),
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use chrono::Utc;
257
258 #[test]
259 fn parse_duration_days() {
260 let dt = parse_duration("7d").unwrap();
261 let diff = Utc::now() - dt;
262 assert!((diff.num_days() - 7).abs() <= 1);
263 }
264
265 #[test]
266 fn parse_duration_hours() {
267 let dt = parse_duration("24h").unwrap();
268 let diff = Utc::now() - dt;
269 assert!((diff.num_hours() - 24).abs() <= 1);
270 }
271
272 #[test]
273 fn parse_duration_minutes() {
274 let dt = parse_duration("30m").unwrap();
275 let diff = Utc::now() - dt;
276 assert!((diff.num_minutes() - 30).abs() <= 1);
277 }
278
279 #[test]
280 fn parse_duration_invalid() {
281 assert!(parse_duration("abc").is_err());
282 assert!(parse_duration("7x").is_err());
283 assert!(parse_duration("d").is_err());
284 }
285
286 #[cfg(feature = "audit-log")]
287 #[test]
288 fn colorize_operation_returns_string() {
289 assert!(!colorize_operation("init").is_empty());
290 assert!(!colorize_operation("set").is_empty());
291 assert!(!colorize_operation("unknown").is_empty());
292 }
293
294 #[cfg(feature = "audit-log")]
295 #[test]
296 fn audit_query_roundtrip() {
297 use crate::audit::AuditLog;
298 let dir = tempfile::TempDir::new().unwrap();
299 let audit = AuditLog::open(dir.path()).unwrap();
300
301 audit.log("set", "dev", Some("KEY"), Some("added"));
302 audit.log("delete", "prod", Some("OLD"), None);
303
304 let entries = audit.query(10, None).unwrap();
305 assert_eq!(entries.len(), 2);
306 }
307
308 #[cfg(feature = "audit-log")]
309 #[test]
310 fn audit_with_since_filter() {
311 use crate::audit::AuditLog;
312 let dir = tempfile::TempDir::new().unwrap();
313 let audit = AuditLog::open(dir.path()).unwrap();
314
315 audit.log("set", "dev", Some("KEY"), None);
316
317 let since = parse_duration("1h").unwrap();
318 let entries = audit.query(10, Some(since)).unwrap();
319 assert_eq!(entries.len(), 1);
320 }
321
322 #[cfg(feature = "audit-log")]
323 #[test]
324 fn audit_empty_returns_empty() {
325 use crate::audit::AuditLog;
326 let dir = tempfile::TempDir::new().unwrap();
327 let audit = AuditLog::open(dir.path()).unwrap();
328 let entries = audit.query(10, None).unwrap();
329 assert!(entries.is_empty());
330 }
331
332 #[cfg(feature = "audit-log")]
333 #[test]
334 fn export_json_roundtrip() {
335 use crate::audit::{AuditEntryExport, AuditLog};
336 let dir = tempfile::TempDir::new().unwrap();
337 let audit = AuditLog::open(dir.path()).unwrap();
338
339 audit.log("set", "dev", Some("KEY"), Some("added"));
340 audit.log("delete", "prod", Some("OLD"), None);
341
342 let entries = audit.query(100, None).unwrap();
343 let exports: Vec<AuditEntryExport> = entries.iter().map(AuditEntryExport::from).collect();
344
345 let json = serde_json::to_string_pretty(&exports).unwrap();
346 let parsed: Vec<AuditEntryExport> = serde_json::from_str(&json).unwrap();
347 assert_eq!(parsed.len(), 2);
348 assert_eq!(parsed[0].operation, "delete");
349 assert_eq!(parsed[1].operation, "set");
350 }
351
352 #[cfg(feature = "audit-log")]
353 #[test]
354 fn export_csv_format() {
355 use crate::audit::{AuditEntryExport, AuditLog};
356 let dir = tempfile::TempDir::new().unwrap();
357 let audit = AuditLog::open(dir.path()).unwrap();
358
359 audit.log("set", "dev", Some("MY_KEY"), Some("added"));
360
361 let entries = audit.query(100, None).unwrap();
362 let exports: Vec<AuditEntryExport> = entries.iter().map(AuditEntryExport::from).collect();
363 let csv = format_as_csv(&exports);
364
365 assert!(csv.starts_with("id,timestamp,operation,environment,key_name,details,user,pid\n"));
366 assert!(csv.contains("set"));
367 assert!(csv.contains("dev"));
368 assert!(csv.contains("MY_KEY"));
369 }
370
371 #[cfg(feature = "audit-log")]
372 #[test]
373 fn purge_count_correct() {
374 use crate::audit::AuditLog;
375 let dir = tempfile::TempDir::new().unwrap();
376 let audit = AuditLog::open(dir.path()).unwrap();
377
378 audit.log("set", "dev", Some("A"), None);
379 audit.log("set", "dev", Some("B"), None);
380 audit.log("set", "dev", Some("C"), None);
381
382 let future = Utc::now() + chrono::Duration::hours(1);
384 let deleted = audit.purge(future).unwrap();
385 assert_eq!(deleted, 3);
386 }
387}