g-cli 0.1.0

Git that talks back. A human-friendly CLI wrapper for Git.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;

use colored::Colorize;

use crate::git;

pub fn run(output: Option<String>, max_commits: usize) {
    let commits = git::commit_graph(max_commits);

    if commits.is_empty() {
        println!();
        println!("  {} No commits to map.", "".yellow());
        println!();
        return;
    }

    let tips = git::branch_tips();
    let head = git::head_hash();
    let current_branch = git::current_branch();

    // Build hash -> branch names lookup
    let mut branch_map: HashMap<String, Vec<String>> = HashMap::new();
    for (hash, name) in &tips {
        branch_map.entry(hash.clone()).or_default().push(name.clone());
    }

    // Collect all commit hashes in the graph for edge filtering
    let known: HashSet<String> = commits.iter().map(|c| c.hash.clone()).collect();

    let mut dot = String::new();
    dot.push_str("digraph repo {\n");
    dot.push_str("  rankdir=TB;\n");
    dot.push_str("  bgcolor=\"#0d1117\";\n");
    dot.push_str("  node [style=filled, fontname=\"Consolas,monospace\", fontsize=10];\n");
    dot.push_str("  edge [color=\"#8b949e\"];\n");
    dot.push_str("\n");

    // Commit nodes
    for commit in &commits {
        let is_head = head.as_deref() == Some(&commit.hash);
        let branches = branch_map.get(&commit.hash);
        let is_merge = commit.parents.len() > 1;

        let label = escape_dot(&commit.subject);
        let short = &commit.short_hash;

        let (fillcolor, fontcolor, shape) = if is_head {
            ("#238636", "#ffffff", "box")
        } else if is_merge {
            ("#1f6feb", "#ffffff", "box")
        } else {
            ("#161b22", "#c9d1d9", "box")
        };

        let border_color = if is_head { "#3fb950" } else { "#30363d" };

        dot.push_str(&format!(
            "  \"{}\" [label=\"{} {}\" shape={} fillcolor=\"{}\" fontcolor=\"{}\" color=\"{}\"];\n",
            commit.hash, short, label, shape, fillcolor, fontcolor, border_color
        ));

        // Branch labels as separate nodes
        if let Some(names) = branches {
            for name in names {
                let tag_color = if *name == current_branch {
                    "#238636"
                } else {
                    "#1f6feb"
                };
                let tag_id = format!("branch_{}", name.replace('/', "_").replace('-', "_"));
                dot.push_str(&format!(
                    "  \"{}\" [label=\"{}\" shape=ellipse fillcolor=\"{}\" fontcolor=\"#ffffff\" color=\"{}\" fontsize=11 style=\"filled,bold\"];\n",
                    tag_id, name, tag_color, tag_color
                ));
                dot.push_str(&format!(
                    "  \"{}\" -> \"{}\" [style=dashed color=\"{}\"];\n",
                    tag_id, commit.hash, tag_color
                ));
            }
        }

        // HEAD label
        if is_head {
            dot.push_str(&format!(
                "  \"HEAD\" [label=\"HEAD\" shape=diamond fillcolor=\"#f0883e\" fontcolor=\"#ffffff\" color=\"#f0883e\" fontsize=11 style=\"filled,bold\"];\n"
            ));
            dot.push_str(&format!(
                "  \"HEAD\" -> \"{}\" [style=dashed color=\"#f0883e\"];\n",
                commit.hash
            ));
        }
    }

    dot.push_str("\n");

    // Edges: child -> parent (top-down = newest on top)
    for commit in &commits {
        for parent in &commit.parents {
            if known.contains(parent) {
                dot.push_str(&format!(
                    "  \"{}\" -> \"{}\";\n",
                    commit.hash, parent
                ));
            }
        }
    }

    dot.push_str("}\n");

    // Write to file
    let out_path = match output {
        Some(p) => PathBuf::from(p),
        None => PathBuf::from("repo-map.dot"),
    };

    match fs::write(&out_path, &dot) {
        Ok(_) => {
            println!();
            println!(
                "  {} Repo map exported to {}",
                "".green().bold(),
                out_path.display().to_string().cyan()
            );
            println!();
            println!("  {} commits, {} branches mapped.", commits.len(), tips.len());
            println!();
            println!("  {}:", "To render".dimmed());
            println!(
                "    {} {}",
                "".dimmed(),
                format!("dot -Tpng {} -o repo-map.png", out_path.display()).cyan()
            );
            println!(
                "    {} {}",
                "".dimmed(),
                format!("dot -Tsvg {} -o repo-map.svg", out_path.display()).cyan()
            );
            println!();
        }
        Err(e) => {
            println!();
            println!("  {} Failed to write {}: {}", "".red(), out_path.display(), e);
            println!();
        }
    }
}

fn escape_dot(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', " ")
}