use crate::annotation::Fuse;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
pub struct BlameInfo {
pub author: String,
}
pub fn blame_file(repo_root: &Path, file: &Path) -> Option<HashMap<usize, BlameInfo>> {
if file
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return None;
}
let output = Command::new("git")
.arg("blame")
.arg("--porcelain")
.arg(file)
.current_dir(repo_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
Some(parse_blame_porcelain(&text))
}
fn sanitize_author(name: &str) -> String {
let mut result = String::with_capacity(name.len());
let mut chars = name.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next(); for inner in chars.by_ref() {
if ('\x40'..='\x7e').contains(&inner) {
break; }
}
}
} else if !c.is_control() {
result.push(c);
}
}
result.trim().to_string()
}
fn parse_blame_porcelain(output: &str) -> HashMap<usize, BlameInfo> {
let mut map = HashMap::new();
let mut lines = output.lines().peekable();
while let Some(line) = lines.next() {
if line.len() < 40 {
continue;
}
let (maybe_hash, rest) = line.split_at(40);
if !maybe_hash.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
let mut parts = rest.split_whitespace();
let _orig = match parts.next() {
Some(v) => v,
None => continue,
};
let final_line: usize = match parts.next().and_then(|v| v.parse().ok()) {
Some(n) => n,
None => continue,
};
let mut author = String::new();
for meta in lines.by_ref() {
if meta.starts_with('\t') {
break;
}
if let Some(name) = meta.strip_prefix("author ") {
author = sanitize_author(name);
}
}
map.insert(final_line, BlameInfo { author });
}
map
}
pub fn enrich_with_blame(annotations: &mut [Fuse], repo_root: &Path) {
let files_needing_blame: Vec<std::path::PathBuf> = {
let mut seen = std::collections::HashSet::new();
annotations
.iter()
.filter(|a| a.owner.is_none())
.map(|a| a.file.clone())
.filter(|f| seen.insert(f.clone()))
.collect()
};
let blame_maps: HashMap<std::path::PathBuf, HashMap<usize, BlameInfo>> = files_needing_blame
.iter()
.filter_map(|f| {
let bm = blame_file(repo_root, f)?;
Some((f.clone(), bm))
})
.collect();
for ann in annotations.iter_mut() {
if ann.owner.is_some() {
continue;
}
if let Some(blame_map) = blame_maps.get(&ann.file) {
if let Some(info) = blame_map.get(&ann.line) {
if !info.author.is_empty() && info.author != "Not Committed Yet" {
ann.blamed_owner = Some(info.author.clone());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::annotation::Status;
use chrono::NaiveDate;
use std::path::PathBuf;
fn make_annotation(line: usize, owner: Option<&str>) -> Fuse {
Fuse {
file: PathBuf::from("src/foo.rs"),
line,
tag: "TODO".to_string(),
date: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
owner: owner.map(|s| s.to_string()),
message: "test".to_string(),
status: Status::Detonated,
blamed_owner: None,
}
}
#[test]
fn test_sanitize_author_strips_control_chars() {
assert_eq!(sanitize_author("\x1b[31mred name\x1b[0m"), "red name");
assert_eq!(sanitize_author("alice\x00bob"), "alicebob");
assert_eq!(sanitize_author("Alice Smith"), "Alice Smith");
assert_eq!(sanitize_author(" Alice "), "Alice");
}
#[test]
fn test_parse_blame_porcelain_basic() {
let porcelain = "\
abc1234567890123456789012345678901234567 1 1 1\n\
author Alice Smith\n\
author-mail <alice@example.com>\n\
\tsome code on line 1\n\
abc1234567890123456789012345678901234567 2 2 1\n\
author Bob Jones\n\
author-mail <bob@example.com>\n\
\tsome code on line 2\n\
";
let map = parse_blame_porcelain(porcelain);
assert_eq!(map.len(), 2);
assert_eq!(map.get(&1).unwrap().author, "Alice Smith");
assert_eq!(map.get(&2).unwrap().author, "Bob Jones");
}
#[test]
fn test_parse_blame_porcelain_empty() {
let map = parse_blame_porcelain("");
assert!(map.is_empty());
}
#[test]
fn test_enrich_with_blame_skips_owned() {
let mut annotations = vec![make_annotation(1, Some("alice"))];
let fake_root = std::path::Path::new("/tmp/not-a-git-repo");
enrich_with_blame(&mut annotations, fake_root);
assert_eq!(annotations[0].owner, Some("alice".to_string()));
assert_eq!(annotations[0].blamed_owner, None);
}
#[test]
fn test_enrich_with_blame_no_blame_data() {
let mut annotations = vec![make_annotation(5, None)];
let fake_root = std::path::Path::new("/tmp/not-a-git-repo");
enrich_with_blame(&mut annotations, fake_root);
assert_eq!(annotations[0].blamed_owner, None);
}
#[test]
fn test_parse_blame_porcelain_reused_hash() {
let hash = "abcdef1234567890123456789012345678901234";
let input = format!(
"{hash} 1 1 1\nauthor Carol\nauthor-mail <c@example.com>\n\tline one\n\
{hash} 2 2 1\n\tline two\n",
);
let map = parse_blame_porcelain(&input);
assert_eq!(map.get(&1).unwrap().author, "Carol");
assert!(map.contains_key(&2));
}
#[test]
fn test_parse_blame_porcelain_not_committed_yet() {
let porcelain = "\
0000000000000000000000000000000000000000 1 1 1\n\
author Not Committed Yet\n\
author-mail <not.committed.yet>\n\
\tuncommitted line\n\
";
let map = parse_blame_porcelain(porcelain);
assert_eq!(map.get(&1).unwrap().author, "Not Committed Yet");
}
#[test]
fn test_enrich_with_blame_sets_blamed_owner() {
let porcelain = "\
abc1234567890123456789012345678901234567 1 10 1\n\
author Dave\n\
author-mail <dave@example.com>\n\
\t// TODO[2020-01-01]: some old thing\n\
";
let blame_map = parse_blame_porcelain(porcelain);
let mut ann = make_annotation(10, None);
if let Some(info) = blame_map.get(&ann.line) {
if !info.author.is_empty() && info.author != "Not Committed Yet" {
ann.blamed_owner = Some(info.author.clone());
}
}
assert_eq!(ann.blamed_owner, Some("Dave".to_string()));
}
#[test]
fn test_enrich_with_blame_ignores_not_committed_yet() {
let porcelain = "\
0000000000000000000000000000000000000000 1 5 1\n\
author Not Committed Yet\n\
author-mail <not.committed.yet>\n\
\tuncommitted line\n\
";
let blame_map = parse_blame_porcelain(porcelain);
let mut ann = make_annotation(5, None);
if let Some(info) = blame_map.get(&ann.line) {
if !info.author.is_empty() && info.author != "Not Committed Yet" {
ann.blamed_owner = Some(info.author.clone());
}
}
assert_eq!(ann.blamed_owner, None);
}
#[test]
fn test_enrich_does_not_overwrite_explicit_owner_even_with_blame_data() {
let porcelain = "\
abc1234567890123456789012345678901234567 1 3 1\n\
author Eve\n\
author-mail <eve@example.com>\n\
\tsome line\n\
";
let blame_map = parse_blame_porcelain(porcelain);
let mut ann = make_annotation(3, Some("alice"));
if ann.owner.is_none() {
if let Some(info) = blame_map.get(&ann.line) {
if !info.author.is_empty() && info.author != "Not Committed Yet" {
ann.blamed_owner = Some(info.author.clone());
}
}
}
assert_eq!(ann.owner, Some("alice".to_string()));
assert_eq!(ann.blamed_owner, None);
}
#[test]
fn test_enrich_with_blame_multiple_files_same_line_number() {
let porcelain_foo = "\
aaaa000000000000000000000000000000000000 1 1 1\n\
author Foo Author\n\
author-mail <foo@example.com>\n\
\tfoo line\n\
";
let porcelain_bar = "\
bbbb000000000000000000000000000000000000 1 1 1\n\
author Bar Author\n\
author-mail <bar@example.com>\n\
\tbar line\n\
";
let foo_blame = parse_blame_porcelain(porcelain_foo);
let bar_blame = parse_blame_porcelain(porcelain_bar);
let mut ann_foo = Fuse {
file: PathBuf::from("src/foo.rs"),
line: 1,
tag: "TODO".to_string(),
date: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
owner: None,
message: "foo".to_string(),
status: Status::Detonated,
blamed_owner: None,
};
let mut ann_bar = Fuse {
file: PathBuf::from("src/bar.rs"),
line: 1,
..ann_foo.clone()
};
for (ann, blame_map) in [(&mut ann_foo, &foo_blame), (&mut ann_bar, &bar_blame)] {
if ann.owner.is_none() {
if let Some(info) = blame_map.get(&ann.line) {
if !info.author.is_empty() && info.author != "Not Committed Yet" {
ann.blamed_owner = Some(info.author.clone());
}
}
}
}
assert_eq!(ann_foo.blamed_owner, Some("Foo Author".to_string()));
assert_eq!(ann_bar.blamed_owner, Some("Bar Author".to_string()));
}
}