1#![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
19pub struct WebhookCommand;
21
22impl WebhookCommand {
23 #[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
36pub 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 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
122pub 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
157pub 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 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
250pub 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
323pub 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
369pub 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 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
414fn 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}