use std::path::Path;
use colored::Colorize;
use git2::Repository;
use sem_core::parser::plugins::create_default_registry;
use super::truncate_str;
pub struct BlameOptions {
pub cwd: String,
pub file_path: String,
pub json: bool,
}
struct EntityBlame {
name: String,
entity_type: String,
start_line: usize,
end_line: usize,
author: String,
date: String,
commit_sha: String,
summary: String,
}
pub fn blame_command(opts: BlameOptions) {
let root = Path::new(&opts.cwd);
let registry = create_default_registry();
let full_path = root.join(&opts.file_path);
let content = match std::fs::read_to_string(&full_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{} Cannot read {}: {}", "error:".red().bold(), opts.file_path, e);
std::process::exit(1);
}
};
let plugin = match registry.get_plugin(&opts.file_path) {
Some(p) => p,
None => {
eprintln!(
"{} Unsupported file type: {}",
"error:".red().bold(),
opts.file_path
);
std::process::exit(1);
}
};
let entities = plugin.extract_entities(&content, &opts.file_path);
if entities.is_empty() {
eprintln!("{} No entities found in {}", "warning:".yellow().bold(), opts.file_path);
return;
}
let repo: git2::Repository = match Repository::discover(root) {
Ok(r) => r,
Err(e) => {
eprintln!("{} Not a git repository: {}", "error:".red().bold(), e);
std::process::exit(1);
}
};
let repo_root = repo.workdir().unwrap_or(root);
let abs_file = std::fs::canonicalize(root.join(&opts.file_path)).unwrap_or(full_path.clone());
let repo_root_canonical = std::fs::canonicalize(repo_root).unwrap_or(repo_root.to_path_buf());
let relative_path = abs_file
.strip_prefix(&repo_root_canonical)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| opts.file_path.clone());
let blame: git2::Blame = match repo.blame_file(Path::new(&relative_path), None) {
Ok(b) => b,
Err(e) => {
eprintln!("{} Cannot blame {}: {}", "error:".red().bold(), opts.file_path, e);
std::process::exit(1);
}
};
let mut results: Vec<EntityBlame> = Vec::new();
for entity in &entities {
let mut latest_time: i64 = 0;
let mut latest_author = String::new();
let mut latest_sha = String::new();
let mut latest_summary = String::new();
let mut latest_date = String::new();
for line in entity.start_line..=entity.end_line {
if let Some(hunk) = blame.get_line(line) {
let sig = hunk.final_signature();
let time = sig.when().seconds();
if time > latest_time {
latest_time = time;
latest_author = sig.name().unwrap_or("unknown").to_string();
let oid = hunk.final_commit_id();
latest_sha = format!("{}", oid);
latest_summary = repo
.find_commit(oid)
.ok()
.and_then(|c| c.summary().map(String::from))
.unwrap_or_default();
let ts = sig.when().seconds();
let naive = chrono_lite_format(ts);
latest_date = naive;
}
}
}
results.push(EntityBlame {
name: entity.name.clone(),
entity_type: entity.entity_type.clone(),
start_line: entity.start_line,
end_line: entity.end_line,
author: latest_author,
date: latest_date,
commit_sha: latest_sha,
summary: latest_summary,
});
}
if opts.json {
let output: Vec<_> = results
.iter()
.map(|r| {
serde_json::json!({
"name": r.name,
"type": r.entity_type,
"lines": [r.start_line, r.end_line],
"author": r.author,
"date": r.date,
"commit": &r.commit_sha[..8.min(r.commit_sha.len())],
"summary": r.summary,
})
})
.collect();
println!("{}", serde_json::to_string(&output).unwrap());
} else {
println!(
"{}",
format!("┌─ {} ", opts.file_path).bold()
);
println!("│");
let max_name_len = results.iter().map(|r| r.name.len()).max().unwrap_or(10);
let max_type_len = results.iter().map(|r| r.entity_type.len()).max().unwrap_or(8);
for r in &results {
let sha_short = if r.commit_sha.len() >= 8 {
&r.commit_sha[..8]
} else {
&r.commit_sha
};
let is_nested = results.iter().any(|other| {
other.name != r.name
&& other.start_line <= r.start_line
&& other.end_line >= r.end_line
&& !(other.start_line == r.start_line && other.end_line == r.end_line)
});
let marker = if is_nested { "│ └" } else { "│ ⊕" };
let summary_short = truncate_str(&r.summary, 40);
println!(
"{} {:<max_type_len$} {:<max_name_len$} {} {} {} {}",
marker,
r.entity_type.dimmed(),
r.name.bold(),
sha_short.yellow(),
r.author.cyan(),
r.date.dimmed(),
summary_short,
max_type_len = max_type_len,
max_name_len = max_name_len,
);
}
println!("│");
println!("└{}", "─".repeat(60));
}
}
fn chrono_lite_format(unix_seconds: i64) -> String {
let days = unix_seconds / 86400;
let mut y = 1970;
let mut remaining_days = days;
loop {
let year_days = if is_leap(y) { 366 } else { 365 };
if remaining_days < year_days {
break;
}
remaining_days -= year_days;
y += 1;
}
let month_days = if is_leap(y) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut m = 0;
for (i, &md) in month_days.iter().enumerate() {
if remaining_days < md {
m = i;
break;
}
remaining_days -= md;
}
let d = remaining_days + 1;
format!("{:04}-{:02}-{:02}", y, m + 1, d)
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}