use crate::evaluator::{DEFAULT_WINDOW_WIDTH, MatchSpan, WindowedSpan, window_command};
use colored::Colorize;
use std::fmt::Write;
use std::io::{self, IsTerminal};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HighlightSpan {
pub start: usize,
pub end: usize,
pub label: Option<String>,
}
impl HighlightSpan {
#[must_use]
pub const fn new(start: usize, end: usize) -> Self {
Self {
start,
end,
label: None,
}
}
#[must_use]
pub fn with_label(start: usize, end: usize, label: impl Into<String>) -> Self {
Self {
start,
end,
label: Some(label.into()),
}
}
#[must_use]
pub const fn to_match_span(&self) -> MatchSpan {
MatchSpan {
start: self.start,
end: self.end,
}
}
}
#[derive(Debug, Clone)]
pub struct HighlightedCommand {
pub command_line: String,
pub caret_line: String,
pub label_line: Option<String>,
}
impl HighlightedCommand {
#[must_use]
pub fn to_string_with_prefix(&self, prefix: &str) -> String {
let mut result = format!("{prefix}{}\n", self.command_line);
let _ = writeln!(result, "{prefix}{}", self.caret_line);
if let Some(label) = &self.label_line {
let _ = writeln!(result, "{prefix}{label}");
}
result
}
}
#[must_use]
pub fn should_use_color() -> bool {
if std::env::var_os("NO_COLOR").is_some() || crate::output::env_flag_enabled("DCG_NO_COLOR") {
return false;
}
if matches!(std::env::var("TERM").as_deref(), Ok("dumb")) {
return false;
}
io::stderr().is_terminal()
}
pub fn configure_colors() {
if !should_use_color() {
colored::control::set_override(false);
}
}
fn build_caret_line(span: &WindowedSpan, use_color: bool) -> String {
let leading_spaces = " ".repeat(span.start);
let caret_count = span.end.saturating_sub(span.start).max(1);
let carets = "^".repeat(caret_count);
if use_color {
format!("{leading_spaces}{}", carets.red().bold())
} else {
format!("{leading_spaces}{carets}")
}
}
fn build_label_line(span: &WindowedSpan, label: &str, use_color: bool) -> String {
let leading_spaces = " ".repeat(span.start);
let connector = "└── ";
if use_color {
let colored_label = label.yellow();
format!("{leading_spaces}{}{colored_label}", connector.dimmed())
} else {
format!("{leading_spaces}{connector}{label}")
}
}
#[must_use]
pub fn format_highlighted_command(
command: &str,
span: &HighlightSpan,
use_color: bool,
max_width: usize,
) -> HighlightedCommand {
let match_span = span.to_match_span();
let windowed = window_command(command, &match_span, max_width);
let command_line = if use_color {
colorize_command_with_span(&windowed.display, windowed.adjusted_span.as_ref())
} else {
windowed.display.clone()
};
let (caret_line, label_line) = windowed.adjusted_span.map_or_else(
|| {
let fallback_caret = if use_color {
"^".red().bold().to_string()
} else {
"^".to_string()
};
(fallback_caret, None)
},
|adj_span| {
let caret = build_caret_line(&adj_span, use_color);
let label = span
.label
.as_ref()
.map(|l| build_label_line(&adj_span, l, use_color));
(caret, label)
},
);
HighlightedCommand {
command_line,
caret_line,
label_line,
}
}
fn colorize_command_with_span(command: &str, span: Option<&WindowedSpan>) -> String {
let Some(span) = span else {
return command.to_string();
};
let chars: Vec<char> = command.chars().collect();
if span.start >= chars.len() || span.end > chars.len() || span.start >= span.end {
return command.to_string();
}
let before_end: usize = chars[..span.start].iter().map(|c| c.len_utf8()).sum();
let match_end: usize = chars[..span.end].iter().map(|c| c.len_utf8()).sum();
let before = &command[..before_end];
let matched = &command[before_end..match_end];
let after = &command[match_end..];
format!("{before}{}{}", matched.red().bold(), after)
}
#[must_use]
pub fn format_regex_pattern(pattern: &str, use_color: bool) -> String {
if !use_color || pattern.is_empty() {
return pattern.to_string();
}
#[cfg(feature = "rich-output")]
if let Some(rendered) = render_regex_pattern_rich(pattern) {
return rendered;
}
format_regex_pattern_manual(pattern)
}
#[must_use]
pub fn format_markdown_explanation(text: &str, use_color: bool, width: usize) -> String {
#[cfg(not(feature = "rich-output"))]
let _ = (use_color, width);
let text = text.trim();
if text.is_empty() {
return String::new();
}
#[cfg(feature = "rich-output")]
if use_color {
if let Some(rendered) = render_markdown_explanation_rich(text, width) {
return rendered;
}
}
strip_markdown_formatting(text)
}
#[must_use]
pub fn find_pattern_regex(pack_id: &str, pattern_name: &str) -> Option<String> {
let pack = crate::packs::REGISTRY.get(pack_id)?;
if let Some(pattern) = pack
.safe_patterns
.iter()
.find(|pattern| pattern.name == pattern_name)
{
return Some(pattern.regex.as_str().to_string());
}
pack.destructive_patterns
.iter()
.find(|pattern| pattern.name == Some(pattern_name))
.map(|pattern| pattern.regex.as_str().to_string())
}
#[cfg(feature = "rich-output")]
fn render_markdown_explanation_rich(text: &str, width: usize) -> Option<String> {
use rich_rust::prelude::{Console, Markdown};
let markdown = Markdown::new(text).hyperlinks(false);
let segments = markdown.render(width.max(1));
let mut rendered = Vec::new();
if Console::new()
.print_segments_to(&mut rendered, &segments)
.is_err()
{
return None;
}
let text = String::from_utf8(rendered).ok()?;
let text = text.trim_end_matches(['\r', '\n']).to_string();
if text.is_empty() { None } else { Some(text) }
}
fn strip_markdown_formatting(text: &str) -> String {
text.lines()
.map(strip_markdown_line)
.collect::<Vec<_>>()
.join("\n")
}
fn strip_markdown_line(line: &str) -> String {
let trimmed = line.trim_start();
let leading_len = line.len() - trimmed.len();
let leading = &line[..leading_len];
if let Some(stripped) = strip_markdown_heading(trimmed) {
return format!("{leading}{}", strip_markdown_inline(stripped));
}
if let Some(item) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
return format!("{leading}- {}", strip_markdown_inline(item));
}
strip_markdown_inline(line)
}
fn strip_markdown_heading(line: &str) -> Option<&str> {
let hash_count = line.chars().take_while(|ch| *ch == '#').count();
if hash_count == 0 || hash_count > 6 {
return None;
}
let rest = &line[hash_count..];
if rest.chars().next().is_some_and(char::is_whitespace) {
Some(rest.trim_start())
} else {
None
}
}
fn strip_markdown_inline(text: &str) -> String {
let mut output = String::with_capacity(text.len());
let mut index = 0;
while index < text.len() {
let Some(ch) = text[index..].chars().next() else {
break;
};
if ch == '!' {
if let Some((replacement, next_index)) = parse_markdown_link(text, index + 1, false) {
output.push_str(&replacement);
index = next_index;
continue;
}
} else if ch == '[' {
if let Some((replacement, next_index)) = parse_markdown_link(text, index, true) {
output.push_str(&replacement);
index = next_index;
continue;
}
}
if matches!(ch, '`' | '*' | '_' | '~') {
index += ch.len_utf8();
continue;
}
output.push(ch);
index += ch.len_utf8();
}
output
}
fn parse_markdown_link(
text: &str,
open_bracket_index: usize,
include_url: bool,
) -> Option<(String, usize)> {
let label_start = open_bracket_index.checked_add(1)?;
let label_rest = text.get(label_start..)?;
let label_end_relative = label_rest.find(']')?;
let label = &label_rest[..label_end_relative];
let after_label_index = label_start + label_end_relative + 1;
let after_label = text.get(after_label_index..)?;
let url_rest = after_label.strip_prefix('(')?;
let url_end_relative = url_rest.find(')')?;
let url = &url_rest[..url_end_relative];
let next_index = after_label_index + 1 + url_end_relative + 1;
let label = strip_markdown_inline(label);
let rendered = if include_url && !url.is_empty() {
format!("{label} ({url})")
} else {
label
};
Some((rendered, next_index))
}
#[cfg(feature = "rich-output")]
fn render_regex_pattern_rich(pattern: &str) -> Option<String> {
use rich_rust::prelude::{Console, Syntax};
const REGEX_LANGUAGE_ALIASES: &[&str] = &["regex", "re", "Regular Expressions"];
const THEME_ALIASES: &[&str] = &["python-rich-default", "base16-ocean.dark", "InspiredGitHub"];
for language in REGEX_LANGUAGE_ALIASES {
for theme in THEME_ALIASES {
let syntax = Syntax::new(pattern, *language)
.theme(*theme)
.line_numbers(false)
.padding(0, 0);
let Ok(segments) = syntax.render(None) else {
continue;
};
let mut rendered = Vec::new();
if Console::new()
.print_segments_to(&mut rendered, &segments)
.is_err()
{
continue;
}
let Ok(text) = String::from_utf8(rendered) else {
continue;
};
let trimmed = text.trim_end_matches(['\r', '\n']).to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
}
None
}
fn format_regex_pattern_manual(pattern: &str) -> String {
let mut result = String::with_capacity(pattern.len() + 32);
let mut chars = pattern.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
result.push_str(&ch.to_string().dimmed().to_string());
if let Some(escaped) = chars.next() {
result.push_str(&escaped.to_string().bright_cyan().to_string());
}
continue;
}
let styled = match ch {
'^' | '$' => ch.to_string().cyan().bold().to_string(),
'*' | '+' | '?' | '{' | '}' => ch.to_string().yellow().bold().to_string(),
'(' | ')' => ch.to_string().green().bold().to_string(),
'[' | ']' => ch.to_string().blue().bold().to_string(),
'|' => ch.to_string().magenta().bold().to_string(),
'.' => ch.to_string().bright_black().bold().to_string(),
_ => ch.to_string(),
};
result.push_str(&styled);
}
result
}
#[must_use]
pub fn format_highlighted_command_auto(command: &str, span: &HighlightSpan) -> HighlightedCommand {
format_highlighted_command(command, span, should_use_color(), DEFAULT_WINDOW_WIDTH)
}
#[must_use]
pub fn format_highlighted_command_multi(
command: &str,
spans: &[HighlightSpan],
use_color: bool,
max_width: usize,
) -> Vec<HighlightedCommand> {
spans
.iter()
.map(|span| format_highlighted_command(command, span, use_color, max_width))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
static COLOR_OVERRIDE_LOCK: Mutex<()> = Mutex::new(());
struct ColorOverrideGuard {
_lock: MutexGuard<'static, ()>,
}
impl ColorOverrideGuard {
fn force_color() -> Self {
let lock = COLOR_OVERRIDE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
colored::control::set_override(true);
Self { _lock: lock }
}
}
impl Drop for ColorOverrideGuard {
fn drop(&mut self) {
colored::control::unset_override();
}
}
#[test]
fn test_highlight_span_new() {
let span = HighlightSpan::new(5, 10);
assert_eq!(span.start, 5);
assert_eq!(span.end, 10);
assert!(span.label.is_none());
}
#[test]
fn test_highlight_span_with_label() {
let span = HighlightSpan::with_label(0, 16, "test label");
assert_eq!(span.start, 0);
assert_eq!(span.end, 16);
assert_eq!(span.label.as_deref(), Some("test label"));
}
#[test]
fn test_format_simple_command() {
let cmd = "git reset --hard HEAD";
let span = HighlightSpan::new(0, 16);
let result = format_highlighted_command(cmd, &span, false, 80);
assert_eq!(result.command_line, cmd);
assert!(result.caret_line.starts_with('^'));
assert_eq!(result.caret_line.matches('^').count(), 16);
}
#[test]
fn test_format_with_label() {
let cmd = "git reset --hard HEAD";
let span = HighlightSpan::with_label(0, 16, "Matched: git reset");
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(result.label_line.is_some());
let label = result.label_line.unwrap();
assert!(label.contains("└──"));
assert!(label.contains("Matched: git reset"));
}
#[test]
fn test_format_middle_span() {
let cmd = "echo test && git reset --hard && echo done";
let span = HighlightSpan::new(13, 29);
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(result.caret_line.starts_with(" "));
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_format_long_command_windowed() {
let prefix = "a ".repeat(50);
let suffix = " b".repeat(50);
let cmd = format!("{prefix}git reset --hard{suffix}");
let start = prefix.len();
let span = HighlightSpan::with_label(start, start + 16, "dangerous");
let result = format_highlighted_command(&cmd, &span, false, 60);
assert!(result.command_line.contains("..."));
assert!(result.command_line.contains("git reset --hard"));
}
#[test]
fn test_format_utf8_command() {
let cmd = "echo 'héllo wörld' && rm -rf /tmp/test";
let span = HighlightSpan::new(24, 31); let result = format_highlighted_command(cmd, &span, false, 80);
assert!(!result.command_line.is_empty());
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_format_empty_span() {
let cmd = "git status";
let span = HighlightSpan::new(5, 5);
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_format_span_at_end() {
let cmd = "echo test && git push --force";
let end = cmd.len();
let span = HighlightSpan::new(end - 12, end);
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_format_windowing_limits_width() {
let prefix = "a ".repeat(60);
let suffix = " b".repeat(60);
let cmd = format!("{prefix}git reset --hard{suffix}");
let start = prefix.len();
let span = HighlightSpan::with_label(start, start + 16, "Matched: git reset --hard");
let max_width = 50;
let result = format_highlighted_command(&cmd, &span, false, max_width);
assert!(result.command_line.contains("git reset --hard"));
assert!(result.command_line.contains("..."));
assert!(result.command_line.chars().count() <= max_width);
assert!(result.caret_line.find('^').unwrap_or(0) >= 3);
}
#[test]
fn test_format_utf8_windowing_alignment() {
let prefix = "é".repeat(40);
let cmd = format!("{prefix} rm -rf /tmp/test tail");
let start = prefix.len() + 1;
let matched = "rm -rf /tmp/test";
let span = HighlightSpan::new(start, start + matched.len());
let result = format_highlighted_command(&cmd, &span, false, 30);
assert!(result.command_line.contains(matched));
assert!(result.command_line.contains("..."));
assert_eq!(result.caret_line.matches('^').count(), matched.len());
assert!(result.caret_line.find('^').unwrap_or(0) >= 3);
}
#[test]
fn test_format_no_ansi_when_color_disabled() {
let cmd = "git reset --hard HEAD";
let span = HighlightSpan::with_label(0, 16, "Matched: git reset");
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(!result.command_line.contains('\u{1b}'));
assert!(!result.caret_line.contains('\u{1b}'));
if let Some(label) = result.label_line {
assert!(!label.contains('\u{1b}'));
}
}
#[test]
fn test_build_caret_line_no_color() {
let span = WindowedSpan { start: 5, end: 10 };
let caret = build_caret_line(&span, false);
assert_eq!(caret, " ^^^^^");
}
#[test]
fn test_build_label_line_no_color() {
let span = WindowedSpan { start: 5, end: 10 };
let label = build_label_line(&span, "test", false);
assert!(label.starts_with(" └── "));
assert!(label.ends_with("test"));
}
#[test]
fn test_format_highlighted_command_auto() {
let cmd = "git reset --hard";
let span = HighlightSpan::new(0, 16);
let result = format_highlighted_command_auto(cmd, &span);
assert!(!result.command_line.is_empty());
assert!(!result.caret_line.is_empty());
}
#[test]
fn test_format_highlighted_command_multi() {
let cmd = "git reset --hard && rm -rf /tmp";
let spans = vec![
HighlightSpan::with_label(0, 16, "reset"),
HighlightSpan::with_label(20, 26, "rm -rf"),
];
let results = format_highlighted_command_multi(cmd, &spans, false, 80);
assert_eq!(results.len(), 2);
assert!(results[0].label_line.as_ref().unwrap().contains("reset"));
assert!(results[1].label_line.as_ref().unwrap().contains("rm -rf"));
}
#[test]
fn test_highlighted_command_to_string() {
let cmd = "git reset --hard";
let span = HighlightSpan::with_label(0, 16, "Matched");
let result = format_highlighted_command(cmd, &span, false, 80);
let output = result.to_string_with_prefix(" ");
assert!(output.contains(" git reset"));
assert!(output.contains(" ^"));
assert!(output.contains(" └──"));
}
#[test]
fn test_colorize_command_with_span() {
let cmd = "git reset --hard";
let span = WindowedSpan { start: 0, end: 16 };
let result = colorize_command_with_span(cmd, Some(&span));
assert!(!result.is_empty());
}
#[test]
fn test_should_use_color_respects_no_color() {
let _ = should_use_color();
}
#[test]
fn test_format_markdown_explanation_plain_strips_inline_markup() {
let rendered = format_markdown_explanation(
"Use `git stash` before **reset**. See [docs](https://example.test).",
false,
80,
);
assert!(rendered.contains("git stash"));
assert!(rendered.contains("reset"));
assert!(rendered.contains("docs (https://example.test)"));
assert!(!rendered.contains('`'));
assert!(!rendered.contains("**"));
}
#[test]
fn test_format_markdown_explanation_plain_handles_blocks() {
let rendered =
format_markdown_explanation("# Danger\n- run `git status`\n* ask **first**", false, 80);
assert_eq!(
rendered, "Danger\n- run git status\n- ask first",
"plain fallback should keep readable block structure"
);
}
#[test]
fn test_format_markdown_explanation_plain_keeps_image_alt_text() {
let rendered = format_markdown_explanation("Review .", false, 80);
assert_eq!(rendered, "Review diagram.");
}
#[test]
fn test_utf8_2byte_chars_caret_alignment() {
let cmd = "echo café && rm -rf /";
let byte_start = "echo café && ".len(); let span = HighlightSpan::new(byte_start, byte_start + 8); let result = format_highlighted_command(cmd, &span, false, 80);
assert_eq!(result.command_line, cmd);
assert!(result.caret_line.contains('^'));
let caret_count = result.caret_line.matches('^').count();
assert!(caret_count > 0, "Expected carets for the match");
}
#[test]
fn test_utf8_3byte_chars_caret_alignment() {
let cmd = "echo 䏿–‡ && git reset --hard";
let byte_start = "echo 䏿–‡ && ".len();
let span = HighlightSpan::new(byte_start, byte_start + 16);
let result = format_highlighted_command(cmd, &span, false, 80);
assert_eq!(result.command_line, cmd);
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_utf8_4byte_emoji_caret_alignment() {
let cmd = "echo 🔥🔥🔥 && rm -rf /tmp";
let byte_start = "echo 🔥🔥🔥 && ".len();
let span = HighlightSpan::new(byte_start, byte_start + 6); let result = format_highlighted_command(cmd, &span, false, 80);
assert_eq!(result.command_line, cmd);
assert!(result.caret_line.contains('^'));
let leading_spaces = result.caret_line.len() - result.caret_line.trim_start().len();
assert_eq!(leading_spaces, 12);
}
#[test]
fn test_utf8_mixed_multibyte_alignment() {
let cmd = "café 䏿–‡ 🎉 rm -rf /";
let byte_start = "café 䏿–‡ 🎉 ".len();
let span = HighlightSpan::new(byte_start, byte_start + 8);
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(!result.command_line.is_empty());
let leading_spaces = result.caret_line.len() - result.caret_line.trim_start().len();
assert_eq!(leading_spaces, 10);
}
#[test]
fn test_utf8_span_at_multibyte_boundary() {
let cmd = "echo 🔥 test";
let span = HighlightSpan::new(6, 10);
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(!result.command_line.is_empty());
}
#[test]
fn test_utf8_full_width_chars() {
let cmd = "echo å…¨è§’æ–‡å— && rm -rf";
let byte_start = "echo å…¨è§’æ–‡å— && ".len();
let span = HighlightSpan::new(byte_start, cmd.len());
let result = format_highlighted_command(cmd, &span, false, 80);
assert!(!result.command_line.is_empty());
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_windowing_match_at_exact_start() {
let match_text = "git reset --hard";
let suffix = " && ".to_string() + &"x".repeat(100);
let cmd = format!("{match_text}{suffix}");
let span = HighlightSpan::new(0, 16);
let result = format_highlighted_command(&cmd, &span, false, 40);
assert!(!result.command_line.starts_with("..."));
assert!(result.command_line.ends_with("..."));
assert!(result.command_line.contains("git reset --hard"));
assert!(result.caret_line.starts_with('^'));
}
#[test]
fn test_windowing_match_at_exact_end() {
let prefix = "x".repeat(100) + " && ";
let match_text = "git reset --hard";
let cmd = format!("{prefix}{match_text}");
let span = HighlightSpan::new(prefix.len(), cmd.len());
let result = format_highlighted_command(&cmd, &span, false, 40);
assert!(result.command_line.starts_with("..."));
assert!(!result.command_line.ends_with("..."));
assert!(result.command_line.contains("git reset --hard"));
}
#[test]
fn test_windowing_match_larger_than_window() {
let match_text = "a".repeat(50);
let cmd = format!("prefix {match_text} suffix");
let span = HighlightSpan::new(7, 57);
let result = format_highlighted_command(&cmd, &span, false, 30);
assert!(result.command_line.contains("..."));
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_windowing_very_narrow_window() {
let cmd = "git reset --hard HEAD";
let span = HighlightSpan::new(0, 16);
let result = format_highlighted_command(cmd, &span, false, 10);
assert!(!result.command_line.is_empty());
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_windowing_with_utf8_maintains_alignment() {
let prefix = "café ".repeat(20);
let match_text = "rm -rf /";
let suffix = " done".repeat(20);
let cmd = format!("{prefix}{match_text}{suffix}");
let span = HighlightSpan::new(prefix.len(), prefix.len() + 8);
let result = format_highlighted_command(&cmd, &span, false, 40);
assert!(result.command_line.contains("..."));
assert!(result.command_line.contains("rm -rf /"));
let carets_start = result.caret_line.chars().take_while(|c| *c == ' ').count();
if let Some(byte_pos) = result.command_line.find("rm -rf /") {
let match_start = result.command_line[..byte_pos].chars().count();
assert_eq!(
carets_start, match_start,
"Carets at {} should align with match at {} in '{}'",
carets_start, match_start, result.command_line
);
} else {
panic!("Match text not found in windowed command");
}
}
#[test]
fn test_no_ansi_escapes_when_color_disabled() {
let cmd = "git reset --hard HEAD";
let span = HighlightSpan::with_label(0, 16, "Dangerous");
let result = format_highlighted_command(cmd, &span, false, 80);
let ansi_escape = '\x1b';
assert!(
!result.command_line.contains(ansi_escape),
"Command line should not contain ANSI escapes when color is disabled"
);
assert!(
!result.caret_line.contains(ansi_escape),
"Caret line should not contain ANSI escapes when color is disabled"
);
if let Some(label) = &result.label_line {
assert!(
!label.contains(ansi_escape),
"Label line should not contain ANSI escapes when color is disabled"
);
}
}
#[test]
fn test_ansi_escapes_present_when_color_enabled() {
let _color_override = ColorOverrideGuard::force_color();
let cmd = "git reset --hard HEAD";
let span = HighlightSpan::with_label(0, 16, "Dangerous");
let result = format_highlighted_command(cmd, &span, true, 80);
let ansi_escape = '\x1b';
assert!(
result.caret_line.contains(ansi_escape),
"Caret line should contain ANSI escapes when color is enabled"
);
}
#[test]
fn test_colorize_command_produces_ansi_codes() {
let _color_override = ColorOverrideGuard::force_color();
let cmd = "git reset --hard";
let span = WindowedSpan { start: 0, end: 16 };
let result = colorize_command_with_span(cmd, Some(&span));
let ansi_escape = '\x1b';
assert!(
result.contains(ansi_escape),
"Colorized command should contain ANSI escapes"
);
}
#[test]
fn test_format_regex_pattern_no_color_is_exact() {
let pattern = r"^git\s+reset\s+--hard(?:\s|$)";
assert_eq!(format_regex_pattern(pattern, false), pattern);
}
#[test]
fn test_find_pattern_regex_finds_builtin_safe_pattern() {
let regex = find_pattern_regex("core.git", "restore-staged-long")
.expect("core.git restore-staged-long regex should be present");
assert!(regex.contains("restore"));
assert!(regex.contains("--staged"));
}
#[test]
fn test_find_pattern_regex_finds_builtin_destructive_pattern() {
let regex = find_pattern_regex("core.git", "reset-hard")
.expect("core.git reset-hard regex should be present");
assert!(regex.contains("reset"));
assert!(regex.contains("--hard"));
}
#[test]
fn test_find_pattern_regex_returns_none_for_unknown_pattern() {
assert!(find_pattern_regex("core.git", "not-a-real-pattern").is_none());
}
#[test]
fn test_manual_regex_pattern_highlights_metacharacters() {
let _color_override = ColorOverrideGuard::force_color();
let pattern = r"^(git|rm)\s+.+$";
let highlighted = format_regex_pattern_manual(pattern);
assert!(highlighted.contains('\x1b'));
assert!(highlighted.contains("git"));
assert!(highlighted.contains("rm"));
assert!(highlighted.contains('\\'));
}
#[test]
fn test_no_color_for_build_caret_line() {
let span = WindowedSpan { start: 3, end: 8 };
let result = build_caret_line(&span, false);
assert_eq!(result, " ^^^^^");
assert!(!result.contains('\x1b'));
}
#[test]
fn test_color_for_build_caret_line() {
let _color_override = ColorOverrideGuard::force_color();
let span = WindowedSpan { start: 3, end: 8 };
let result = build_caret_line(&span, true);
assert!(result.contains('\x1b'));
assert!(result.contains('^'));
}
#[test]
fn test_no_color_for_build_label_line() {
let span = WindowedSpan { start: 5, end: 10 };
let result = build_label_line(&span, "Test Label", false);
assert!(result.starts_with(" └── "));
assert!(result.ends_with("Test Label"));
assert!(!result.contains('\x1b'));
}
#[test]
fn test_color_for_build_label_line() {
let _color_override = ColorOverrideGuard::force_color();
let span = WindowedSpan { start: 5, end: 10 };
let result = build_label_line(&span, "Test Label", true);
assert!(result.contains('\x1b'));
assert!(result.contains("Test Label"));
}
#[test]
fn test_caret_count_matches_span_length() {
let cmd = "echo test && git push --force";
let span = HighlightSpan::new(13, 29); let result = format_highlighted_command(cmd, &span, false, 80);
let caret_count = result.caret_line.matches('^').count();
assert_eq!(caret_count, 16, "Caret count should match span length");
}
#[test]
fn test_caret_position_matches_span_start() {
let cmd = "prefix && git reset --hard";
let span_start = 10; let span = HighlightSpan::new(span_start, span_start + 16);
let result = format_highlighted_command(cmd, &span, false, 80);
let leading_spaces = result.caret_line.len() - result.caret_line.trim_start().len();
assert_eq!(
leading_spaces, span_start,
"Leading spaces should match span start position"
);
}
#[test]
fn test_caret_alignment_after_windowing() {
let prefix = "x".repeat(50);
let match_text = "git reset --hard";
let suffix = "y".repeat(50);
let cmd = format!("{prefix}{match_text}{suffix}");
let span = HighlightSpan::new(50, 66);
let result = format_highlighted_command(&cmd, &span, false, 40);
let match_pos = result.command_line.find("git reset").unwrap_or(0);
let caret_start = result.caret_line.find('^').unwrap_or(0);
assert_eq!(
caret_start, match_pos,
"Carets should align with match in windowed command"
);
}
#[test]
fn test_label_alignment_matches_carets() {
let cmd = "echo test && rm -rf /";
let span = HighlightSpan::with_label(13, 21, "Dangerous!");
let result = format_highlighted_command(cmd, &span, false, 80);
let caret_start = result.caret_line.len() - result.caret_line.trim_start().len();
let label = result.label_line.expect("Should have label");
let label_start = label.len() - label.trim_start().len();
assert_eq!(
caret_start, label_start,
"Label line should align with caret line"
);
}
#[test]
fn test_zero_length_span_shows_one_caret() {
let cmd = "git status";
let span = HighlightSpan::new(4, 4); let result = format_highlighted_command(cmd, &span, false, 80);
let caret_count = result.caret_line.matches('^').count();
assert!(
caret_count >= 1,
"Should show at least one caret for empty span"
);
}
#[test]
fn test_span_beyond_command_end_handles_gracefully() {
let cmd = "short";
let span = HighlightSpan::new(0, 100); let result = format_highlighted_command(cmd, &span, false, 80);
assert!(!result.command_line.is_empty());
assert!(result.caret_line.contains('^'));
}
#[test]
fn test_inverted_span_handles_gracefully() {
let cmd = "git status";
let span = HighlightSpan::new(8, 2); let result = format_highlighted_command(cmd, &span, false, 80);
assert!(!result.command_line.is_empty());
}
#[test]
fn test_to_string_with_prefix_format() {
let cmd = "rm -rf /";
let span = HighlightSpan::with_label(0, 8, "Filesystem destruction");
let result = format_highlighted_command(cmd, &span, false, 80);
let output = result.to_string_with_prefix(">>> ");
for line in output.lines() {
assert!(
line.starts_with(">>> "),
"Line should start with prefix: {line}"
);
}
}
#[test]
fn test_output_has_consistent_line_count() {
let cmd = "git reset --hard";
let span_without_label = HighlightSpan::new(0, 16);
let span_with_label = HighlightSpan::with_label(0, 16, "Label");
let result_no_label = format_highlighted_command(cmd, &span_without_label, false, 80);
let result_with_label = format_highlighted_command(cmd, &span_with_label, false, 80);
let output_no_label = result_no_label.to_string_with_prefix("");
let output_with_label = result_with_label.to_string_with_prefix("");
assert_eq!(
output_no_label.lines().count(),
2,
"Output without label should have 2 lines"
);
assert_eq!(
output_with_label.lines().count(),
3,
"Output with label should have 3 lines"
);
}
}