whogitit 1.0.0

Track AI-generated code at line-level granularity
Documentation
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};

/// Prompt command arguments
#[derive(Debug, Args)]
pub struct PromptArgs {
    /// File and line reference (e.g., "src/main.rs:42" or "src/main.rs")
    pub reference: String,

    /// Revision to inspect (default: HEAD)
    #[arg(short, long)]
    pub revision: Option<String>,

    /// Output format
    #[arg(long, value_enum)]
    pub format: Option<OutputFormat>,

    /// Output as JSON (deprecated: use --format json)
    #[arg(long)]
    pub json: bool,
}

/// Parsed file:line reference
struct FileLineRef {
    file: String,
    line: Option<u32>,
}

impl FileLineRef {
    fn parse(reference: &str) -> Result<Self> {
        if let Some((file, line_str)) = reference.rsplit_once(':') {
            // Check if the part after : is actually a line number
            if let Ok(line) = line_str.parse::<u32>() {
                return Ok(Self {
                    file: file.to_string(),
                    line: Some(line),
                });
            }
        }

        // No line number, just a file path
        Ok(Self {
            file: reference.to_string(),
            line: None,
        })
    }
}

/// Run the prompt command
pub fn run(args: PromptArgs) -> Result<()> {
    // Parse reference
    let file_ref = FileLineRef::parse(&args.reference)?;
    let output_format = if args.json {
        OutputFormat::Json
    } else {
        args.format.unwrap_or(OutputFormat::Pretty)
    };

    // Open repository
    let repo = Repository::discover(".").context("Not in a git repository")?;

    // Create blamer
    let mut blamer = AIBlamer::new(&repo)?;

    // Run blame to find AI attribution
    let result = blamer.blame(&file_ref.file, args.revision.as_deref())?;

    // Find the relevant line
    let target_line = match file_ref.line {
        Some(line) => result.lines.iter().find(|l| l.line_number == line),
        None => {
            // Find first AI-generated line
            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
        );
    }

    // Get attribution for more details
    let attribution = blamer
        .get_commit_attribution(&line.commit_id)?
        .context("Failed to fetch attribution data")?;

    // Get the prompt info
    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,
) {
    // Box top
    println!("{}", "".repeat(68));

    // Header
    println!(
        "{} #{} in session {}  ",
        "PROMPT".bold(),
        prompt.index,
        truncate(session_id, 20)
    );
    println!("║  Model: {} | {}  ", model.cyan(), timestamp.dimmed());

    // Separator
    println!("{}", "".repeat(68));

    // Prompt content with word wrap
    for line in word_wrap(&prompt.text, 64) {
        println!("{}", pad_right(&line, 64));
    }

    // Box bottom
    println!("{}", "".repeat(68));
    println!();

    // Files affected
    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::*;

    // FileLineRef::parse tests

    #[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() {
        // Windows-style path C:\file.rs:10 should parse correctly
        // The last colon followed by a number is the line
        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() {
        // If the part after colon is not a number, treat whole thing as filename
        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() {
        // Trailing colon with no number
        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() {
        // Line 0 should still parse
        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);
    }

    // PromptArgs tests

    #[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);
    }
}