branchdiff 0.60.1

Terminal UI showing unified diff of current branch vs its base
Documentation
use std::io::{self, Write};

use anyhow::Result;
use ratatui::style::{Color, Modifier, Style};

use branchdiff::app::App;
use branchdiff::diff::LineSource;
use branchdiff::ui::colors::print_line_style as line_style;
use branchdiff::ui::spans::coalesce_spans;

const RESET: &str = "\x1b[0m";

fn color_to_ansi(color: Color) -> Option<&'static str> {
    match color {
        Color::Black => Some("\x1b[30m"),
        Color::Red => Some("\x1b[31m"),
        Color::Green => Some("\x1b[32m"),
        Color::Yellow => Some("\x1b[33m"),
        Color::Blue => Some("\x1b[34m"),
        Color::Magenta => Some("\x1b[35m"),
        Color::Cyan => Some("\x1b[36m"),
        Color::Gray => Some("\x1b[37m"),
        Color::DarkGray => Some("\x1b[90m"),
        Color::LightRed => Some("\x1b[91m"),
        Color::LightGreen => Some("\x1b[92m"),
        Color::LightYellow => Some("\x1b[93m"),
        Color::LightBlue => Some("\x1b[94m"),
        Color::LightMagenta => Some("\x1b[95m"),
        Color::LightCyan => Some("\x1b[96m"),
        Color::White => Some("\x1b[97m"),
        _ => None,
    }
}

fn style_to_ansi(style: Style) -> String {
    let mut codes = Vec::new();

    if let Some(fg) = style.fg
        && let Some(code) = color_to_ansi(fg)
    {
        codes.push(code);
    }

    if style.add_modifier.contains(Modifier::BOLD) {
        codes.push("\x1b[1m");
    }
    if style.add_modifier.contains(Modifier::DIM) {
        codes.push("\x1b[2m");
    }

    codes.join("")
}

