Skip to main content

envvault/cli/commands/
audit_cmd.rs

1//! `envvault audit` — display the audit log.
2//!
3//! Usage:
4//!   envvault audit               # show last 50 entries
5//!   envvault audit --last 20     # show last 20
6//!   envvault audit --since 7d    # entries from last 7 days
7
8use chrono::Utc;
9
10use crate::audit::{AuditEntry, AuditLog};
11use crate::cli::output;
12use crate::cli::Cli;
13use crate::errors::{EnvVaultError, Result};
14
15/// Execute the `audit` command.
16pub 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
40/// Parse a human-friendly duration string like "7d", "24h", "30m".
41fn 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
72/// Print audit entries in a formatted table.
73pub 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
103/// Colorize operation names for display.
104fn 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        // Should be roughly 7 days (within a few seconds).
127        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        // Just verify it doesn't panic for known and unknown operations.
154        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        // Query with "1h" should include recent entries.
179        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}