use clap::ValueEnum;
use colored::Colorize;
use serde::Serialize;
use crate::capture::snapshot::LineSource;
use crate::core::attribution::BlameResult;
use crate::utils::{truncate, truncate_or_pad};
pub const MACHINE_OUTPUT_SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum OutputFormat {
#[default]
Pretty,
Json,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LineSourceOutput {
Original,
Ai { edit_id: String },
AiModified { edit_id: String, similarity: f64 },
Human,
Unknown,
}
impl From<&LineSource> for LineSourceOutput {
fn from(source: &LineSource) -> Self {
match source {
LineSource::Original => Self::Original,
LineSource::AI { edit_id } => Self::Ai {
edit_id: edit_id.clone(),
},
LineSource::AIModified {
edit_id,
similarity,
} => Self::AiModified {
edit_id: edit_id.clone(),
similarity: *similarity,
},
LineSource::Human => Self::Human,
LineSource::Unknown => Self::Unknown,
}
}
}
pub fn format_blame(result: &BlameResult, format: OutputFormat) -> String {
match format {
OutputFormat::Pretty => format_blame_pretty(result),
OutputFormat::Json => format_blame_json(result),
}
}
fn format_blame_pretty(result: &BlameResult) -> String {
let mut output = String::new();
output.push_str(&format!(
"\n {} {} │ {} │ {} │ {} │ {}\n",
"LINE".dimmed(),
" ".repeat(2),
"COMMIT ".dimmed(),
"AUTHOR ".dimmed(),
"SRC".dimmed(),
"CODE".dimmed()
));
output.push_str(&format!("{}\n", "─".repeat(85).dimmed()));
for line in &result.lines {
let line_num = format!("{:>5}", line.line_number);
let commit = &line.commit_short;
let author = truncate_or_pad(&line.author, 10);
let source_marker = match &line.source {
LineSource::AI { .. } => "●".green().bold().to_string(),
LineSource::AIModified { .. } => "◐".yellow().to_string(),
LineSource::Human => "+".blue().to_string(),
LineSource::Original => "─".dimmed().to_string(),
LineSource::Unknown => "?".dimmed().to_string(),
};
let code = truncate(&line.content, 50);
let formatted_line = format!(
"{} │ {} │ {} │ {} │ {}\n",
line_num.dimmed(),
commit.yellow(),
author,
source_marker,
code
);
output.push_str(&formatted_line);
}
let ai_count = result.ai_line_count();
let ai_modified_count = result.ai_modified_line_count();
let human_count = result.human_line_count();
let original_count = result.original_line_count();
let percentage = result.ai_percentage();
output.push_str(&format!("{}\n", "─".repeat(85).dimmed()));
output.push_str(&format!(
"Legend: {} AI ({}) {} AI-modified ({}) {} Human ({}) {} Original ({})\n",
"●".green().bold(),
ai_count,
"◐".yellow(),
ai_modified_count,
"+".blue(),
human_count,
"─".dimmed(),
original_count,
));
output.push_str(&format!(
"AI involvement: {:.0}% ({} of {} lines)\n",
percentage,
ai_count + ai_modified_count,
result.lines.len()
));
if let Some(line) = result.lines.iter().find(|l| l.prompt_preview.is_some()) {
if let Some(preview) = &line.prompt_preview {
output.push_str(&format!("First AI prompt: \"{}\"\n", preview.dimmed()));
}
}
output
}
fn format_blame_json(result: &BlameResult) -> String {
let json_output: Vec<serde_json::Value> = result
.lines
.iter()
.map(|line| {
serde_json::json!({
"line_number": line.line_number,
"line": line.line_number,
"commit": {
"id": line.commit_id,
"short": line.commit_short,
"author": line.author,
},
"source": LineSourceOutput::from(&line.source),
"flags": {
"is_ai": line.source.is_ai(),
"is_human": line.source.is_human(),
},
"prompt": {
"index": line.prompt_index,
"preview": line.prompt_preview,
},
"content": line.content,
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": MACHINE_OUTPUT_SCHEMA_VERSION,
"schema": "whogitit.blame.v1",
"file": result.path,
"revision": result.revision,
"lines": json_output,
"summary": {
"total_lines": result.lines.len(),
"ai_lines": result.pure_ai_line_count(),
"ai_modified_lines": result.ai_modified_line_count(),
"human_lines": result.human_line_count(),
"original_lines": result.original_line_count(),
"ai_percentage": result.ai_percentage(),
}
}))
.unwrap_or_else(|_| "{}".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::attribution::{BlameLineResult, BlameResult};
#[test]
fn test_truncate() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 8), "hello...");
}
#[test]
fn test_truncate_or_pad() {
assert_eq!(truncate_or_pad("hi", 5), "hi ");
assert_eq!(truncate_or_pad("hello world", 5), "hell…");
}
#[test]
fn test_line_source_output_ai_modified() {
let source = LineSource::AIModified {
edit_id: "e1".to_string(),
similarity: 0.75,
};
let output = LineSourceOutput::from(&source);
assert!(matches!(output, LineSourceOutput::AiModified { .. }));
}
#[test]
fn test_blame_json_has_schema_version_and_structured_source() {
let result = BlameResult {
path: "src/main.rs".to_string(),
revision: "HEAD".to_string(),
lines: vec![BlameLineResult {
line_number: 1,
content: "fn main() {}".to_string(),
commit_id: "abc1234567".to_string(),
commit_short: "abc1234".to_string(),
author: "Test".to_string(),
source: LineSource::AI {
edit_id: "edit-1".to_string(),
},
prompt_index: Some(0),
prompt_preview: Some("prompt".to_string()),
}],
};
let output = format_blame_json(&result);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(
parsed["schema_version"],
serde_json::Value::from(MACHINE_OUTPUT_SCHEMA_VERSION)
);
assert_eq!(parsed["schema"], "whogitit.blame.v1");
assert_eq!(parsed["lines"][0]["source"]["type"], "ai");
assert_eq!(parsed["lines"][0]["source"]["edit_id"], "edit-1");
}
}