1use crate::evaluator::MatchSpan;
7use crate::highlight::HighlightSpan;
8use crate::output::auto_theme;
9#[cfg(feature = "rich-output")]
10use crate::output::console::console;
11use crate::output::denial::DenialBox;
12use crate::output::theme::Severity as ThemeSeverity;
13use crate::packs::PatternSuggestion;
14use colored::Colorize;
15#[cfg(feature = "rich-output")]
16#[allow(unused_imports)]
17use rich_rust::prelude::*;
18use serde::{Deserialize, Serialize};
19use std::borrow::Cow;
20use std::io::{self, IsTerminal, Read, Write};
21use std::time::Duration;
22
23#[derive(Debug, Deserialize)]
25pub struct HookInput {
26 pub event: Option<String>,
28
29 #[serde(alias = "toolName")]
31 pub tool_name: Option<String>,
32
33 #[serde(alias = "toolInput")]
35 pub tool_input: Option<ToolInput>,
36
37 #[serde(alias = "toolArgs")]
40 pub tool_args: Option<serde_json::Value>,
41}
42
43#[derive(Debug, Deserialize)]
45pub struct ToolInput {
46 pub command: Option<serde_json::Value>,
48}
49
50#[derive(Debug, Serialize)]
52pub struct HookOutput<'a> {
53 #[serde(rename = "hookSpecificOutput")]
55 pub hook_specific_output: HookSpecificOutput<'a>,
56}
57
58#[derive(Debug, Serialize)]
60pub struct HookSpecificOutput<'a> {
61 #[serde(rename = "hookEventName")]
63 pub hook_event_name: &'static str,
64
65 #[serde(rename = "permissionDecision")]
67 pub permission_decision: &'static str,
68
69 #[serde(rename = "permissionDecisionReason")]
71 pub permission_decision_reason: Cow<'a, str>,
72
73 #[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
75 pub allow_once_code: Option<String>,
76
77 #[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
79 pub allow_once_full_hash: Option<String>,
80
81 #[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
85 pub rule_id: Option<String>,
86
87 #[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
89 pub pack_id: Option<String>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub severity: Option<crate::packs::Severity>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
98 pub confidence: Option<f64>,
99
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub remediation: Option<Remediation>,
103}
104
105#[derive(Debug, Serialize)]
113pub struct CopilotHookOutput<'a> {
114 #[serde(rename = "continue")]
116 pub continue_execution: bool,
117
118 #[serde(rename = "stopReason")]
120 pub stop_reason: Cow<'a, str>,
121
122 #[serde(rename = "permissionDecision")]
124 pub permission_decision: &'static str,
125
126 #[serde(rename = "permissionDecisionReason")]
128 pub permission_decision_reason: Cow<'a, str>,
129
130 #[serde(rename = "allowOnceCode", skip_serializing_if = "Option::is_none")]
132 pub allow_once_code: Option<String>,
133
134 #[serde(rename = "allowOnceFullHash", skip_serializing_if = "Option::is_none")]
136 pub allow_once_full_hash: Option<String>,
137
138 #[serde(rename = "ruleId", skip_serializing_if = "Option::is_none")]
140 pub rule_id: Option<String>,
141
142 #[serde(rename = "packId", skip_serializing_if = "Option::is_none")]
144 pub pack_id: Option<String>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub severity: Option<crate::packs::Severity>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub confidence: Option<f64>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub remediation: Option<Remediation>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum HookProtocol {
162 ClaudeCompatible,
164 Copilot,
166}
167
168#[derive(Debug, Clone)]
170pub struct AllowOnceInfo {
171 pub code: String,
172 pub full_hash: String,
173}
174
175#[derive(Debug, Clone, Serialize)]
180pub struct Remediation {
181 #[serde(rename = "safeAlternative", skip_serializing_if = "Option::is_none")]
183 pub safe_alternative: Option<String>,
184
185 pub explanation: String,
187
188 #[serde(rename = "allowOnceCommand")]
190 pub allow_once_command: String,
191}
192
193#[derive(Debug)]
195pub enum HookResult {
196 Allow,
198
199 Deny {
201 command: String,
203 reason: String,
205 pack: Option<String>,
207 pattern_name: Option<String>,
209 },
210
211 Skip,
213
214 ParseError,
216}
217
218#[derive(Debug)]
220pub enum HookReadError {
221 Io(io::Error),
223 InputTooLarge(usize),
225 Json(serde_json::Error),
227}
228
229pub fn read_hook_input(max_bytes: usize) -> Result<HookInput, HookReadError> {
237 let mut input = String::with_capacity(256);
238 {
239 let stdin = io::stdin();
240 let mut handle = stdin.lock().take(max_bytes as u64 + 1);
242 handle
243 .read_to_string(&mut input)
244 .map_err(HookReadError::Io)?;
245 }
246
247 if input.len() > max_bytes {
248 return Err(HookReadError::InputTooLarge(input.len()));
249 }
250
251 serde_json::from_str(&input).map_err(HookReadError::Json)
252}
253
254#[must_use]
256pub fn detect_protocol(input: &HookInput) -> HookProtocol {
257 let tool_name = input
258 .tool_name
259 .as_deref()
260 .map(str::to_ascii_lowercase)
261 .unwrap_or_default();
262
263 if input.event.is_some()
264 || input.tool_args.is_some()
265 || matches!(
266 tool_name.as_str(),
267 "run_shell_command" | "run-shell-command"
268 )
269 {
270 HookProtocol::Copilot
271 } else {
272 HookProtocol::ClaudeCompatible
273 }
274}
275
276fn is_supported_shell_tool(tool_name: Option<&str>) -> bool {
277 let Some(tool_name) = tool_name else {
278 return false;
279 };
280
281 matches!(
282 tool_name.to_ascii_lowercase().as_str(),
283 "bash" | "launch-process" | "run_shell_command" | "run-shell-command"
284 )
285}
286
287fn extract_command_from_tool_args(tool_args: &serde_json::Value) -> Option<String> {
288 match tool_args {
289 serde_json::Value::Object(map) => map.get("command").and_then(|v| match v {
290 serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
291 _ => None,
292 }),
293 serde_json::Value::String(s) => {
294 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
295 extract_command_from_tool_args(&parsed)
296 } else if s.is_empty() {
297 None
298 } else {
299 Some(s.clone())
300 }
301 }
302 _ => None,
303 }
304}
305
306#[must_use]
308pub fn extract_command_with_protocol(input: &HookInput) -> Option<(String, HookProtocol)> {
309 if !is_supported_shell_tool(input.tool_name.as_deref()) {
311 return None;
312 }
313
314 let protocol = detect_protocol(input);
315
316 if let Some(tool_input) = input.tool_input.as_ref() {
317 if let Some(serde_json::Value::String(s)) = tool_input.command.as_ref() {
318 if !s.is_empty() {
319 return Some((s.clone(), protocol));
320 }
321 }
322 }
323
324 if let Some(tool_args) = input.tool_args.as_ref() {
325 if let Some(command) = extract_command_from_tool_args(tool_args) {
326 return Some((command, protocol));
327 }
328 }
329
330 None
331}
332
333#[must_use]
335pub fn extract_command(input: &HookInput) -> Option<String> {
336 extract_command_with_protocol(input).map(|(command, _)| command)
337}
338
339pub fn configure_colors() {
341 if std::env::var_os("NO_COLOR").is_some() || std::env::var_os("DCG_NO_COLOR").is_some() {
342 colored::control::set_override(false);
343 return;
344 }
345
346 if !io::stderr().is_terminal() {
347 colored::control::set_override(false);
348 }
349}
350
351fn format_explain_hint(command: &str) -> String {
353 let escaped = command.replace('"', "\\\"");
355 format!("Tip: dcg explain \"{escaped}\"")
356}
357
358fn build_rule_id(pack: Option<&str>, pattern: Option<&str>) -> Option<String> {
359 match (pack, pattern) {
360 (Some(pack_id), Some(pattern_name)) => Some(format!("{pack_id}:{pattern_name}")),
361 _ => None,
362 }
363}
364
365fn format_explanation_text(
366 explanation: Option<&str>,
367 rule_id: Option<&str>,
368 pack: Option<&str>,
369) -> String {
370 let trimmed = explanation.map(str::trim).filter(|text| !text.is_empty());
371
372 if let Some(text) = trimmed {
373 return text.to_string();
374 }
375
376 if let Some(rule) = rule_id {
377 return format!(
378 "Matched destructive pattern {rule}. No additional explanation is available yet. See pack documentation for details."
379 );
380 }
381
382 if let Some(pack_name) = pack {
383 return format!(
384 "Matched destructive pack {pack_name}. No additional explanation is available yet. See pack documentation for details."
385 );
386 }
387
388 "Matched a destructive pattern. No additional explanation is available yet. See pack documentation for details."
389 .to_string()
390}
391
392fn format_explanation_block(explanation: &str) -> String {
393 let mut lines = explanation.lines();
394 let Some(first) = lines.next() else {
395 return "Explanation:".to_string();
396 };
397
398 let mut output = format!("Explanation: {first}");
399 for line in lines {
400 output.push('\n');
401 output.push_str(" ");
402 output.push_str(line);
403 }
404 output
405}
406
407#[must_use]
409pub fn format_denial_message(
410 command: &str,
411 reason: &str,
412 explanation: Option<&str>,
413 pack: Option<&str>,
414 pattern: Option<&str>,
415) -> String {
416 let explain_hint = format_explain_hint(command);
417 let rule_id = build_rule_id(pack, pattern);
418 let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
419 let explanation_block = format_explanation_block(&explanation_text);
420
421 let rule_line = rule_id.as_deref().map_or_else(
422 || {
423 pack.map(|pack_name| format!("Pack: {pack_name}\n\n"))
424 .unwrap_or_default()
425 },
426 |rule| format!("Rule: {rule}\n\n"),
427 );
428
429 format!(
430 "BLOCKED by dcg\n\n\
431 {explain_hint}\n\n\
432 Reason: {reason}\n\n\
433 {explanation_block}\n\n\
434 {rule_line}\
435 Command: {command}\n\n\
436 If this operation is truly needed, ask the user for explicit \
437 permission and have them run the command manually."
438 )
439}
440
441fn to_output_severity(s: crate::packs::Severity) -> ThemeSeverity {
443 match s {
444 crate::packs::Severity::Critical => ThemeSeverity::Critical,
445 crate::packs::Severity::High => ThemeSeverity::High,
446 crate::packs::Severity::Medium => ThemeSeverity::Medium,
447 crate::packs::Severity::Low => ThemeSeverity::Low,
448 }
449}
450
451const MAX_SUGGESTIONS: usize = 4;
452
453#[allow(clippy::too_many_lines)]
455pub fn print_colorful_warning(
456 command: &str,
457 _reason: &str,
458 pack: Option<&str>,
459 pattern: Option<&str>,
460 explanation: Option<&str>,
461 allow_once_code: Option<&str>,
462 matched_span: Option<&MatchSpan>,
463 pattern_suggestions: &[PatternSuggestion],
464 severity: Option<crate::packs::Severity>,
465) {
466 #[cfg(feature = "rich-output")]
467 let console_instance = console();
468 let theme = auto_theme();
469
470 let rule_id = build_rule_id(pack, pattern);
472 let pattern_display = rule_id.as_deref().or(pack).unwrap_or("unknown pattern");
473
474 let theme_severity = severity
475 .map(to_output_severity)
476 .unwrap_or(ThemeSeverity::High);
477
478 let explanation_text = explanation.map(str::trim).filter(|text| !text.is_empty());
479
480 let span = matched_span
482 .map(|s| HighlightSpan::new(s.start, s.end))
483 .unwrap_or_else(|| HighlightSpan::new(0, 0)); let suggestions_enabled = crate::output::suggestions_enabled();
486
487 let filtered_suggestions: Vec<&PatternSuggestion> = if suggestions_enabled {
489 pattern_suggestions
490 .iter()
491 .filter(|s| s.platform.matches_current())
492 .collect()
493 } else {
494 Vec::new()
495 };
496 let mut alternatives: Vec<String> = filtered_suggestions
497 .iter()
498 .take(MAX_SUGGESTIONS)
499 .map(|s| format!("{}: {}", s.description, s.command))
500 .collect();
501
502 if suggestions_enabled && alternatives.is_empty() {
504 if let Some(sugg) = get_contextual_suggestion(command) {
505 alternatives.push(sugg.to_string());
506 }
507 }
508
509 let mut denial = DenialBox::new(command, span, pattern_display, theme_severity)
510 .with_alternatives(alternatives);
511
512 if let Some(text) = explanation_text {
513 denial = denial.with_explanation(text);
514 }
515
516 if let Some(code) = allow_once_code {
517 denial = denial.with_allow_once_code(code);
518 }
519
520 eprintln!("{}", denial.render(&theme));
523
524 #[cfg(feature = "rich-output")]
526 if !console_instance.is_plain() {
527 }
531
532 let escaped_cmd = command.replace('"', "\\\"");
534 let truncated_cmd = truncate_for_display(&escaped_cmd, 45);
535 let explain_cmd = format!("dcg explain \"{truncated_cmd}\"");
536
537 let footer_style = if theme.colors_enabled { "\x1b[90m" } else { "" }; let reset = if theme.colors_enabled { "\x1b[0m" } else { "" };
540 let cyan = if theme.colors_enabled { "\x1b[36m" } else { "" };
541
542 eprintln!("{footer_style}Learn more:{reset}");
543 eprintln!(" $ {cyan}{explain_cmd}{reset}");
544
545 if let Some(ref rule) = rule_id {
546 eprintln!(" $ {cyan}dcg allowlist add {rule} --project{reset}");
547 }
548
549 eprintln!();
550 eprintln!("{footer_style}False positive? File an issue:{reset}");
551 eprintln!(
552 "{footer_style}https://github.com/Dicklesworthstone/destructive_command_guard/issues/new?template=false_positive.yml{reset}"
553 );
554 eprintln!();
555}
556
557#[cfg(feature = "rich-output")]
558#[allow(dead_code)] fn render_suggestions_panel(suggestions: &[PatternSuggestion]) -> String {
560 use rich_rust::r#box::ROUNDED;
561 use rich_rust::prelude::*;
562
563 let mut lines = Vec::new();
565 if !crate::output::suggestions_enabled() {
566 return String::new();
567 }
568
569 let filtered: Vec<&PatternSuggestion> = suggestions
570 .iter()
571 .filter(|s| s.platform.matches_current())
572 .take(MAX_SUGGESTIONS)
573 .collect();
574
575 for (i, s) in filtered.iter().enumerate() {
576 lines.push(format!("[bold cyan]{}.[/] {}", i + 1, s.description));
577 lines.push(format!(" [green]$[/] [cyan]{}[/]", s.command));
578 }
579 let content_str = lines.join("\n");
580
581 let width = crate::output::terminal_width() as usize;
582 Panel::from_text(&content_str)
583 .title("[yellow bold] 💡 Suggestions [/]")
584 .box_style(&ROUNDED)
585 .border_style(Style::new().color(Color::parse("yellow").unwrap_or_default()))
586 .render_plain(width)
587}
588
589fn truncate_for_display(s: &str, max_len: usize) -> String {
591 if s.len() <= max_len {
592 s.to_string()
593 } else {
594 let target = max_len.saturating_sub(3);
596 let boundary = s
597 .char_indices()
598 .take_while(|(i, _)| *i < target)
599 .last()
600 .map_or(0, |(i, c)| i + c.len_utf8());
601 format!("{}...", &s[..boundary])
602 }
603}
604
605fn get_contextual_suggestion(command: &str) -> Option<&'static str> {
607 if command.contains("reset") || command.contains("checkout") {
608 Some("Consider using 'git stash' first to save your changes.")
609 } else if command.contains("clean") {
610 Some("Use 'git clean -n' first to preview what would be deleted.")
611 } else if command.contains("push") && command.contains("force") {
612 Some("Consider using '--force-with-lease' for safer force pushing.")
613 } else if command.contains("rm -rf") || command.contains("rm -r") {
614 Some("Verify the path carefully before running rm -rf manually.")
615 } else if command.contains("DROP") || command.contains("drop") {
616 Some("Consider backing up the database/table before dropping.")
617 } else if command.contains("kubectl") && command.contains("delete") {
618 Some("Use 'kubectl delete --dry-run=client' to preview changes first.")
619 } else if command.contains("docker") && command.contains("prune") {
620 Some("Use 'docker system df' to see what would be affected.")
621 } else if command.contains("terraform") && command.contains("destroy") {
622 Some("Use 'terraform plan -destroy' to preview changes first.")
623 } else {
624 None
625 }
626}
627
628#[cold]
630#[inline(never)]
631#[allow(clippy::too_many_arguments)]
632pub fn output_denial_for_protocol(
633 protocol: HookProtocol,
634 command: &str,
635 reason: &str,
636 pack: Option<&str>,
637 pattern: Option<&str>,
638 explanation: Option<&str>,
639 allow_once: Option<&AllowOnceInfo>,
640 matched_span: Option<&MatchSpan>,
641 severity: Option<crate::packs::Severity>,
642 confidence: Option<f64>,
643 pattern_suggestions: &[PatternSuggestion],
644) {
645 let allow_once_code = allow_once.map(|info| info.code.as_str());
647 print_colorful_warning(
648 command,
649 reason,
650 pack,
651 pattern,
652 explanation,
653 allow_once_code,
654 matched_span,
655 pattern_suggestions,
656 severity,
657 );
658
659 let message = format_denial_message(command, reason, explanation, pack, pattern);
661 let rule_id = build_rule_id(pack, pattern);
662 let remediation = allow_once.map(|info| {
663 let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
664 Remediation {
665 safe_alternative: get_contextual_suggestion(command).map(String::from),
666 explanation: explanation_text,
667 allow_once_command: format!("dcg allow-once {}", info.code),
668 }
669 });
670
671 let stdout = io::stdout();
672 let mut handle = stdout.lock();
673
674 match protocol {
675 HookProtocol::ClaudeCompatible => {
676 let output = HookOutput {
677 hook_specific_output: HookSpecificOutput {
678 hook_event_name: "PreToolUse",
679 permission_decision: "deny",
680 permission_decision_reason: Cow::Owned(message),
681 allow_once_code: allow_once.map(|info| info.code.clone()),
682 allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
683 rule_id,
684 pack_id: pack.map(String::from),
685 severity,
686 confidence,
687 remediation,
688 },
689 };
690
691 let _ = serde_json::to_writer(&mut handle, &output);
692 let _ = writeln!(handle);
693 }
694 HookProtocol::Copilot => {
695 let output = CopilotHookOutput {
696 continue_execution: false,
697 stop_reason: Cow::Owned(format!("BLOCKED by dcg: {reason}")),
698 permission_decision: "deny",
699 permission_decision_reason: Cow::Owned(message),
700 allow_once_code: allow_once.map(|info| info.code.clone()),
701 allow_once_full_hash: allow_once.map(|info| info.full_hash.clone()),
702 rule_id,
703 pack_id: pack.map(String::from),
704 severity,
705 confidence,
706 remediation,
707 };
708
709 let _ = serde_json::to_writer(&mut handle, &output);
710 let _ = writeln!(handle);
711 }
712 }
713}
714
715#[cold]
717#[inline(never)]
718#[allow(clippy::too_many_arguments)]
719pub fn output_denial(
720 command: &str,
721 reason: &str,
722 pack: Option<&str>,
723 pattern: Option<&str>,
724 explanation: Option<&str>,
725 allow_once: Option<&AllowOnceInfo>,
726 matched_span: Option<&MatchSpan>,
727 severity: Option<crate::packs::Severity>,
728 confidence: Option<f64>,
729 pattern_suggestions: &[PatternSuggestion],
730) {
731 output_denial_for_protocol(
732 HookProtocol::ClaudeCompatible,
733 command,
734 reason,
735 pack,
736 pattern,
737 explanation,
738 allow_once,
739 matched_span,
740 severity,
741 confidence,
742 pattern_suggestions,
743 );
744}
745
746#[cold]
748#[inline(never)]
749pub fn output_warning(
750 command: &str,
751 reason: &str,
752 pack: Option<&str>,
753 pattern: Option<&str>,
754 explanation: Option<&str>,
755) {
756 let stderr = io::stderr();
757 let mut handle = stderr.lock();
758
759 let _ = writeln!(handle);
760 let _ = writeln!(
761 handle,
762 "{} {}",
763 "dcg WARNING (allowed by policy):".yellow().bold(),
764 reason
765 );
766
767 let rule_id = build_rule_id(pack, pattern);
769 let explanation_text = format_explanation_text(explanation, rule_id.as_deref(), pack);
770 let mut explanation_lines = explanation_text.lines();
771
772 if let Some(first) = explanation_lines.next() {
773 let _ = writeln!(handle, " {} {}", "Explanation:".bright_black(), first);
774 for line in explanation_lines {
775 let _ = writeln!(handle, " {line}");
776 }
777 }
778
779 if let Some(ref rule) = rule_id {
780 let _ = writeln!(handle, " {} {}", "Rule:".bright_black(), rule);
781 } else if let Some(pack_name) = pack {
782 let _ = writeln!(handle, " {} {}", "Pack:".bright_black(), pack_name);
783 }
784
785 let _ = writeln!(handle, " {} {}", "Command:".bright_black(), command);
786 let _ = writeln!(
787 handle,
788 " {}",
789 "No hook JSON deny was emitted; this warning is informational.".bright_black()
790 );
791}
792
793pub fn log_blocked_command(
800 log_file: &str,
801 command: &str,
802 reason: &str,
803 pack: Option<&str>,
804) -> io::Result<()> {
805 use std::fs::OpenOptions;
806
807 let path = if log_file.starts_with("~/") {
809 dirs::home_dir().map_or_else(
810 || std::path::PathBuf::from(log_file),
811 |h| h.join(&log_file[2..]),
812 )
813 } else {
814 std::path::PathBuf::from(log_file)
815 };
816
817 if let Some(parent) = path.parent() {
819 std::fs::create_dir_all(parent)?;
820 }
821
822 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
823
824 let timestamp = chrono_lite_timestamp();
825 let pack_str = pack.unwrap_or("unknown");
826
827 writeln!(file, "[{timestamp}] [{pack_str}] {reason}")?;
828 writeln!(file, " Command: {command}")?;
829 writeln!(file)?;
830
831 Ok(())
832}
833
834pub fn log_budget_skip(
841 log_file: &str,
842 command: &str,
843 stage: &str,
844 elapsed: Duration,
845 budget: Duration,
846) -> io::Result<()> {
847 use std::fs::OpenOptions;
848
849 let path = if log_file.starts_with("~/") {
851 dirs::home_dir().map_or_else(
852 || std::path::PathBuf::from(log_file),
853 |h| h.join(&log_file[2..]),
854 )
855 } else {
856 std::path::PathBuf::from(log_file)
857 };
858
859 if let Some(parent) = path.parent() {
861 std::fs::create_dir_all(parent)?;
862 }
863
864 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
865
866 let timestamp = chrono_lite_timestamp();
867 writeln!(
868 file,
869 "[{timestamp}] [budget] evaluation skipped due to budget at {stage}"
870 )?;
871 writeln!(
872 file,
873 " Budget: {}ms, Elapsed: {}ms",
874 budget.as_millis(),
875 elapsed.as_millis()
876 )?;
877 writeln!(file, " Command: {command}")?;
878 writeln!(file)?;
879
880 Ok(())
881}
882
883fn chrono_lite_timestamp() -> String {
886 use std::time::{SystemTime, UNIX_EPOCH};
887
888 let duration = SystemTime::now()
889 .duration_since(UNIX_EPOCH)
890 .unwrap_or_default();
891
892 let secs = duration.as_secs();
893 format!("{secs}")
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use std::sync::Mutex;
900
901 static ENV_LOCK: Mutex<()> = Mutex::new(());
902
903 struct EnvVarGuard {
904 key: &'static str,
905 previous: Option<String>,
906 }
907
908 impl EnvVarGuard {
909 fn set(key: &'static str, value: &str) -> Self {
910 let previous = std::env::var(key).ok();
911 unsafe { std::env::set_var(key, value) };
914 Self { key, previous }
915 }
916
917 #[allow(dead_code)]
918 fn remove(key: &'static str) -> Self {
919 let previous = std::env::var(key).ok();
920 unsafe { std::env::remove_var(key) };
923 Self { key, previous }
924 }
925 }
926
927 impl Drop for EnvVarGuard {
928 fn drop(&mut self) {
929 if let Some(value) = self.previous.take() {
930 unsafe { std::env::set_var(self.key, value) };
933 } else {
934 unsafe { std::env::remove_var(self.key) };
937 }
938 }
939 }
940
941 #[test]
942 fn test_parse_valid_bash_input() {
943 let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
944 let input: HookInput = serde_json::from_str(json).unwrap();
945 assert_eq!(extract_command(&input), Some("git status".to_string()));
946 }
947
948 #[test]
949 fn test_parse_non_bash_input() {
950 let json = r#"{"tool_name":"Read","tool_input":{"command":"git status"}}"#;
951 let input: HookInput = serde_json::from_str(json).unwrap();
952 assert_eq!(extract_command(&input), None);
953 }
954
955 #[test]
956 fn test_parse_missing_command() {
957 let json = r#"{"tool_name":"Bash","tool_input":{}}"#;
958 let input: HookInput = serde_json::from_str(json).unwrap();
959 assert_eq!(extract_command(&input), None);
960 }
961
962 #[test]
963 fn test_parse_copilot_tool_input_command() {
964 let json = r#"{"event":"pre-tool-use","toolName":"run_shell_command","toolInput":{"command":"git status"}}"#;
965 let input: HookInput = serde_json::from_str(json).unwrap();
966 assert_eq!(extract_command(&input), Some("git status".to_string()));
967 assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
968 }
969
970 #[test]
971 fn test_parse_copilot_tool_args_json_string() {
972 let json = r#"{"event":"pre-tool-use","toolName":"bash","toolArgs":"{\"command\":\"rm -rf /tmp/build\"}"}"#;
973 let input: HookInput = serde_json::from_str(json).unwrap();
974 assert_eq!(
975 extract_command(&input),
976 Some("rm -rf /tmp/build".to_string())
977 );
978 assert_eq!(detect_protocol(&input), HookProtocol::Copilot);
979 }
980
981 #[test]
982 fn test_parse_non_string_command() {
983 let json = r#"{"tool_name":"Bash","tool_input":{"command":123}}"#;
984 let input: HookInput = serde_json::from_str(json).unwrap();
985 assert_eq!(extract_command(&input), None);
986 }
987
988 #[test]
989 fn test_format_denial_message_includes_explanation_and_rule() {
990 let message = format_denial_message(
991 "git reset --hard",
992 "destructive",
993 Some("This is irreversible."),
994 Some("core.git"),
995 Some("reset-hard"),
996 );
997
998 assert!(message.contains("Reason: destructive"));
999 assert!(message.contains("Explanation: This is irreversible."));
1000 assert!(message.contains("Rule: core.git:reset-hard"));
1001 assert!(message.contains("Tip: dcg explain"));
1002 }
1003
1004 #[test]
1005 fn test_env_var_guard_restores_value() {
1006 let _lock = ENV_LOCK.lock().unwrap();
1007 let key = "DCG_TEST_ENV_GUARD";
1008 unsafe { std::env::remove_var(key) };
1010
1011 {
1012 let _guard = EnvVarGuard::set(key, "1");
1013 assert_eq!(std::env::var(key).as_deref(), Ok("1"));
1014 }
1015
1016 assert!(std::env::var(key).is_err());
1017 }
1018}