use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;
use serde::Serialize;
use crate::cli::OutputFormat;
use crate::storage::{Database, Message, Session};
#[derive(clap::Args)]
#[command(after_help = "EXAMPLES:\n \
lore blame src/main.rs:42 Show session for line 42\n \
lore blame src/lib.rs:10 -f json Output as JSON\n \
lore blame Cargo.toml:1 Show session for first line")]
pub struct Args {
#[arg(value_name = "FILE:LINE")]
#[arg(long_help = "The file path and line number to look up, separated by\n\
a colon. The file path is relative to the repository root.\n\
Example: src/main.rs:42")]
pub target: String,
#[arg(short, long, value_enum, default_value = "text")]
pub format: OutputFormat,
}
#[derive(Serialize)]
struct BlameOutput {
file: String,
line: usize,
commit_sha: String,
commit_short: String,
commit_summary: String,
commit_author: String,
commit_date: String,
line_content: String,
sessions: Vec<BlameSessionInfo>,
}
#[derive(Serialize)]
struct BlameSessionInfo {
session_id: String,
tool: String,
started_at: String,
message_count: i32,
relevant_excerpts: Vec<String>,
}
pub fn run(args: Args) -> Result<()> {
let (file_path, line_num) = parse_file_line(&args.target)?;
let blame_info = git_blame(&file_path, line_num)?;
let db = Database::open_default()?;
let links = db.get_links_by_commit(&blame_info.commit_sha)?;
let mut session_infos = Vec::new();
for link in &links {
if let Some(session) = db.get_session(&link.session_id)? {
let messages = db.get_messages(&session.id)?;
let excerpts = find_relevant_excerpts(&messages, &file_path, &blame_info.line_content);
session_infos.push((session, excerpts));
}
}
match args.format {
OutputFormat::Json => {
print_json(&file_path, line_num, &blame_info, &session_infos)?;
}
OutputFormat::Markdown => {
print_markdown(&file_path, line_num, &blame_info, &session_infos);
}
OutputFormat::Text => {
print_text(&file_path, line_num, &blame_info, &session_infos);
}
}
Ok(())
}
struct BlameInfo {
commit_sha: String,
commit_short: String,
author: String,
date: String,
summary: String,
line_content: String,
}
fn parse_file_line(target: &str) -> Result<(String, usize)> {
let last_colon = target
.rfind(':')
.context("Invalid format. Expected file:line (e.g., src/main.rs:42)")?;
let file_path = &target[..last_colon];
let line_str = &target[last_colon + 1..];
if file_path.is_empty() {
anyhow::bail!("File path cannot be empty. Expected file:line (e.g., src/main.rs:42)");
}
let line_num: usize = line_str
.parse()
.context("Invalid line number. Expected a positive integer.")?;
if line_num == 0 {
anyhow::bail!("Line number must be positive (1-indexed).");
}
Ok((file_path.to_string(), line_num))
}
fn git_blame(file_path: &str, line_num: usize) -> Result<BlameInfo> {
let repo = git2::Repository::discover(".")
.context("Not in a git repository. Run this command from within a git repository.")?;
let workdir = repo
.workdir()
.context("Could not determine repository working directory")?;
let abs_path = if Path::new(file_path).is_absolute() {
file_path.to_string()
} else {
workdir.join(file_path).to_string_lossy().to_string()
};
if !Path::new(&abs_path).exists() {
anyhow::bail!("File not found: {file_path}");
}
let content = std::fs::read_to_string(&abs_path)
.with_context(|| format!("Could not read file: {file_path}"))?;
let lines: Vec<&str> = content.lines().collect();
if line_num > lines.len() {
anyhow::bail!(
"Line {line_num} is out of range. File has {} lines.",
lines.len()
);
}
let line_content = lines[line_num - 1].to_string();
let rel_path = if Path::new(file_path).is_absolute() {
Path::new(file_path)
.strip_prefix(workdir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file_path.to_string())
} else {
file_path.to_string()
};
let blame = repo
.blame_file(Path::new(&rel_path), None)
.with_context(|| format!("Could not run git blame on: {file_path}"))?;
let hunk = blame
.get_line(line_num)
.with_context(|| format!("Could not get blame info for line {line_num}"))?;
let commit_id = hunk.final_commit_id();
let commit_sha = commit_id.to_string();
let commit_short = commit_sha[..8.min(commit_sha.len())].to_string();
let commit = repo
.find_commit(commit_id)
.with_context(|| format!("Could not find commit: {commit_sha}"))?;
let author = commit.author().name().unwrap_or("Unknown").to_string();
let summary = commit.summary().unwrap_or("").to_string();
let time = commit.time();
let date = chrono::DateTime::from_timestamp(time.seconds(), 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
Ok(BlameInfo {
commit_sha,
commit_short,
author,
date,
summary,
line_content,
})
}
fn find_relevant_excerpts(
messages: &[Message],
file_path: &str,
line_content: &str,
) -> Vec<String> {
let mut excerpts = Vec::new();
let file_name = Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(file_path);
let normalized_line = line_content.trim();
for msg in messages {
let text = msg.content.text();
if text.is_empty() {
continue;
}
let mentions_file = text.contains(file_path) || text.contains(file_name);
let contains_code = !normalized_line.is_empty() && text.contains(normalized_line);
if mentions_file || contains_code {
let snippet = extract_snippet(&text, file_path, file_name, normalized_line);
if !snippet.is_empty() && !excerpts.contains(&snippet) {
excerpts.push(snippet);
}
}
}
excerpts.truncate(5);
excerpts
}
fn extract_snippet(text: &str, file_path: &str, file_name: &str, line_content: &str) -> String {
let pos = text
.find(file_path)
.or_else(|| text.find(file_name))
.or_else(|| {
if !line_content.is_empty() {
text.find(line_content)
} else {
None
}
});
if let Some(pos) = pos {
let start = pos.saturating_sub(100);
let end = (pos + 100).min(text.len());
let start = text[..start]
.rfind(char::is_whitespace)
.map(|p| p + 1)
.unwrap_or(start);
let end = text[end..]
.find(char::is_whitespace)
.map(|p| end + p)
.unwrap_or(end);
let mut snippet = text[start..end].to_string();
if start > 0 {
snippet = format!("...{snippet}");
}
if end < text.len() {
snippet = format!("{snippet}...");
}
snippet = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
return snippet;
}
String::new()
}
fn print_json(
file_path: &str,
line_num: usize,
blame_info: &BlameInfo,
session_infos: &[(Session, Vec<String>)],
) -> Result<()> {
let output = BlameOutput {
file: file_path.to_string(),
line: line_num,
commit_sha: blame_info.commit_sha.clone(),
commit_short: blame_info.commit_short.clone(),
commit_summary: blame_info.summary.clone(),
commit_author: blame_info.author.clone(),
commit_date: blame_info.date.clone(),
line_content: blame_info.line_content.clone(),
sessions: session_infos
.iter()
.map(|(s, excerpts)| BlameSessionInfo {
session_id: s.id.to_string(),
tool: s.tool.clone(),
started_at: s.started_at.to_rfc3339(),
message_count: s.message_count,
relevant_excerpts: excerpts.clone(),
})
.collect(),
};
let json = serde_json::to_string_pretty(&output)?;
println!("{json}");
Ok(())
}
fn print_markdown(
file_path: &str,
line_num: usize,
blame_info: &BlameInfo,
session_infos: &[(Session, Vec<String>)],
) {
println!("# Blame: `{file_path}:{line_num}`");
println!();
println!("## Commit");
println!();
println!("| Property | Value |");
println!("|----------|-------|");
println!("| SHA | `{}` |", blame_info.commit_short);
println!("| Author | {} |", blame_info.author);
println!("| Date | {} |", blame_info.date);
println!("| Summary | {} |", blame_info.summary);
println!();
println!("## Line Content");
println!();
println!("```");
println!("{}", blame_info.line_content);
println!("```");
println!();
if session_infos.is_empty() {
println!("*No linked sessions found for this commit.*");
} else {
println!("## Linked Sessions ({})", session_infos.len());
println!();
for (session, excerpts) in session_infos {
let id_short = &session.id.to_string()[..8];
println!("### Session `{id_short}`");
println!();
println!("- **Tool:** {}", session.tool);
println!(
"- **Started:** {}",
session.started_at.format("%Y-%m-%d %H:%M")
);
println!("- **Messages:** {}", session.message_count);
println!();
if !excerpts.is_empty() {
println!("**Relevant excerpts:**");
println!();
for excerpt in excerpts {
println!("> {excerpt}");
println!();
}
}
}
}
}
fn print_text(
file_path: &str,
line_num: usize,
blame_info: &BlameInfo,
session_infos: &[(Session, Vec<String>)],
) {
println!(
"{} {}:{}",
"Blame".bold(),
file_path.cyan(),
line_num.to_string().yellow()
);
println!();
println!("{}", "Commit:".bold());
println!(
" {} {} {}",
blame_info.commit_short.yellow(),
blame_info.author.dimmed(),
blame_info.date.dimmed()
);
println!(" {}", blame_info.summary);
println!();
println!("{}", "Line content:".bold());
println!(" {}", blame_info.line_content.dimmed());
println!();
if session_infos.is_empty() {
println!("{}", "No linked sessions found for this commit.".dimmed());
println!();
println!(
"{}",
"Use 'lore link <session> --commit <sha>' to link a session.".dimmed()
);
} else {
println!(
"{}",
format!("Linked sessions ({}):", session_infos.len()).bold()
);
for (session, excerpts) in session_infos {
let id_short = &session.id.to_string()[..8];
println!();
println!(
" {} {} ({} messages)",
id_short.cyan(),
session.tool.dimmed(),
session.message_count
);
println!(
" {} {}",
"Started:".dimmed(),
session.started_at.format("%Y-%m-%d %H:%M")
);
if !excerpts.is_empty() {
println!(" {}", "Relevant context:".dimmed());
for excerpt in excerpts.iter().take(3) {
let display = if excerpt.len() > 100 {
format!("{}...", &excerpt[..100])
} else {
excerpt.clone()
};
println!(" {}", display.dimmed());
}
}
}
println!();
println!(
"{}",
"Use 'lore show <session-id>' to view full session details.".dimmed()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::MessageContent;
#[test]
fn test_parse_file_line_valid() {
let (file, line) = parse_file_line("src/main.rs:42").unwrap();
assert_eq!(file, "src/main.rs");
assert_eq!(line, 42);
}
#[test]
fn test_parse_file_line_line_one() {
let (file, line) = parse_file_line("Cargo.toml:1").unwrap();
assert_eq!(file, "Cargo.toml");
assert_eq!(line, 1);
}
#[test]
fn test_parse_file_line_nested_path() {
let (file, line) = parse_file_line("src/cli/commands/blame.rs:100").unwrap();
assert_eq!(file, "src/cli/commands/blame.rs");
assert_eq!(line, 100);
}
#[test]
fn test_parse_file_line_missing_colon() {
let result = parse_file_line("src/main.rs");
assert!(result.is_err());
}
#[test]
fn test_parse_file_line_invalid_line_number() {
let result = parse_file_line("src/main.rs:abc");
assert!(result.is_err());
}
#[test]
fn test_parse_file_line_zero_line() {
let result = parse_file_line("src/main.rs:0");
assert!(result.is_err());
}
#[test]
fn test_parse_file_line_empty_file() {
let result = parse_file_line(":42");
assert!(result.is_err());
}
#[test]
fn test_extract_snippet_file_match() {
let text = "I'm going to read the src/main.rs file to understand the structure.";
let snippet = extract_snippet(text, "src/main.rs", "main.rs", "");
assert!(snippet.contains("src/main.rs"));
}
#[test]
fn test_extract_snippet_code_match() {
let text = "Here's the code:\nfn main() {\n println!(\"Hello\");\n}";
let snippet = extract_snippet(text, "nonexistent.rs", "nonexistent.rs", "fn main()");
assert!(snippet.contains("fn main()"));
}
#[test]
fn test_extract_snippet_no_match() {
let text = "This text has nothing relevant.";
let snippet = extract_snippet(text, "other.rs", "other.rs", "unrelated code");
assert!(snippet.is_empty());
}
#[test]
fn test_find_relevant_excerpts_empty_messages() {
let messages: Vec<Message> = vec![];
let excerpts = find_relevant_excerpts(&messages, "src/main.rs", "fn main()");
assert!(excerpts.is_empty());
}
#[test]
fn test_find_relevant_excerpts_limits_to_five() {
use chrono::Utc;
use uuid::Uuid;
let messages: Vec<Message> = (0..10)
.map(|i| Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: i,
timestamp: Utc::now(),
role: crate::storage::MessageRole::User,
content: MessageContent::Text(format!(
"Message {i} mentions src/main.rs and some content"
)),
model: None,
git_branch: None,
cwd: None,
})
.collect();
let excerpts = find_relevant_excerpts(&messages, "src/main.rs", "content");
assert!(excerpts.len() <= 5);
}
}