whogitit 1.0.0

Track AI-generated code at line-level granularity
Documentation
use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use git2::Repository;

use crate::cli::output::{LineSourceOutput, OutputFormat, MACHINE_OUTPUT_SCHEMA_VERSION};
use crate::storage::notes::NotesStore;
use crate::utils::{truncate, SHORT_COMMIT_LEN};

/// Show command arguments
#[derive(Debug, Args)]
pub struct ShowArgs {
    /// Commit to show (default: HEAD)
    #[arg(default_value = "HEAD")]
    pub commit: String,

    /// Output format
    #[arg(long, value_enum, default_value_t = OutputFormat::Pretty)]
    pub format: OutputFormat,
}

/// Run the show command
pub fn run(args: ShowArgs) -> Result<()> {
    // Open repository
    let repo = Repository::discover(".").context(
        "Not in a git repository. \
         Run 'git init' to create one, or 'cd' to a directory containing a .git folder.",
    )?;

    // Resolve commit reference
    let obj = repo.revparse_single(&args.commit).with_context(|| {
        format!(
            "Failed to resolve '{}'. \n\
                 Suggestions:\n  \
                 - Use a valid commit SHA: whogitit show abc1234\n  \
                 - Use HEAD for latest: whogitit show HEAD\n  \
                 - Use a branch name: whogitit show main\n  \
                 - Use HEAD~N for parent: whogitit show HEAD~1",
            args.commit
        )
    })?;
    let commit = obj
        .peel_to_commit()
        .with_context(|| format!("'{}' is not a valid commit reference", args.commit))?;

    let commit_id = commit.id().to_string();
    // Safe substring: commit IDs are hex strings (ASCII), but we still use min() for safety
    let commit_short = &commit_id[..commit_id.len().min(SHORT_COMMIT_LEN)];

    // Get attribution
    let notes_store = NotesStore::new(&repo)?;
    let attribution = notes_store.fetch_attribution(commit.id())?;

    match attribution {
        Some(attr) => {
            if args.format == OutputFormat::Json {
                let files_json: Vec<_> = attr
                    .files
                    .iter()
                    .map(|file| {
                        let lines_json: Vec<_> = file
                            .lines
                            .iter()
                            .map(|line| {
                                serde_json::json!({
                                    "line_number": line.line_number,
                                    "content": line.content,
                                    "source": LineSourceOutput::from(&line.source),
                                    "edit_id": line.edit_id,
                                    "prompt_index": line.prompt_index,
                                    "confidence": line.confidence,
                                })
                            })
                            .collect();

                        serde_json::json!({
                            "path": file.path,
                            "lines": lines_json,
                            "summary": file.summary,
                        })
                    })
                    .collect();

                let output = serde_json::json!({
                    "schema_version": MACHINE_OUTPUT_SCHEMA_VERSION,
                    "schema": "whogitit.show.v1",
                    "has_attribution": true,
                    "commit": commit_id,
                    "commit_short": commit_short,
                    "attribution_version": attr.version,
                    "session": attr.session,
                    "prompts": attr.prompts,
                    "files": files_json,
                    "summary": {
                        "total_ai_lines": attr.total_ai_lines(),
                        "total_ai_modified_lines": attr.total_ai_modified_lines(),
                        "total_human_lines": attr.total_human_lines(),
                        "total_original_lines": attr.total_original_lines(),
                    }
                });
                println!("{}", serde_json::to_string_pretty(&output)?);
            } else {
                print_summary(commit_short, &attr);
            }
        }
        None => {
            if args.format == OutputFormat::Json {
                println!(
                    "{}",
                    serde_json::to_string_pretty(&serde_json::json!({
                        "schema_version": MACHINE_OUTPUT_SCHEMA_VERSION,
                        "schema": "whogitit.show.v1",
                        "has_attribution": false,
                        "commit": commit_id,
                        "commit_short": commit_short,
                    }))?
                );
            } else {
                println!("No AI attribution found for commit {}", commit_short);
                println!("This commit was not made with AI assistance tracked by whogitit.");
            }
        }
    }

    Ok(())
}

