use anyhow::{bail, Context, Result};
use clap::Args;
use colored::Colorize;
use git2::Repository;
use crate::cli::output::{LineSourceOutput, OutputFormat, MACHINE_OUTPUT_SCHEMA_VERSION};
use crate::core::blame::AIBlamer;
use crate::utils::{pad_right, truncate, word_wrap};
#[derive(Debug, Args)]
pub struct PromptArgs {
pub reference: String,
#[arg(short, long)]
pub revision: Option<String>,
#[arg(long, value_enum)]
pub format: Option<OutputFormat>,
#[arg(long)]
pub json: bool,
}
struct FileLineRef {
file: String,
line: Option<u32>,
}
impl FileLineRef {
fn parse(reference: &str) -> Result<Self> {
if let Some((file, line_str)) = reference.rsplit_once(':') {
if let Ok(line) = line_str.parse::<u32>() {
return Ok(Self {
file: file.to_string(),
line: Some(line),
});
}
}
Ok(Self {
file: reference.to_string(),
line: None,
})
}
}
pub fn run(args: PromptArgs) -> Result<()> {
let file_ref = FileLineRef::parse(&args.reference)?;
let output_format = if args.json {
OutputFormat::Json
} else {
args.format.unwrap_or(OutputFormat::Pretty)
};
let repo = Repository::discover(".").context("Not in a git repository")?;
let mut blamer = AIBlamer::new(&repo)?;
let result = blamer.blame(&file_ref.file, args.revision.as_deref())?;
let target_line = match file_ref.line {
Some(line) => result.lines.iter().find(|l| l.line_number == line),
None => {
result.lines.iter().find(|l| l.source.is_ai())
}
};
let line = match target_line {
Some(l) => l,
None => {
if let Some(line_num) = file_ref.line {
bail!("Line {} not found in {}", line_num, file_ref.file);
} else {
bail!("No AI-generated lines found in {}", file_ref.file);
}
}
};
if !line.source.is_ai() {
bail!(
"Line {} in {} was not AI-generated",
line.line_number,
file_ref.file
);
}
let attribution = blamer
.get_commit_attribution(&line.commit_id)?
.context("Failed to fetch attribution data")?;
let prompt_info = line
.prompt_index
.and_then(|idx| attribution.get_prompt(idx));
if output_format == OutputFormat::Json {
let output = serde_json::json!({
"schema_version": MACHINE_OUTPUT_SCHEMA_VERSION,
"schema": "whogitit.prompt.v1",
"query": {
"reference": args.reference,
"file": file_ref.file,
"line_number": line.line_number,
"revision": result.revision,
},
"line": {
"line_number": line.line_number,
"content": line.content,
"source": LineSourceOutput::from(&line.source),
"prompt_index": line.prompt_index,
},
"commit": {
"id": line.commit_id,
"short": line.commit_short,
"author": line.author,
},
"prompt": prompt_info.map(|p| serde_json::json!({
"index": p.index,
"text": p.text,
"timestamp": p.timestamp,
"affected_files": p.affected_files,
})),
"session": {
"id": attribution.session.session_id,
"model": attribution.session.model.id,
"started_at": attribution.session.started_at,
},
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
match prompt_info {
Some(prompt) => {
print_prompt_box(
prompt,
&attribution.session.session_id,
&attribution.session.model.id,
&attribution.session.started_at,
);
}
None => {
println!(
"Line {} is AI-generated but prompt details are not available.",
line.line_number
);
}
}
println!("File: {}:{}", file_ref.file, line.line_number);
println!("Commit: {}", line.commit_short);
println!("Source: {:?}", line.source);
}
Ok(())
}
fn print_prompt_box(
prompt: &crate::core::attribution::PromptInfo,
session_id: &str,
model: &str,
timestamp: &str,
) {
println!("╔{}╗", "═".repeat(68));
println!(
"║ {} #{} in session {} ",
"PROMPT".bold(),
prompt.index,
truncate(session_id, 20)
);
println!("║ Model: {} | {} ", model.cyan(), timestamp.dimmed());
println!("╠{}╣", "═".repeat(68));
for line in word_wrap(&prompt.text, 64) {
println!("║ {} ║", pad_right(&line, 64));
}
println!("╚{}╝", "═".repeat(68));
println!();
if !prompt.affected_files.is_empty() {
println!("{}", "Files affected by this prompt:".dimmed());
for file in &prompt.affected_files {
println!(" - {}", file);
}
println!();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_file_with_line() {
let result = FileLineRef::parse("src/main.rs:42").unwrap();
assert_eq!(result.file, "src/main.rs");
assert_eq!(result.line, Some(42));
}
#[test]
fn test_parse_file_without_line() {
let result = FileLineRef::parse("src/main.rs").unwrap();
assert_eq!(result.file, "src/main.rs");
assert_eq!(result.line, None);
}
#[test]
fn test_parse_file_with_line_1() {
let result = FileLineRef::parse("test.rs:1").unwrap();
assert_eq!(result.file, "test.rs");
assert_eq!(result.line, Some(1));
}
#[test]
fn test_parse_file_with_large_line_number() {
let result = FileLineRef::parse("test.rs:99999").unwrap();
assert_eq!(result.file, "test.rs");
assert_eq!(result.line, Some(99999));
}
#[test]
fn test_parse_nested_path_with_line() {
let result = FileLineRef::parse("src/cli/blame.rs:123").unwrap();
assert_eq!(result.file, "src/cli/blame.rs");
assert_eq!(result.line, Some(123));
}
#[test]
fn test_parse_file_with_colon_in_path() {
let result = FileLineRef::parse("C:\\folder\\file.rs:10").unwrap();
assert_eq!(result.file, "C:\\folder\\file.rs");
assert_eq!(result.line, Some(10));
}
#[test]
fn test_parse_file_with_colon_not_number() {
let result = FileLineRef::parse("file:name.rs").unwrap();
assert_eq!(result.file, "file:name.rs");
assert_eq!(result.line, None);
}
#[test]
fn test_parse_file_trailing_colon() {
let result = FileLineRef::parse("file.rs:").unwrap();
assert_eq!(result.file, "file.rs:");
assert_eq!(result.line, None);
}
#[test]
fn test_parse_file_with_zero_line() {
let result = FileLineRef::parse("file.rs:0").unwrap();
assert_eq!(result.file, "file.rs");
assert_eq!(result.line, Some(0));
}
#[test]
fn test_parse_absolute_path() {
let result = FileLineRef::parse("/home/user/project/src/main.rs:100").unwrap();
assert_eq!(result.file, "/home/user/project/src/main.rs");
assert_eq!(result.line, Some(100));
}
#[test]
fn test_parse_relative_path() {
let result = FileLineRef::parse("./src/lib.rs:50").unwrap();
assert_eq!(result.file, "./src/lib.rs");
assert_eq!(result.line, Some(50));
}
#[test]
fn test_parse_parent_dir_path() {
let result = FileLineRef::parse("../other/file.rs:25").unwrap();
assert_eq!(result.file, "../other/file.rs");
assert_eq!(result.line, Some(25));
}
#[test]
fn test_parse_empty_string() {
let result = FileLineRef::parse("").unwrap();
assert_eq!(result.file, "");
assert_eq!(result.line, None);
}
#[test]
fn test_prompt_args_structure() {
let args = PromptArgs {
reference: "src/main.rs:42".to_string(),
revision: None,
format: None,
json: false,
};
assert_eq!(args.reference, "src/main.rs:42");
assert!(args.revision.is_none());
assert!(args.format.is_none());
assert!(!args.json);
}
#[test]
fn test_prompt_args_json_output() {
let args = PromptArgs {
reference: "file.rs".to_string(),
revision: Some("HEAD~1".to_string()),
format: Some(OutputFormat::Json),
json: true,
};
assert_eq!(args.revision.as_deref(), Some("HEAD~1"));
assert!(matches!(args.format, Some(OutputFormat::Json)));
assert!(args.json);
}
}