sem-cli 0.9.0

Semantic version control CLI. Shows what entities changed (functions, classes, methods) instead of lines.
use std::collections::HashMap;
use std::path::Path;

use colored::Colorize;
use sem_core::git::bridge::GitBridge;
use sem_core::git::types::BlameLineInfo;

use super::truncate_str;

pub struct BlameOptions {
    pub cwd: String,
    pub file_path: String,
    pub json: bool,
}

struct EntityBlame {
    name: String,
    entity_type: String,
    start_line: usize,
    end_line: usize,
    author: String,
    date: String,
    commit_sha: Option<String>,
    summary: String,
}

pub fn blame_command(opts: BlameOptions) {
    let root = Path::new(&opts.cwd);
    let registry = super::create_registry(&opts.cwd);

    // Read file and extract entities
    let full_path = root.join(&opts.file_path);
    let content = match std::fs::read_to_string(&full_path) {
        Ok(c) => c,
        Err(e) => {
            eprintln!(
                "{} Cannot read {}: {}",
                "error:".red().bold(),
                opts.file_path,
                e
            );
            std::process::exit(1);
        }
    };

    let entities = registry.extract_entities(&opts.file_path, &content);
    if entities.is_empty() {
        if opts.json {
            println!("[]");
            return;
        }

        eprintln!(
            "{} No entities found in {}",
            "warning:".yellow().bold(),
            opts.file_path
        );
        return;
    }

    // Open git repo and run blame
    let git = match GitBridge::open(root) {
        Ok(r) => r,
        Err(e) => {
            eprintln!(
                "{} Cannot open git repository: {}",
                "error:".red().bold(),
                e
            );
            std::process::exit(1);
        }
    };

    // Resolve file path relative to repo root for git blame
    let repo_root = git.repo_root();
    let abs_file = std::fs::canonicalize(root.join(&opts.file_path)).unwrap_or(full_path.clone());
    let repo_root_canonical = std::fs::canonicalize(repo_root).unwrap_or(repo_root.to_path_buf());
    let relative_path = abs_file
        .strip_prefix(&repo_root_canonical)
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|_| opts.file_path.clone());

    let blame = match git.blame_file_porcelain(Path::new(&relative_path)) {
        Ok(b) => b,
        Err(e) => {
            eprintln!(
                "{} Cannot blame {}: {}",
                "error:".red().bold(),
                opts.file_path,
                e
            );
            std::process::exit(1);
        }
    };
    let blame_by_line: HashMap<usize, BlameLineInfo> = blame
        .into_iter()
        .map(|line| (line.line_number, line))
        .collect();

    // For each entity, find the most recent commit that touched its lines
    let mut results: Vec<EntityBlame> = Vec::new();

    for entity in &entities {
        // Find the latest commit across the entity's line range
        let mut selected: Option<&BlameLineInfo> = None;

        for line in entity.start_line..=entity.end_line {
            if let Some(info) = blame_by_line.get(&line) {
                if info.commit_sha.is_none() {
                    selected = Some(info);
                    break;
                }

                let is_newer = match (info.author_time, selected.and_then(|s| s.author_time)) {
                    (Some(current), Some(previous)) => current > previous,
                    (Some(_), None) => true,
                    _ => selected.is_none(),
                };
                if is_newer {
                    selected = Some(info);
                }
            }
        }

        let (author, date, commit_sha, summary) = match selected {
            Some(info) => (
                info.author.clone(),
                info.author_time.map(chrono_lite_format).unwrap_or_default(),
                info.commit_sha.clone(),
                info.summary.clone(),
            ),
            None => (String::new(), String::new(), None, String::new()),
        };

        results.push(EntityBlame {
            name: entity.name.clone(),
            entity_type: entity.entity_type.clone(),
            start_line: entity.start_line,
            end_line: entity.end_line,
            author,
            date,
            commit_sha,
            summary,
        });
    }

    if opts.json {
        let output: Vec<_> = results
            .iter()
            .map(|r| {
                serde_json::json!({
                    "name": r.name,
                    "type": r.entity_type,
                    "lines": [r.start_line, r.end_line],
                    "author": if r.author.is_empty() { "unknown" } else { &r.author },
                    "date": r.date,
                    "commit": r.commit_sha.clone(),
                    "summary": r.summary,
                })
            })
            .collect();
        println!("{}", serde_json::to_string(&output).unwrap());
    } else {
        println!("{}", format!("┌─ {} ", opts.file_path).bold());
        println!("");

        // Group by parent (top-level vs nested)
        let max_name_len = results.iter().map(|r| r.name.len()).max().unwrap_or(10);
        let max_type_len = results
            .iter()
            .map(|r| r.entity_type.len())
            .max()
            .unwrap_or(8);

        for r in &results {
            let sha_short = if r.commit_sha.is_none() {
                "uncommtd"
            } else if r.commit_sha.as_deref().unwrap_or_default().len() >= 8 {
                &r.commit_sha.as_deref().unwrap_or_default()[..8]
            } else {
                r.commit_sha.as_deref().unwrap_or_default()
            };

            let is_nested = results.iter().any(|other| {
                other.name != r.name
                    && other.start_line <= r.start_line
                    && other.end_line >= r.end_line
                    && !(other.start_line == r.start_line && other.end_line == r.end_line)
            });
            let marker = if is_nested { "│   └" } else { "│  ⊕" };

            let summary_short = truncate_str(&r.summary, 40);

            println!(
                "{} {:<max_type_len$}  {:<max_name_len$}  {}  {}  {}  {}",
                marker,
                r.entity_type.dimmed(),
                r.name.bold(),
                sha_short.yellow(),
                r.author.cyan(),
                r.date.dimmed(),
                summary_short,
                max_type_len = max_type_len,
                max_name_len = max_name_len,
            );
        }

        println!("");
        println!("{}", "".repeat(60));
    }
}

/// Simple timestamp formatting without external deps.
fn chrono_lite_format(unix_seconds: i64) -> String {
    // Convert unix timestamp to date string
    let days = unix_seconds / 86400;
    let mut y = 1970;
    let mut remaining_days = days;

    loop {
        let year_days = if is_leap(y) { 366 } else { 365 };
        if remaining_days < year_days {
            break;
        }
        remaining_days -= year_days;
        y += 1;
    }

    let month_days = if is_leap(y) {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };

    let mut m = 0;
    for (i, &md) in month_days.iter().enumerate() {
        if remaining_days < md {
            m = i;
            break;
        }
        remaining_days -= md;
    }

    let d = remaining_days + 1;
    format!("{:04}-{:02}-{:02}", y, m + 1, d)
}

fn is_leap(y: i64) -> bool {
    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}