fn print_summary(commit_short: &str, attr: &crate::core::attribution::AIAttribution) {
    println!("{}: {}", "Commit".bold(), commit_short.yellow());
    println!("{}: {}", "Session".bold(), attr.session.session_id.cyan());
    println!("{}: {}", "Model".bold(), attr.session.model.id);
    println!("{}: {}", "Started".bold(), attr.session.started_at.dimmed());
    println!();

    // Show prompts
    if !attr.prompts.is_empty() {
        println!("{}", "Prompts used:".bold());
        for prompt in &attr.prompts {
            let preview = truncate(&prompt.text, 60);
            println!("  #{}: \"{}\"", prompt.index, preview.dimmed());
        }
        println!();
    }

    // Show files with detailed breakdown
    println!("{}", "Files with AI changes:".bold());

    let mut total_ai = 0usize;
    let mut total_ai_modified = 0usize;
    let mut total_human = 0usize;
    let mut total_original = 0usize;

    for file in &attr.files {
        let s = &file.summary;
        total_ai += s.ai_lines;
        total_ai_modified += s.ai_modified_lines;
        total_human += s.human_lines;
        total_original += s.original_lines;

        // Color-coded breakdown
        let ai_str = format!("{} AI", s.ai_lines).green();
        let modified_str = if s.ai_modified_lines > 0 {
            format!(", {} modified", s.ai_modified_lines)
                .yellow()
                .to_string()
        } else {
            String::new()
        };
        let human_str = if s.human_lines > 0 {
            format!(", {} human", s.human_lines).blue().to_string()
        } else {
            String::new()
        };
        let original_str = if s.original_lines > 0 {
            format!(", {} original", s.original_lines)
                .dimmed()
                .to_string()
        } else {
            String::new()
        };

        println!(
            "  {} ({}{}{}{}) - {} total lines",
            file.path, ai_str, modified_str, human_str, original_str, s.total_lines
        );
    }

    println!();
    println!("{}", "Summary:".bold());
    println!("  {} AI-generated lines", total_ai.to_string().green());
    if total_ai_modified > 0 {
        println!(
            "  {} AI lines modified by human",
            total_ai_modified.to_string().yellow()
        );
    }
    if total_human > 0 {
        println!("  {} human-added lines", total_human.to_string().blue());
    }
    if total_original > 0 {
        println!(
            "  {} original/unchanged lines",
            total_original.to_string().dimmed()
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ShowArgs tests

    #[test]
    fn test_show_args_default_commit() {
        let args = ShowArgs {
            commit: "HEAD".to_string(),
            format: OutputFormat::Pretty,
        };
        assert_eq!(args.commit, "HEAD");
        assert!(matches!(args.format, OutputFormat::Pretty));
    }

    #[test]
    fn test_show_args_with_sha() {
        let args = ShowArgs {
            commit: "abc1234".to_string(),
            format: OutputFormat::Json,
        };
        assert_eq!(args.commit, "abc1234");
        assert!(matches!(args.format, OutputFormat::Json));
    }

    #[test]
    fn test_show_args_with_branch() {
        let args = ShowArgs {
            commit: "main".to_string(),
            format: OutputFormat::Pretty,
        };
        assert_eq!(args.commit, "main");
    }

    #[test]
    fn test_show_args_with_parent_ref() {
        let args = ShowArgs {
            commit: "HEAD~3".to_string(),
            format: OutputFormat::Pretty,
        };
        assert_eq!(args.commit, "HEAD~3");
    }

    // Line counting aggregation test (simulated)
    #[test]
    fn test_line_count_aggregation() {
        // Simulate the aggregation logic from print_summary
        let file_stats = vec![
            (10, 2, 5, 100), // (ai, ai_modified, human, original)
            (20, 5, 10, 200),
            (5, 1, 2, 50),
        ];

        let mut total_ai = 0usize;
        let mut total_ai_modified = 0usize;
        let mut total_human = 0usize;
        let mut total_original = 0usize;

        for (ai, ai_mod, human, original) in &file_stats {
            total_ai += ai;
            total_ai_modified += ai_mod;
            total_human += human;
            total_original += original;
        }

        assert_eq!(total_ai, 35);
        assert_eq!(total_ai_modified, 8);
        assert_eq!(total_human, 17);
        assert_eq!(total_original, 350);
    }

    // Commit short substring test
    #[test]
    fn test_commit_short_extraction() {
        let commit_id = "abc1234def456789";
        let commit_short = &commit_id[..commit_id.len().min(SHORT_COMMIT_LEN)];
        assert_eq!(commit_short, "abc1234");
        assert_eq!(commit_short.len(), 7);
    }

    #[test]
    fn test_commit_short_extraction_short_id() {
        // Edge case: commit ID shorter than SHORT_COMMIT_LEN
        let commit_id = "abc12";
        let commit_short = &commit_id[..commit_id.len().min(SHORT_COMMIT_LEN)];
        assert_eq!(commit_short, "abc12");
    }
}