pub fn print_diff(app: &App) -> Result<()> {
    let mut stdout = io::stdout().lock();

    let branch_info = format!("{} vs {}", app.comparison.to_label, app.comparison.from_label);

    let file_count = app.files.len();
    let line_count = app.changed_line_count();

    let status = format!(
        "{} | {} file{} | {} line{}",
        branch_info,
        file_count,
        if file_count == 1 { "" } else { "s" },
        line_count,
        if line_count == 1 { "" } else { "s" },
    );

    writeln!(stdout, "\x1b[36m{}{}", status, RESET)?;
    writeln!(stdout)?;

    let max_line_num = app
        .lines
        .iter()
        .filter_map(|l| l.line_number)
        .max()
        .unwrap_or(0);
    let line_num_width = if max_line_num > 0 {
        max_line_num.to_string().len()
    } else {
        0
    };

    for line in &app.lines {
        let style = line_style(line.source);
        let ansi = style_to_ansi(style);

        let line_num_str = if let Some(num) = line.line_number {
            format!("{:>width$}", num, width = line_num_width)
        } else if line_num_width > 0 {
            " ".repeat(line_num_width)
        } else {
            String::new()
        };

        if line.source == LineSource::FileHeader {
            if !line_num_str.is_empty() {
                write!(stdout, "\x1b[90m{} {}", line_num_str, RESET)?;
            }
            writeln!(
                stdout,
                "{}── {} ──{}",
                ansi, line.content, RESET
            )?;
            continue;
        }

        if line.source == LineSource::Elided {
            if !line_num_str.is_empty() {
                write!(stdout, "\x1b[90m{} {}", line_num_str, RESET)?;
            }
            writeln!(
                stdout,
                "{}┈┈ ⋮ {} ⋮ ┈┈{}",
                ansi, line.content, RESET
            )?;
            continue;
        }

        if !line_num_str.is_empty() {
            write!(stdout, "\x1b[90m{} {}", line_num_str, RESET)?;
        }

        write!(stdout, "{}{} ", ansi, line.prefix)?;

        if !line.inline_spans.is_empty() {
            let display_spans = coalesce_spans(&line.inline_spans);
            for span in display_spans {
                let span_style = match span.source {
                    Some(source) => line_style(source),
                    None => style,
                };
                let span_ansi = style_to_ansi(span_style);
                write!(stdout, "{}{}{}", span_ansi, span.text, RESET)?;
            }
            writeln!(stdout)?;
        } else {
            writeln!(stdout, "{}{}", line.content, RESET)?;
        }
    }

    Ok(())
}

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

    #[test]
    fn test_color_to_ansi_basic_colors() {
        assert_eq!(color_to_ansi(Color::Red), Some("\x1b[31m"));
        assert_eq!(color_to_ansi(Color::Green), Some("\x1b[32m"));
        assert_eq!(color_to_ansi(Color::Yellow), Some("\x1b[33m"));
        assert_eq!(color_to_ansi(Color::Cyan), Some("\x1b[36m"));
        assert_eq!(color_to_ansi(Color::DarkGray), Some("\x1b[90m"));
        assert_eq!(color_to_ansi(Color::White), Some("\x1b[97m"));
        assert_eq!(color_to_ansi(Color::Magenta), Some("\x1b[35m"));
    }

    #[test]
    fn test_style_to_ansi_with_color() {
        let style = Style::default().fg(Color::Cyan);
        assert_eq!(style_to_ansi(style), "\x1b[36m");
    }

    #[test]
    fn test_style_to_ansi_with_modifiers() {
        let style = Style::default()
            .fg(Color::Red)
            .add_modifier(Modifier::BOLD);
        let ansi = style_to_ansi(style);
        assert!(ansi.contains("\x1b[31m"));
        assert!(ansi.contains("\x1b[1m"));
    }

    #[test]
    fn test_style_to_ansi_with_dim() {
        let style = Style::default()
            .fg(Color::Red)
            .add_modifier(Modifier::DIM);
        let ansi = style_to_ansi(style);
        assert!(ansi.contains("\x1b[31m"));
        assert!(ansi.contains("\x1b[2m"));
    }

    #[test]
    fn test_style_to_ansi_empty() {
        let style = Style::default();
        assert_eq!(style_to_ansi(style), "");
    }

    #[test]
    fn test_print_diff_produces_output() {
        use std::collections::HashMap;
        use std::path::PathBuf;
        use branchdiff::app::{App, ViewMode, ViewState};
        use branchdiff::diff::FileDiff;
        use branchdiff::gitignore::GitignoreFilter;
        use branchdiff::image_diff::{ImageCache, FONT_WIDTH_PX, FONT_HEIGHT_PX};
        use branchdiff::vcs::{ComparisonContext, VcsBackend};

        let repo_path = PathBuf::from("/tmp/test");
        let app = App {
            gitignore_filter: GitignoreFilter::new(&repo_path),
            repo_path,
            comparison: ComparisonContext {
                from_label: "main".to_string(),
                to_label: "feature".to_string(),
                stack_position: None,
                vcs_backend: VcsBackend::Git,
                bookmark_name: None,
                divergence: None,
            },
            base_identifier: "abc123".to_string(),
            files: vec![FileDiff {
                lines: vec![
                    DiffLine::file_header("test.rs"),
                    DiffLine::new(LineSource::Base, "unchanged".to_string(), ' ', Some(1)),
                    DiffLine::new(LineSource::Committed, "added".to_string(), '+', Some(2)),
                ],
            }],
            lines: vec![
                DiffLine::file_header("test.rs"),
                DiffLine::new(LineSource::Base, "unchanged".to_string(), ' ', Some(1)),
                DiffLine::new(LineSource::Committed, "added".to_string(), '+', Some(2)),
            ],
            error: None,
            conflict_warning: None,
            performance_warning: None,
            file_links: HashMap::new(),
            image_cache: ImageCache::new(),
            image_picker: None,
            font_size: (FONT_WIDTH_PX as u16, FONT_HEIGHT_PX as u16),
            search: None,
            diff_base: crate::vcs::DiffBase::default(),
            view: ViewState {
                scroll_offset: 0,
                viewport_height: 20,
                view_mode: ViewMode::Full,
                content_offset: (1, 1),
                line_num_width: 0,
                content_width: 80,
                panel_width: 80,
                show_help: false,
                selection: None,
                word_selection_anchor: None,
                line_selection_anchor: None,
                row_map: Vec::new(),
                collapsed_files: Default::default(),
                manually_toggled: Default::default(),
                needs_inline_spans: true,
                path_copied_at: None,
                last_click: None,
                pending_copy: None,
                status_bar_lines: Vec::new(),
                status_bar_screen_y: 0,
            },
        };

        let result = print_diff(&app);
        assert!(result.is_ok());
    }

    #[test]
    fn test_line_style_produces_correct_ansi() {
        let committed_style = line_style(LineSource::Committed);
        assert_eq!(committed_style.fg, Some(Color::Cyan));

        let staged_style = line_style(LineSource::Staged);
        assert_eq!(staged_style.fg, Some(Color::Green));

        let unstaged_style = line_style(LineSource::Unstaged);
        assert_eq!(unstaged_style.fg, Some(Color::Yellow));

        let deleted_style = line_style(LineSource::DeletedBase);
        assert_eq!(deleted_style.fg, Some(Color::Red));

        let file_header_style = line_style(LineSource::FileHeader);
        assert_eq!(file_header_style.fg, Some(Color::White));
        assert!(file_header_style.add_modifier.contains(Modifier::BOLD));
    }
}