envvault/cli/commands/
audit_cmd.rs1use chrono::Utc;
9
10use crate::audit::{AuditEntry, AuditLog};
11use crate::cli::output;
12use crate::cli::Cli;
13use crate::errors::{EnvVaultError, Result};
14
15pub fn execute(cli: &Cli, last: usize, since: Option<&str>) -> Result<()> {
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
40fn parse_duration(input: &str) -> Result<chrono::DateTime<Utc>> {
42 let input = input.trim();
43
44 let (num_str, unit) = if let Some(s) = input.strip_suffix('d') {
45 (s, 'd')
46 } else if let Some(s) = input.strip_suffix('h') {
47 (s, 'h')
48 } else if let Some(s) = input.strip_suffix('m') {
49 (s, 'm')
50 } else {
51 return Err(EnvVaultError::CommandFailed(format!(
52 "invalid duration '{input}' — use format like 7d, 24h, or 30m"
53 )));
54 };
55
56 let num: i64 = num_str.parse().map_err(|_| {
57 EnvVaultError::CommandFailed(format!(
58 "invalid duration '{input}' — number part is not valid"
59 ))
60 })?;
61
62 let duration = match unit {
63 'd' => chrono::Duration::days(num),
64 'h' => chrono::Duration::hours(num),
65 'm' => chrono::Duration::minutes(num),
66 _ => unreachable!(),
67 };
68
69 Ok(Utc::now() - duration)
70}
71
72pub fn print_audit_table(entries: &[AuditEntry]) {
74 use comfy_table::{ContentArrangement, Table};
75 use console::style;
76
77 let mut table = Table::new();
78 table.set_content_arrangement(ContentArrangement::Dynamic);
79 table.set_header(vec!["Time", "Operation", "Environment", "Key", "Details"]);
80
81 for entry in entries {
82 let time = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
83 let op = colorize_operation(&entry.operation);
84 let key = entry.key_name.as_deref().unwrap_or("-");
85 let details = entry.details.as_deref().unwrap_or("-");
86
87 table.add_row(vec![
88 time,
89 op,
90 entry.environment.clone(),
91 key.to_string(),
92 details.to_string(),
93 ]);
94 }
95
96 println!(
97 "{}",
98 style(format!("{} audit entries:", entries.len())).bold()
99 );
100 println!("{table}");
101}
102
103fn colorize_operation(op: &str) -> String {
105 use console::style;
106
107 match op {
108 "init" | "env-clone" => style(op).green().to_string(),
109 "set" | "edit" => style(op).blue().to_string(),
110 "delete" | "env-delete" => style(op).red().to_string(),
111 "rotate-key" => style(op).yellow().to_string(),
112 "export" | "import" => style(op).cyan().to_string(),
113 "diff" => style(op).magenta().to_string(),
114 _ => op.to_string(),
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn parse_duration_days() {
124 let dt = parse_duration("7d").unwrap();
125 let diff = Utc::now() - dt;
126 assert!((diff.num_days() - 7).abs() <= 1);
128 }
129
130 #[test]
131 fn parse_duration_hours() {
132 let dt = parse_duration("24h").unwrap();
133 let diff = Utc::now() - dt;
134 assert!((diff.num_hours() - 24).abs() <= 1);
135 }
136
137 #[test]
138 fn parse_duration_minutes() {
139 let dt = parse_duration("30m").unwrap();
140 let diff = Utc::now() - dt;
141 assert!((diff.num_minutes() - 30).abs() <= 1);
142 }
143
144 #[test]
145 fn parse_duration_invalid() {
146 assert!(parse_duration("abc").is_err());
147 assert!(parse_duration("7x").is_err());
148 assert!(parse_duration("d").is_err());
149 }
150
151 #[test]
152 fn colorize_operation_returns_string() {
153 assert!(!colorize_operation("init").is_empty());
155 assert!(!colorize_operation("set").is_empty());
156 assert!(!colorize_operation("unknown").is_empty());
157 }
158
159 #[test]
160 fn audit_query_roundtrip() {
161 let dir = tempfile::TempDir::new().unwrap();
162 let audit = AuditLog::open(dir.path()).unwrap();
163
164 audit.log("set", "dev", Some("KEY"), Some("added"));
165 audit.log("delete", "prod", Some("OLD"), None);
166
167 let entries = audit.query(10, None).unwrap();
168 assert_eq!(entries.len(), 2);
169 }
170
171 #[test]
172 fn audit_with_since_filter() {
173 let dir = tempfile::TempDir::new().unwrap();
174 let audit = AuditLog::open(dir.path()).unwrap();
175
176 audit.log("set", "dev", Some("KEY"), None);
177
178 let since = parse_duration("1h").unwrap();
180 let entries = audit.query(10, Some(since)).unwrap();
181 assert_eq!(entries.len(), 1);
182 }
183
184 #[test]
185 fn audit_empty_returns_empty() {
186 let dir = tempfile::TempDir::new().unwrap();
187 let audit = AuditLog::open(dir.path()).unwrap();
188 let entries = audit.query(10, None).unwrap();
189 assert!(entries.is_empty());
190 }
191}