subcog/cli/
webhook.rs

1//! Webhook CLI command.
2//!
3//! Provides commands for managing webhook notifications:
4//! - List configured webhooks
5//! - Test webhook delivery
6//! - View delivery history
7//! - Export/delete audit logs (GDPR compliance)
8
9// CLI commands are allowed to use println! for output
10#![allow(clippy::print_stdout)]
11
12use crate::Result;
13use crate::storage::index::DomainScope;
14use crate::webhooks::{
15    DeliveryStatus, WebhookAuditBackend, WebhookAuditLogger, WebhookConfig, WebhookService,
16};
17use std::path::Path;
18
19/// Webhook command handler.
20pub struct WebhookCommand;
21
22impl WebhookCommand {
23    /// Creates a new webhook command.
24    #[must_use]
25    pub const fn new() -> Self {
26        Self
27    }
28}
29
30impl Default for WebhookCommand {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36/// Lists all configured webhooks.
37///
38/// # Arguments
39///
40/// * `format` - Output format: "table", "json", or "yaml"
41///
42/// # Errors
43///
44/// Returns an error if the configuration cannot be loaded.
45pub fn cmd_webhook_list(format: &str) -> Result<()> {
46    let config = WebhookConfig::load_default();
47
48    if config.webhooks.is_empty() {
49        println!("No webhooks configured.");
50        println!();
51        println!("To add webhooks, add [[webhooks]] to ~/.config/subcog/config.toml");
52        println!("See documentation for configuration format.");
53        return Ok(());
54    }
55
56    match format {
57        "json" => {
58            let json = serde_json::to_string_pretty(&config.webhooks).map_err(|e| {
59                crate::Error::OperationFailed {
60                    operation: "serialize_webhooks".to_string(),
61                    cause: e.to_string(),
62                }
63            })?;
64            println!("{json}");
65        },
66        "yaml" => {
67            let yaml = serde_yaml_ng::to_string(&config.webhooks).map_err(|e| {
68                crate::Error::OperationFailed {
69                    operation: "serialize_webhooks".to_string(),
70                    cause: e.to_string(),
71                }
72            })?;
73            println!("{yaml}");
74        },
75        _ => {
76            // Table format
77            println!("Configured Webhooks:");
78            println!("{}", "-".repeat(80));
79            println!(
80                "{:<20} {:<10} {:<15} {:<30}",
81                "NAME", "ENABLED", "AUTH", "EVENTS"
82            );
83            println!("{}", "-".repeat(80));
84
85            for webhook in &config.webhooks {
86                let auth_type = match &webhook.auth {
87                    crate::webhooks::WebhookAuth::Bearer { .. } => "Bearer",
88                    crate::webhooks::WebhookAuth::Hmac { .. } => "HMAC",
89                    crate::webhooks::WebhookAuth::Both { .. } => "Bearer+HMAC",
90                    crate::webhooks::WebhookAuth::None => "None",
91                };
92
93                let events = if webhook.events.is_empty() {
94                    "*".to_string()
95                } else {
96                    webhook.events.join(", ")
97                };
98
99                let events_display = if events.len() > 28 {
100                    format!("{}...", &events[..25])
101                } else {
102                    events
103                };
104
105                println!(
106                    "{:<20} {:<10} {:<15} {:<30}",
107                    truncate(&webhook.name, 18),
108                    if webhook.enabled { "Yes" } else { "No" },
109                    auth_type,
110                    events_display
111                );
112            }
113
114            println!("{}", "-".repeat(80));
115            println!("Total: {} webhook(s)", config.webhooks.len());
116        },
117    }
118
119    Ok(())
120}
121
122/// Tests a webhook by sending a test event.
123///
124/// # Arguments
125///
126/// * `name` - Name of the webhook to test
127/// * `data_dir` - Data directory for the audit database
128///
129/// # Errors
130///
131/// Returns an error if the webhook is not found or delivery fails.
132pub fn cmd_webhook_test(name: &str, data_dir: &Path) -> Result<()> {
133    let service = WebhookService::from_config_file(DomainScope::Project, data_dir)?
134        .ok_or_else(|| crate::Error::InvalidInput("No webhooks configured".to_string()))?;
135
136    println!("Testing webhook '{name}'...");
137
138    let result = service.test_webhook(name)?;
139
140    if result.success {
141        println!("✓ Webhook test successful!");
142        println!("  Status code: {}", result.status_code.unwrap_or(0));
143        println!("  Attempts: {}", result.attempts);
144        println!("  Duration: {}ms", result.duration_ms);
145    } else {
146        println!("✗ Webhook test failed!");
147        println!("  Attempts: {}", result.attempts);
148        println!("  Duration: {}ms", result.duration_ms);
149        if let Some(error) = &result.error {
150            println!("  Error: {error}");
151        }
152    }
153
154    Ok(())
155}
156
157/// Shows delivery history for a webhook.
158///
159/// # Arguments
160///
161/// * `name` - Webhook name (optional, shows all if not specified)
162/// * `limit` - Maximum number of records to show
163/// * `data_dir` - Data directory for the audit database
164/// * `format` - Output format: "table" or "json"
165///
166/// # Errors
167///
168/// Returns an error if the audit database cannot be accessed.
169pub fn cmd_webhook_history(
170    name: Option<&str>,
171    limit: usize,
172    data_dir: &Path,
173    format: &str,
174) -> Result<()> {
175    let audit_path = data_dir.join("webhook_audit.db");
176
177    if !audit_path.exists() {
178        println!("No webhook delivery history found.");
179        return Ok(());
180    }
181
182    let logger = WebhookAuditLogger::new(&audit_path)?;
183
184    let records = if let Some(webhook_name) = name {
185        logger.get_history(webhook_name, limit)?
186    } else {
187        // Get history for all webhooks (export all)
188        logger.export_domain_logs("*")?
189    };
190
191    if records.is_empty() {
192        println!("No delivery history found.");
193        return Ok(());
194    }
195
196    if format == "json" {
197        let json =
198            serde_json::to_string_pretty(&records).map_err(|e| crate::Error::OperationFailed {
199                operation: "serialize_history".to_string(),
200                cause: e.to_string(),
201            })?;
202        println!("{json}");
203    } else {
204        println!("Webhook Delivery History:");
205        println!("{}", "-".repeat(100));
206        println!(
207            "{:<20} {:<12} {:<10} {:<8} {:<10} {:<35}",
208            "WEBHOOK", "EVENT", "STATUS", "CODE", "ATTEMPTS", "TIMESTAMP"
209        );
210        println!("{}", "-".repeat(100));
211
212        for record in records.iter().take(limit) {
213            let status = match record.status {
214                DeliveryStatus::Success => "✓ OK",
215                DeliveryStatus::Failed => "✗ FAIL",
216                DeliveryStatus::Timeout => "◷ TOUT",
217            };
218
219            let code: String = record
220                .status_code
221                .map_or_else(|| "-".to_string(), |c| c.to_string());
222
223            let timestamp = chrono::DateTime::from_timestamp(record.timestamp, 0).map_or_else(
224                || "Unknown".to_string(),
225                |dt| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
226            );
227
228            println!(
229                "{:<20} {:<12} {:<10} {:<8} {:<10} {:<35}",
230                truncate(&record.webhook_name, 18),
231                truncate(&record.event_type, 10),
232                status,
233                code,
234                record.attempts,
235                timestamp
236            );
237        }
238
239        println!("{}", "-".repeat(100));
240        println!(
241            "Showing {} of {} record(s)",
242            records.len().min(limit),
243            records.len()
244        );
245    }
246
247    Ok(())
248}
249
250/// Shows webhook statistics.
251///
252/// # Arguments
253///
254/// * `name` - Webhook name (optional)
255/// * `data_dir` - Data directory for the audit database
256///
257/// # Errors
258///
259/// Returns an error if the audit database cannot be accessed.
260pub fn cmd_webhook_stats(name: Option<&str>, data_dir: &Path) -> Result<()> {
261    let config = WebhookConfig::load_default();
262    let audit_path = data_dir.join("webhook_audit.db");
263
264    if !audit_path.exists() {
265        println!("No webhook statistics available (no deliveries recorded).");
266        return Ok(());
267    }
268
269    let logger = WebhookAuditLogger::new(&audit_path)?;
270
271    let webhooks_to_show: Vec<_> = if let Some(webhook_name) = name {
272        config
273            .webhooks
274            .iter()
275            .filter(|w| w.name == webhook_name)
276            .collect()
277    } else {
278        config.webhooks.iter().collect()
279    };
280
281    if webhooks_to_show.is_empty() {
282        if let Some(n) = name {
283            println!("Webhook '{n}' not found.");
284        } else {
285            println!("No webhooks configured.");
286        }
287        return Ok(());
288    }
289
290    println!("Webhook Statistics:");
291    println!("{}", "-".repeat(80));
292    println!(
293        "{:<20} {:<10} {:<10} {:<10} {:<12} {:<12}",
294        "WEBHOOK", "TOTAL", "SUCCESS", "FAILED", "AVG MS", "SUCCESS %"
295    );
296    println!("{}", "-".repeat(80));
297
298    for webhook in webhooks_to_show {
299        let stats = logger.count_by_status(&webhook.name)?;
300        #[allow(clippy::cast_precision_loss)]
301        let success_pct = if stats.total > 0 {
302            (stats.success as f64 / stats.total as f64) * 100.0
303        } else {
304            0.0
305        };
306
307        println!(
308            "{:<20} {:<10} {:<10} {:<10} {:<12.1} {:<12.1}%",
309            truncate(&webhook.name, 18),
310            stats.total,
311            stats.success,
312            stats.failed,
313            stats.avg_duration_ms,
314            success_pct
315        );
316    }
317
318    println!("{}", "-".repeat(80));
319
320    Ok(())
321}
322
323/// Exports webhook audit logs for a domain (GDPR compliance).
324///
325/// # Arguments
326///
327/// * `domain` - Domain to export logs for
328/// * `output` - Output file path (optional, prints to stdout if not specified)
329/// * `data_dir` - Data directory for the audit database
330///
331/// # Errors
332///
333/// Returns an error if the audit database cannot be accessed.
334pub fn cmd_webhook_export(domain: &str, output: Option<&Path>, data_dir: &Path) -> Result<()> {
335    let audit_path = data_dir.join("webhook_audit.db");
336
337    if !audit_path.exists() {
338        println!("No webhook audit logs found.");
339        return Ok(());
340    }
341
342    let logger = WebhookAuditLogger::new(&audit_path)?;
343    let records = logger.export_domain_logs(domain)?;
344
345    if records.is_empty() {
346        println!("No audit logs found for domain '{domain}'.");
347        return Ok(());
348    }
349
350    let json =
351        serde_json::to_string_pretty(&records).map_err(|e| crate::Error::OperationFailed {
352            operation: "export_audit_logs".to_string(),
353            cause: e.to_string(),
354        })?;
355
356    if let Some(path) = output {
357        std::fs::write(path, &json).map_err(|e| crate::Error::OperationFailed {
358            operation: "write_export".to_string(),
359            cause: e.to_string(),
360        })?;
361        println!("Exported {} records to {}", records.len(), path.display());
362    } else {
363        println!("{json}");
364    }
365
366    Ok(())
367}
368
369/// Deletes webhook audit logs for a domain (GDPR Right to Erasure).
370///
371/// # Arguments
372///
373/// * `domain` - Domain to delete logs for
374/// * `force` - Skip confirmation prompt
375/// * `data_dir` - Data directory for the audit database
376///
377/// # Errors
378///
379/// Returns an error if the audit database cannot be accessed.
380pub fn cmd_webhook_delete_logs(domain: &str, force: bool, data_dir: &Path) -> Result<()> {
381    let audit_path = data_dir.join("webhook_audit.db");
382
383    if !audit_path.exists() {
384        println!("No webhook audit logs found.");
385        return Ok(());
386    }
387
388    let logger = WebhookAuditLogger::new(&audit_path)?;
389
390    // Count records first
391    let records = logger.export_domain_logs(domain)?;
392    if records.is_empty() {
393        println!("No audit logs found for domain '{domain}'.");
394        return Ok(());
395    }
396
397    if !force {
398        println!(
399            "This will permanently delete {} audit log record(s) for domain '{domain}'.",
400            records.len()
401        );
402        println!("This action cannot be undone.");
403        println!();
404        println!("To proceed, run with --force flag.");
405        return Ok(());
406    }
407
408    let deleted = logger.delete_domain_logs(domain)?;
409    println!("Deleted {deleted} audit log record(s) for domain '{domain}'.");
410
411    Ok(())
412}
413
414/// Truncates a string to a maximum length.
415fn truncate(s: &str, max_len: usize) -> String {
416    if s.len() <= max_len {
417        s.to_string()
418    } else {
419        format!("{}...", &s[..max_len.saturating_sub(3)])
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_webhook_command_new() {
429        let _cmd = WebhookCommand::new();
430    }
431
432    #[test]
433    #[allow(clippy::default_constructed_unit_structs)]
434    fn test_webhook_command_default() {
435        let _cmd = WebhookCommand::default();
436    }
437
438    #[test]
439    fn test_truncate() {
440        assert_eq!(truncate("hello", 10), "hello");
441        assert_eq!(truncate("hello world", 8), "hello...");
442        assert_eq!(truncate("hi", 2), "hi");
443    }
444}