use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use ansi_str::AnsiStr;
use unicode_width::UnicodeWidthStr;
use worktrunk::git::{Repository, parse_numstat_line};
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{ADDITION, DELETION};
use crate::display::format_relative_time_short;
use super::super::list::layout::{DiffDisplayConfig, DiffVariant};
pub(super) const FIELD_DELIM: char = '\x1f';
pub(super) const HASH_START: char = '\x01';
pub(super) const HASH_END: char = '\x00';
pub(super) const TIMESTAMP_WIDTH: usize = 4;
pub(super) fn batch_fetch_stats(
repo: &Repository,
hashes: &[String],
) -> HashMap<String, (usize, usize)> {
if hashes.is_empty() {
return HashMap::new();
}
let stdin_data = hashes.iter().map(|h| format!("{h}\n")).collect::<String>();
let Ok(repo_path) = repo.repo_path() else {
return HashMap::new();
};
let Ok(output) = Cmd::new("git")
.args(["diff-tree", "--numstat", "-r", "--root", "--stdin"])
.current_dir(repo_path)
.stdin_bytes(stdin_data)
.run()
else {
return HashMap::new();
};
let mut stats: HashMap<String, (usize, usize)> = HashMap::new();
let mut current_hash: Option<String> = None;
let mut current_stats = (0usize, 0usize);
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.chars().all(|c| c.is_ascii_hexdigit()) && (line.len() == 40 || line.len() == 64) {
if let Some(hash) = current_hash.take() {
stats.insert(hash, current_stats);
}
current_hash = Some(line.to_string());
current_stats = (0, 0);
} else if let Some((ins, del)) = parse_numstat_line(line) {
current_stats.0 += ins;
current_stats.1 += del;
}
}
if let Some(hash) = current_hash {
stats.insert(hash, current_stats);
}
stats
}
pub(super) fn process_log_with_dimming(
log_output: &str,
unique_commits: Option<&HashSet<String>>,
) -> (String, Vec<String>) {
let dim = anstyle::Style::new().dimmed();
let reset = anstyle::Reset;
let mut result = String::with_capacity(log_output.len());
let mut hashes = Vec::new();
for (i, line) in log_output.lines().enumerate() {
if i > 0 {
result.push('\n');
}
if let Some(hash_start) = line.find(HASH_START)
&& let Some(hash_end_offset) = line[hash_start + 1..].find(HASH_END)
{
let hash_end = hash_start + 1 + hash_end_offset;
let graph_prefix = &line[..hash_start];
let full_hash = &line[hash_start + 1..hash_end];
let display = &line[hash_end + 1..];
hashes.push(full_hash.to_string());
let is_bright = match unique_commits {
None => true, Some(set) => set.contains(full_hash), };
if is_bright {
result.push_str(graph_prefix);
result.push(HASH_START);
result.push_str(full_hash);
result.push(HASH_END);
result.push_str(display);
} else {
let _ = write!(
result,
"{}{HASH_START}{full_hash}{HASH_END}{dim}{}{reset}",
graph_prefix,
display.ansi_strip()
);
}
continue;
}
result.push_str(line);
}
(result, hashes)
}
pub(super) fn format_log_output(
log_output: &str,
stats: &HashMap<String, (usize, usize)>,
) -> String {
format_log_output_with_formatter(log_output, stats, format_relative_time_short)
}
pub(super) fn format_log_output_with_formatter<F>(
log_output: &str,
stats: &HashMap<String, (usize, usize)>,
format_time: F,
) -> String
where
F: Fn(i64) -> String,
{
let max_prefix_width = log_output
.lines()
.filter(|line| line.contains(FIELD_DELIM))
.filter_map(|line| {
let first_delim = line.find(FIELD_DELIM)?;
let graph_hash_raw = &line[..first_delim];
let graph_hash = strip_hash_markers(graph_hash_raw);
Some(graph_hash.ansi_strip().width())
})
.max()
.unwrap_or(0);
let mut result = Vec::new();
for line in log_output.lines() {
if line.contains(FIELD_DELIM) {
let commit_stats = extract_hash_from_line(line)
.and_then(|h| stats.get(h))
.copied()
.unwrap_or((0, 0));
result.push(format_commit_line(
line,
commit_stats,
max_prefix_width,
&format_time,
));
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
pub(super) fn extract_hash_from_line(line: &str) -> Option<&str> {
let hash_start = line.find(HASH_START)?;
let hash_end_offset = line[hash_start + 1..].find(HASH_END)?;
Some(&line[hash_start + 1..hash_start + 1 + hash_end_offset])
}
pub(super) fn strip_hash_markers(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == HASH_START {
while let Some(&next) = chars.peek() {
chars.next();
if next == HASH_END {
break;
}
}
} else {
result.push(c);
}
}
result
}
pub(super) fn format_commit_line<F>(
commit_line: &str,
(insertions, deletions): (usize, usize),
target_width: usize,
format_time: &F,
) -> String
where
F: Fn(i64) -> String,
{
let dim_style = anstyle::Style::new().dimmed();
let reset = anstyle::Reset;
if let Some(first_delim) = commit_line.find(FIELD_DELIM)
&& let Some(second_delim) = commit_line[first_delim + 1..].find(FIELD_DELIM)
{
let graph_hash_raw = &commit_line[..first_delim];
let graph_hash = strip_hash_markers(graph_hash_raw);
let timestamp_str = &commit_line[first_delim + 1..first_delim + 1 + second_delim];
let rest = &commit_line[first_delim + 1 + second_delim + 1..];
let time = timestamp_str
.parse::<i64>()
.map(format_time)
.unwrap_or_default();
let diff_config = DiffDisplayConfig {
variant: DiffVariant::Signs,
positive_style: ADDITION,
negative_style: DELETION,
always_show_zeros: false,
};
let stat_str = format!(" {}", diff_config.format_aligned(insertions, deletions));
let current_width = graph_hash.ansi_strip().width();
let padding = " ".repeat(target_width.saturating_sub(current_width));
format!(
"{}{}{} {dim_style}{:>width$}{reset}{}",
graph_hash,
padding,
stat_str,
time,
rest,
width = TIMESTAMP_WIDTH
)
} else {
commit_line.to_string()
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use super::*;
fn fixed_time_formatter(_timestamp: i64) -> String {
"1h".to_string() }
fn stats_for(
hash: &str,
insertions: usize,
deletions: usize,
) -> HashMap<String, (usize, usize)> {
let mut map = HashMap::new();
map.insert(hash.to_string(), (insertions, deletions));
map
}
fn multi_stats(entries: &[(&str, usize, usize)]) -> HashMap<String, (usize, usize)> {
entries
.iter()
.map(|(h, i, d)| (h.to_string(), (*i, *d)))
.collect()
}
#[test]
fn test_format_log_output_single_commit() {
let full_hash = "abc1234567890123456789012345678901234567ab";
let input = format!("* \x01{}\x00abc1234\x1f1699999000\x1f Fix bug", full_hash);
let stats = stats_for(full_hash, 5, 2);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"* abc1234 [32m+5[0m [31m-2[0m [2m 1h[0m Fix bug");
let input = format!(
"* \x01{}\x00abc1234\x1f1699999000\x1f Commit with graph",
full_hash
);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"* abc1234 [32m+5[0m [31m-2[0m [2m 1h[0m Commit with graph");
}
#[test]
fn test_format_log_output_with_stats() {
let full_hash = "abc1234567890123456789012345678901234567ab";
let input = format!(
"* \x01{}\x00abc1234\x1f1699999000\x1f Add feature",
full_hash
);
let stats = stats_for(full_hash, 13, 5);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"* abc1234 [32m+13[0m [31m-5[0m [2m 1h[0m Add feature");
}
#[test]
fn test_format_log_output_multiple_commits() {
let hash1 = "abc1234567890123456789012345678901234567ab";
let hash2 = "def5678901234567890123456789012345678901cd";
let input = format!(
"* \x01{}\x00abc1234\x1f1699999000\x1f First commit\n\
* \x01{}\x00def5678\x1f1699998000\x1f Second commit",
hash1, hash2
);
let stats = multi_stats(&[(hash1, 5, 2), (hash2, 10, 3)]);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"
* abc1234 [32m+5[0m [31m-2[0m [2m 1h[0m First commit
* def5678 [32m+10[0m [31m-3[0m [2m 1h[0m Second commit
");
}
#[test]
fn test_format_log_output_empty_input() {
let stats = HashMap::new();
let output = format_log_output_with_formatter("", &stats, fixed_time_formatter);
assert!(output.is_empty());
}
#[test]
fn test_format_log_output_preserves_graph_lines() {
let hash1 = "abc1234567890123456789012345678901234567ab";
let hash2 = "def5678901234567890123456789012345678901cd";
let input = format!(
"* \x01{}\x00abc1234\x1f1699999000\x1f Merge branch\n\
|\\ \n\
| * \x01{}\x00def5678\x1f1699998000\x1f Feature commit",
hash1, hash2
);
let stats = multi_stats(&[(hash1, 0, 0), (hash2, 5, 2)]);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @r"
* abc1234 [2m 1h[0m Merge branch
|\
| * def5678 [32m+5[0m [31m-2[0m [2m 1h[0m Feature commit
");
}
#[test]
fn test_format_log_output_no_or_zero_stats() {
let full_hash = "abc1234567890123456789012345678901234567ab";
let input = format!(
"* \x01{}\x00abc1234\x1f1699999000\x1f Just a commit",
full_hash
);
let stats = HashMap::new();
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"* abc1234 [2m 1h[0m Just a commit");
let input = format!("* \x01{}\x00abc1234\x1f1699999000\x1f Add image", full_hash);
let stats = stats_for(full_hash, 0, 0);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"* abc1234 [2m 1h[0m Add image");
}
#[test]
fn test_format_log_output_malformed() {
let stats = HashMap::new();
let output = format_log_output_with_formatter(
"abc1234 regular commit line",
&stats,
fixed_time_formatter,
);
assert_snapshot!(output, @"abc1234 regular commit line");
let output = format_log_output_with_formatter(
"abc1234\x1f1699999000 Fix bug",
&stats,
fixed_time_formatter,
);
assert_snapshot!(output, @"abc1234\u{1f}1699999000 Fix bug");
}
#[test]
fn test_format_log_output_stats_only_deletions() {
let full_hash = "abc1234567890123456789012345678901234567ab";
let input = format!(
"* \x01{}\x00abc1234\x1f1699999000\x1f Remove old code",
full_hash
);
let stats = stats_for(full_hash, 0, 50);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"* abc1234 [31m-50[0m [2m 1h[0m Remove old code");
}
#[test]
fn test_format_log_output_large_stats() {
let full_hash = "abc1234567890123456789012345678901234567ab";
let input = format!(
"* \x01{}\x00abc1234\x1f1699999000\x1f Big refactor",
full_hash
);
let stats = stats_for(full_hash, 1500, 800);
let output = format_log_output_with_formatter(&input, &stats, fixed_time_formatter);
assert_snapshot!(output, @"* abc1234 [1m[32m+1K[0m [31m-800[0m [2m 1h[0m Big refactor");
}
#[test]
fn test_format_commit_line() {
let commit_line = "abc1234\x1f1699999000\x1f Test commit";
let output = format_commit_line(commit_line, (10, 5), 7, &fixed_time_formatter);
assert_snapshot!(output, @"abc1234 [32m+10[0m [31m-5[0m [2m 1h[0m Test commit");
let commit_line = "abc12\x1f1699999000\x1f Short hash";
let output = format_commit_line(commit_line, (5, 2), 9, &fixed_time_formatter);
assert_snapshot!(output, @"abc12 [32m+5[0m [31m-2[0m [2m 1h[0m Short hash");
}
fn parse_dimming_output(output: &str) -> Vec<(bool, String)> {
use ansi_str::AnsiStr;
output
.lines()
.map(|line| {
let is_dimmed = line.contains("\x1b[2m");
let content = line.ansi_strip().to_string();
(is_dimmed, content)
})
.collect()
}
#[test]
fn test_process_log_with_dimming_parses_commit_line() {
let hash = "abc123456789012345678901234567890123456789";
let input = format!("* \x01{}\x00abc1234 (HEAD) Fix bug", hash);
let unique = HashSet::from([hash.to_string()]);
let (output, hashes) = process_log_with_dimming(&input, Some(&unique));
assert!(
output.contains('\x01'),
"SOH should be preserved for format_log_output"
);
assert!(
output.contains('\x00'),
"NUL should be preserved for format_log_output"
);
assert!(output.contains("abc1234"), "short hash preserved");
assert!(output.contains("Fix bug"), "message preserved");
assert_eq!(hashes.len(), 1);
assert_eq!(hashes[0], hash);
let parsed = parse_dimming_output(&output);
assert_eq!(parsed.len(), 1);
assert!(!parsed[0].0, "commit in unique set should be bright");
}
#[test]
fn test_process_log_with_dimming_dims_non_unique() {
let unique_hash = "abc123456789012345678901234567890123456789";
let other_hash = "def123456789012345678901234567890123456789";
let input = format!(
"* \x01{}\x00abc1234 Unique commit\n\
* \x01{}\x00def1234 Not unique",
unique_hash, other_hash
);
let unique = HashSet::from([unique_hash.to_string()]);
let (output, hashes) = process_log_with_dimming(&input, Some(&unique));
assert_eq!(hashes.len(), 2);
let parsed = parse_dimming_output(&output);
assert_eq!(parsed.len(), 2);
assert!(!parsed[0].0, "unique commit should be bright");
assert!(parsed[0].1.contains("Unique commit"));
assert!(parsed[1].0, "non-unique commit should be dimmed");
assert!(parsed[1].1.contains("Not unique"));
}
#[test]
fn test_process_log_with_dimming_none_means_all_bright() {
let hash = "abc123456789012345678901234567890123456789";
let input = format!("* \x01{}\x00abc1234 Some commit", hash);
let (output, hashes) = process_log_with_dimming(&input, None);
assert_eq!(hashes.len(), 1);
let parsed = parse_dimming_output(&output);
assert_eq!(parsed.len(), 1);
assert!(!parsed[0].0, "None means default branch, all bright");
}
#[test]
fn test_process_log_with_dimming_empty_set_means_all_dim() {
let hash = "abc123456789012345678901234567890123456789";
let input = format!("* \x01{}\x00abc1234 Some commit", hash);
let empty: HashSet<String> = HashSet::new();
let (output, hashes) = process_log_with_dimming(&input, Some(&empty));
assert_eq!(hashes.len(), 1);
let parsed = parse_dimming_output(&output);
assert_eq!(parsed.len(), 1);
assert!(
parsed[0].0,
"Some(empty) means feature branch with no unique commits, all dim"
);
}
#[test]
fn test_process_log_with_dimming_preserves_graph_lines() {
let hash = "abc123456789012345678901234567890123456789";
let input = format!(
"* \x01{}\x00abc1234 First\n\
|\n\
* \x01{}\x00def1234 Second",
hash, "def123456789012345678901234567890123456789"
);
let unique = HashSet::from([hash.to_string()]);
let (output, _hashes) = process_log_with_dimming(&input, Some(&unique));
assert!(output.contains("\n|\n"), "graph line should be preserved");
}
#[test]
fn test_process_log_with_dimming_sha256_compatible() {
let sha256_hash = "abc1234567890123456789012345678901234567890123456789012345678901";
assert_eq!(sha256_hash.len(), 64);
let input = format!("* \x01{}\x00abc1234 SHA-256 repo", sha256_hash);
let unique = HashSet::from([sha256_hash.to_string()]);
let (output, hashes) = process_log_with_dimming(&input, Some(&unique));
assert_eq!(hashes[0], sha256_hash);
let parsed = parse_dimming_output(&output);
assert!(!parsed[0].0, "SHA-256 hash should be matched correctly");
assert!(parsed[0].1.contains("SHA-256 repo"));
}
#[test]
fn test_process_log_with_dimming_strips_ansi_when_dimming() {
let hash = "abc123456789012345678901234567890123456789";
let input = format!(
"* \x01{}\x00\x1b[33mabc1234\x1b[m\x1b[33m (HEAD)\x1b[m message",
hash
);
let other_unique = HashSet::from(["other".to_string()]);
let (output, _hashes) = process_log_with_dimming(&input, Some(&other_unique));
let parsed = parse_dimming_output(&output);
assert!(parsed[0].0, "should be dimmed");
assert!(parsed[0].1.contains("abc1234"));
assert!(parsed[0].1.contains("(HEAD)"));
}
#[test]
fn test_strip_hash_markers_removes_soh_nul_block() {
let full_hash = "abc1234567890123456789012345678901234567ab";
let input = format!("* \x01{}\x00abc1234 message", full_hash);
let output = strip_hash_markers(&input);
assert!(!output.contains('\x01'));
assert!(!output.contains('\x00'));
assert_eq!(output, "* abc1234 message");
}
#[test]
fn test_strip_hash_markers_preserves_other_content() {
let input = "* abc1234 (HEAD -> main) Initial commit";
let output = strip_hash_markers(input);
assert_eq!(output, input);
}
#[test]
fn test_strip_hash_markers_handles_multiple_markers() {
let input = "line1 \x01hash1\x00 content1\nline2 \x01hash2\x00 content2";
let output = strip_hash_markers(input);
assert_eq!(output, "line1 content1\nline2 content2");
}
#[test]
fn test_extract_hash_from_line_finds_hash() {
let full_hash = "abc1234567890123456789012345678901234567ab";
let line = format!("* \x01{}\x00abc1234 message", full_hash);
let extracted = extract_hash_from_line(&line);
assert_eq!(extracted, Some(full_hash));
}
#[test]
fn test_extract_hash_from_line_sha256() {
let sha256_hash = "abc1234567890123456789012345678901234567890123456789012345678901";
let line = format!("* \x01{}\x00abc1234 message", sha256_hash);
let extracted = extract_hash_from_line(&line);
assert_eq!(extracted, Some(sha256_hash));
}
#[test]
fn test_extract_hash_from_line_no_markers() {
let line = "* abc1234 message";
let extracted = extract_hash_from_line(line);
assert_eq!(extracted, None);
}
#[test]
fn test_extract_hash_from_line_incomplete_markers() {
let line = "* \x01abc1234 message";
let extracted = extract_hash_from_line(line);
assert_eq!(extracted, None);
}
}