use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;
use cqs::store::{CallerInfo, ChunkSummary, Store};
use cqs::{normalize_path, rel_display, resolve_target};
#[derive(Debug, serde::Serialize)]
pub(crate) struct BlameEntry {
pub hash: String,
pub author: String,
pub date: String,
pub message: String,
}
pub(crate) struct BlameData {
pub chunk: ChunkSummary,
pub commits: Vec<BlameEntry>,
pub callers: Vec<CallerInfo>,
}
pub(crate) fn build_blame_data(
store: &Store,
root: &Path,
target: &str,
depth: usize,
show_callers: bool,
) -> Result<BlameData> {
let _span = tracing::info_span!("build_blame_data", target, depth).entered();
let resolved = resolve_target(store, target).context("Failed to resolve blame target")?;
let chunk = resolved.chunk;
let rel_file = rel_display(&chunk.file, root);
let output = run_git_log_line_range(root, &rel_file, chunk.line_start, chunk.line_end, depth)?;
let commits = parse_git_log_output(&output);
let callers = if show_callers {
store.get_callers_full(&chunk.name).unwrap_or_else(|e| {
tracing::warn!(error = %e, name = %chunk.name, "Failed to fetch callers");
Vec::new()
})
} else {
Vec::new()
};
Ok(BlameData {
chunk,
commits,
callers,
})
}
fn run_git_log_line_range(
root: &Path,
rel_file: &str,
start: u32,
end: u32,
depth: usize,
) -> Result<String> {
let _span =
tracing::info_span!("run_git_log_line_range", file = rel_file, start, end).entered();
if rel_file.starts_with('-') {
anyhow::bail!("Invalid file path '{}': must not start with '-'", rel_file);
}
if rel_file.contains(':') {
anyhow::bail!(
"Invalid file path '{}': colons not supported (conflicts with git -L syntax)",
rel_file
);
}
let (start, end) = if start > end {
tracing::warn!(start, end, "Inverted line range, swapping");
(end, start)
} else {
(start, end)
};
let git_file = rel_file.replace('\\', "/");
let line_range = format!("{},{}:{}", start, end, git_file);
let depth_str = depth.to_string();
let output = std::process::Command::new("git")
.args(["--no-pager", "log", "--no-patch"])
.args(["--format=%h%x00%aN%x00%ai%x00%s"])
.args(["-L", &line_range])
.args(["-n", &depth_str])
.current_dir(root)
.output()
.context("Failed to run 'git log'. Is git installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
if stderr.contains("not a git repository") {
anyhow::bail!("Not a git repository: {}", root.display());
}
if stderr.contains("no path") || stderr.contains("There is no path") {
anyhow::bail!("File '{}' not found in git history", rel_file);
}
if stderr.contains("has only") {
tracing::warn!(stderr, "Line range may exceed file length");
return Ok(String::new());
}
const MAX_STDERR_LEN: usize = 256;
let sanitized = if stderr.len() > MAX_STDERR_LEN {
format!("{}... (truncated)", &stderr[..MAX_STDERR_LEN])
} else {
stderr.to_string()
};
anyhow::bail!("git log failed: {}", sanitized);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub(crate) fn parse_git_log_output(output: &str) -> Vec<BlameEntry> {
let mut entries = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(4, '\0').collect();
if parts.len() != 4 {
tracing::warn!(
line,
"Skipping malformed git log line (expected 4 NUL-separated fields)"
);
continue;
}
entries.push(BlameEntry {
hash: parts[0].to_string(),
author: parts[1].to_string(),
date: parts[2].to_string(),
message: parts[3].to_string(),
});
}
entries
}
#[derive(Debug, serde::Serialize)]
pub(crate) struct BlameOutput<'a> {
pub name: &'a str,
pub file: String,
pub line_start: u32,
pub line_end: u32,
pub signature: &'a str,
pub commits: &'a [BlameEntry],
pub total_commits: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub callers: Vec<BlameCallerEntry>,
}
#[derive(Debug, serde::Serialize)]
pub(crate) struct BlameCallerEntry {
pub name: String,
pub file: String,
pub line_start: u32,
}
pub(crate) fn blame_to_json(data: &BlameData, root: &Path) -> serde_json::Value {
let output = BlameOutput {
name: &data.chunk.name,
file: normalize_path(&data.chunk.file),
line_start: data.chunk.line_start,
line_end: data.chunk.line_end,
signature: &data.chunk.signature,
commits: &data.commits,
total_commits: data.commits.len(),
callers: data
.callers
.iter()
.map(|c| BlameCallerEntry {
name: c.name.clone(),
file: rel_display(&c.file, root),
line_start: c.line,
})
.collect(),
};
serde_json::to_value(&output).unwrap_or_else(|e| {
tracing::warn!(error = %e, "Failed to serialize BlameOutput");
serde_json::json!({})
})
}
fn print_blame_terminal(data: &BlameData, root: &Path) {
let file = rel_display(&data.chunk.file, root);
println!(
"{} {} ({}:{}-{})",
"●".bright_blue(),
data.chunk.name.bold(),
file.dimmed(),
data.chunk.line_start,
data.chunk.line_end,
);
println!(" {}", data.chunk.signature.dimmed());
println!();
if data.commits.is_empty() {
println!(" {}", "No git history for this line range.".dimmed());
} else {
for entry in &data.commits {
let short_date = entry.date.split(' ').next().unwrap_or(&entry.date);
println!(
" {} {} {} {}",
entry.hash.yellow(),
short_date.dimmed(),
entry.author.cyan(),
entry.message,
);
}
}
if !data.callers.is_empty() {
println!();
println!(" {} ({}):", "Callers".bold(), data.callers.len());
for caller in &data.callers {
let caller_file = rel_display(&caller.file, root);
println!(
" {} ({}:{})",
caller.name.green(),
caller_file.dimmed(),
caller.line,
);
}
}
}
pub(crate) fn cmd_blame(
ctx: &crate::cli::CommandContext,
target: &str,
depth: usize,
show_callers: bool,
json: bool,
) -> Result<()> {
let _span = tracing::info_span!("cmd_blame", target).entered();
let store = &ctx.store;
let root = &ctx.root;
let data = build_blame_data(store, root, target, depth, show_callers)?;
if json {
let value = blame_to_json(&data, root);
println!(
"{}",
serde_json::to_string_pretty(&value).context("Failed to serialize blame output")?
);
} else {
print_blame_terminal(&data, root);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_parse_git_log_output_single() {
let output = "abc1234\0Alice\02026-02-20 14:30:00 -0500\0fix: some bug\n";
let entries = parse_git_log_output(output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].hash, "abc1234");
assert_eq!(entries[0].author, "Alice");
assert_eq!(entries[0].date, "2026-02-20 14:30:00 -0500");
assert_eq!(entries[0].message, "fix: some bug");
}
#[test]
fn test_parse_git_log_output_multiple() {
let output = "abc1234\0Alice\02026-02-20\0first commit\n\
def5678\0Bob\02026-02-19\0second commit\n\
ghi9012\0Charlie\02026-02-18\0third commit\n";
let entries = parse_git_log_output(output);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].hash, "abc1234");
assert_eq!(entries[2].author, "Charlie");
}
#[test]
fn test_parse_git_log_output_empty() {
let entries = parse_git_log_output("");
assert!(entries.is_empty());
}
#[test]
fn test_parse_git_log_output_malformed() {
let output = "just-a-hash\n\
abc1234\0Alice\02026-02-20\0valid line\n\
incomplete\0two-parts\n";
let entries = parse_git_log_output(output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].hash, "abc1234");
}
#[test]
fn test_parse_git_log_output_message_with_pipe() {
let output = "abc1234\0Alice\02026-02-20\0fix: search | callers pipeline\n";
let entries = parse_git_log_output(output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].message, "fix: search | callers pipeline");
}
#[test]
fn test_blame_to_json_shape() {
let data = BlameData {
chunk: ChunkSummary {
id: "test-id".to_string(),
file: PathBuf::from("src/search.rs"),
language: cqs::language::Language::Rust,
chunk_type: cqs::language::ChunkType::Function,
name: "resolve_target".to_string(),
signature: "pub fn resolve_target(store: &Store, target: &str)".to_string(),
content: String::new(),
doc: None,
line_start: 23,
line_end: 96,
parent_id: None,
parent_type_name: None,
content_hash: String::new(),
window_idx: None,
},
commits: vec![BlameEntry {
hash: "abc1234".to_string(),
author: "Alice".to_string(),
date: "2026-02-20".to_string(),
message: "fix: something".to_string(),
}],
callers: vec![CallerInfo {
name: "cmd_explain".to_string(),
file: PathBuf::from("src/cli/commands/explain.rs"),
line: 52,
}],
};
let root = Path::new("");
let json = blame_to_json(&data, root);
assert_eq!(json["name"], "resolve_target");
assert_eq!(json["file"], "src/search.rs");
assert_eq!(json["line_start"], 23);
assert_eq!(json["line_end"], 96);
assert_eq!(json["commits"].as_array().unwrap().len(), 1);
assert_eq!(json["commits"][0]["hash"], "abc1234");
assert_eq!(json["total_commits"], 1);
assert_eq!(json["callers"].as_array().unwrap().len(), 1);
assert_eq!(json["callers"][0]["name"], "cmd_explain");
assert_eq!(json["callers"][0]["line_start"], 52);
}
#[test]
fn test_blame_to_json_no_callers() {
let data = BlameData {
chunk: ChunkSummary {
id: "test-id".to_string(),
file: PathBuf::from("src/lib.rs"),
language: cqs::language::Language::Rust,
chunk_type: cqs::language::ChunkType::Function,
name: "foo".to_string(),
signature: "fn foo()".to_string(),
content: String::new(),
doc: None,
line_start: 1,
line_end: 5,
parent_id: None,
parent_type_name: None,
content_hash: String::new(),
window_idx: None,
},
commits: vec![],
callers: vec![],
};
let root = Path::new("");
let json = blame_to_json(&data, root);
assert!(json.get("callers").is_none());
assert_eq!(json["total_commits"], 0);
}
}