rho-coding-agent 2.3.0

A fast Rust agent harness with a small footprint and opinionated defaults
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};

use mermaid_rs_renderer::ir::{GitGraphBranch, GitGraphCommit, GitGraphCommitType};
use mermaid_rs_renderer::Direction as MermaidDirection;
use unicode_width::UnicodeWidthStr;

use crate::tui::terminal_graph::{
    draw_seq_text, fit_label, Canvas, CellClass as Cls, GraphStyles, Oversize, MAX_CANVAS_CELLS,
};

use super::MermaidArt;

const TEXT_FLOOR: usize = 12;

#[derive(Clone, Debug)]
pub(super) struct GitGraphModel {
    lane_count: usize,
    rows: Vec<GitRow>,
}

#[derive(Clone, Copy, Debug)]
enum ConnectOn {
    SameRow,
    ExtraAbove,
}

#[derive(Clone, Copy, Debug)]
struct Connect {
    from: usize,
    on: ConnectOn,
}

#[derive(Clone, Debug)]
struct GitRow {
    lane: usize,
    glyph: char,
    text: String,
    connect: Option<Connect>,
}

pub(super) fn from_ir(ir: &mermaid_rs_renderer::Graph) -> Option<GitGraphModel> {
    if ir.gitgraph.commits.is_empty() {
        return None;
    }

    let mut commits: Vec<_> = ir.gitgraph.commits.iter().collect();
    commits.sort_by_key(|commit| commit.seq);

    let used: HashSet<&str> = commits
        .iter()
        .map(|commit| commit.branch.as_str())
        .collect();
    let mut branches: Vec<GitGraphBranch> = ir
        .gitgraph
        .branches
        .iter()
        .filter(|branch| used.contains(branch.name.as_str()))
        .cloned()
        .collect();
    let mut next_index = branches
        .iter()
        .map(|branch| branch.insertion_index)
        .max()
        .unwrap_or(0)
        .saturating_add(1);
    for commit in &commits {
        if branches.iter().any(|branch| branch.name == commit.branch) {
            continue;
        }
        branches.push(GitGraphBranch {
            name: commit.branch.clone(),
            order: None,
            insertion_index: next_index,
        });
        next_index += 1;
    }
    branches.sort_by(|left, right| {
        branch_order(left)
            .partial_cmp(&branch_order(right))
            .unwrap_or(Ordering::Equal)
            .then(left.insertion_index.cmp(&right.insertion_index))
    });

    let lane_index: HashMap<String, usize> = branches
        .iter()
        .enumerate()
        .map(|(index, branch)| (branch.name.clone(), index))
        .collect();
    let lane_count = branches.len().max(1);

    let mut id_lane: HashMap<String, usize> = HashMap::new();
    let mut seen_lanes: HashSet<usize> = HashSet::new();
    let mut rows = Vec::with_capacity(commits.len());
    for commit in &commits {
        let lane = *lane_index
            .get(&commit.branch)
            .expect("used branches receive a lane before painting");
        let first_on_lane = seen_lanes.insert(lane);
        let glyph_kind = commit.custom_type.unwrap_or(commit.commit_type);
        let connect = incoming_lane(commit, lane, &id_lane, first_on_lane);
        let text = row_text(
            commit,
            first_on_lane,
            lane,
            &commit.branch,
            ir.gitgraph.main_branch.as_str(),
        );
        rows.push(GitRow {
            lane,
            glyph: glyph(glyph_kind),
            text,
            connect,
        });
        id_lane.insert(commit.id.clone(), lane);
    }
    if ir.direction == MermaidDirection::BottomTop {
        rows.reverse();
    }

    Some(GitGraphModel { lane_count, rows })
}

pub(super) fn complexity(ir: &mermaid_rs_renderer::Graph) -> (usize, usize, usize, usize) {
    (
        ir.gitgraph.commits.len(),
        ir.gitgraph
            .commits
            .iter()
            .map(|commit| commit.parents.len())
            .sum(),
        ir.gitgraph.branches.len(),
        ir.gitgraph
            .commits
            .iter()
            .map(|commit| commit.tags.len())
            .sum(),
    )
}

pub(super) fn layout_gitgraph(
    model: &GitGraphModel,
    styles: &GraphStyles,
    max_width: Option<usize>,
) -> Result<MermaidArt, Oversize> {
    let lane_count = model.lane_count.max(1);
    let text_x = 2 * lane_count;
    let longest = model
        .rows
        .iter()
        .map(|row| row.text.width())
        .max()
        .unwrap_or(0);
    let mut text_width = longest;
    if let Some(max_width) = max_width {
        let available = max_width.saturating_sub(text_x);
        if available < TEXT_FLOOR && longest > 0 {
            return Err(Oversize::Width);
        }
        text_width = longest.min(available);
    }
    let texts: Vec<String> = model
        .rows
        .iter()
        .map(|row| {
            if text_width == 0 || row.text.width() <= text_width {
                row.text.clone()
            } else {
                fit_label(&row.text, text_width)
            }
        })
        .collect();

    let canvas_w = (text_x + text_width).max(1);
    if max_width.is_some_and(|max_width| canvas_w > max_width) {
        return Err(Oversize::Width);
    }

    let mut paint_y = Vec::with_capacity(model.rows.len());
    let mut y = 0usize;
    for row in &model.rows {
        if matches!(
            row.connect,
            Some(Connect {
                on: ConnectOn::ExtraAbove,
                ..
            })
        ) {
            y += 1;
        }
        paint_y.push(y);
        y += 1;
    }
    let canvas_h = y.max(1);
    if canvas_w.saturating_mul(canvas_h) > MAX_CANVAS_CELLS {
        return Err(Oversize::Cells);
    }

    let mut canvas = Canvas::new(canvas_w, canvas_h);
    let mut lane_min = vec![usize::MAX; lane_count];
    let mut lane_max = vec![0usize; lane_count];
    let touch = |lane_min: &mut [usize], lane_max: &mut [usize], lane: usize, row_y: usize| {
        if lane >= lane_min.len() {
            return;
        }
        lane_min[lane] = lane_min[lane].min(row_y);
        lane_max[lane] = lane_max[lane].max(row_y);
    };

    for (index, row) in model.rows.iter().enumerate() {
        let commit_y = paint_y[index];
        touch(&mut lane_min, &mut lane_max, row.lane, commit_y);
        if let Some((from, connector_y)) = connector_y(row, commit_y) {
            touch(&mut lane_min, &mut lane_max, from, connector_y);
            touch(&mut lane_min, &mut lane_max, row.lane, connector_y);
        }
    }
    for (lane, (&min_y, &max_y)) in lane_min.iter().zip(&lane_max).enumerate() {
        if min_y == usize::MAX {
            continue;
        }
        canvas.seg_v(lane_x(lane), min_y, max_y);
    }

    for (index, row) in model.rows.iter().enumerate() {
        let commit_y = paint_y[index];
        if let Some((from, connector_y)) = connector_y(row, commit_y) {
            canvas.seg_h(connector_y, lane_x(from), lane_x(row.lane));
        }
        canvas.set(lane_x(row.lane), commit_y, row.glyph, Cls::Border);
        if !texts[index].is_empty() {
            draw_seq_text(&mut canvas, &texts[index], text_x, commit_y, Cls::Text);
        }
    }

    canvas.finalize_mask();
    let (styled_lines, plain_lines) = canvas.to_lines(styles);
    Ok(MermaidArt {
        styled_lines,
        plain_lines,
    })
}

fn connector_y(row: &GitRow, commit_y: usize) -> Option<(usize, usize)> {
    row.connect.map(|connect| {
        let y = match connect.on {
            ConnectOn::SameRow => commit_y,
            ConnectOn::ExtraAbove => commit_y.saturating_sub(1),
        };
        (connect.from, y)
    })
}

fn lane_x(lane: usize) -> usize {
    2 * lane
}

fn glyph(kind: GitGraphCommitType) -> char {
    match kind {
        GitGraphCommitType::Merge => '',
        GitGraphCommitType::Highlight => '',
        GitGraphCommitType::Reverse => '',
        GitGraphCommitType::Normal | GitGraphCommitType::CherryPick => '',
    }
}

fn incoming_lane(
    commit: &GitGraphCommit,
    lane: usize,
    id_lane: &HashMap<String, usize>,
    first_on_lane: bool,
) -> Option<Connect> {
    let other = |parent: &String| {
        id_lane
            .get(parent)
            .copied()
            .filter(|&parent_lane| parent_lane != lane)
    };
    // `custom_type` is only a glyph. Merge joins follow `commit_type`.
    if commit.commit_type == GitGraphCommitType::Merge {
        return commit
            .parents
            .iter()
            .rev()
            .find_map(other)
            .map(|from| Connect {
                from,
                on: ConnectOn::SameRow,
            });
    }
    if first_on_lane {
        return commit.parents.first().and_then(other).map(|from| Connect {
            from,
            on: ConnectOn::ExtraAbove,
        });
    }
    None
}

fn row_text(
    commit: &GitGraphCommit,
    first_on_lane: bool,
    lane: usize,
    lane_name: &str,
    main_branch: &str,
) -> String {
    let mut text = if let Some(message) = commit
        .message
        .as_deref()
        .map(str::trim)
        .filter(|message| !message.is_empty())
    {
        message.to_string()
    } else if commit.custom_id {
        commit.id.clone()
    } else {
        String::new()
    };
    if first_on_lane && (lane != 0 || lane_name != main_branch) {
        if text.is_empty() {
            text = format!("({lane_name})");
        } else {
            text.push_str(" (");
            text.push_str(lane_name);
            text.push(')');
        }
    }
    if !commit.tags.is_empty() {
        text.push_str(" [");
        text.push_str(&commit.tags.join(" "));
        text.push(']');
    }
    text
}

fn branch_order(branch: &GitGraphBranch) -> f32 {
    branch
        .order
        .unwrap_or_else(|| default_branch_order(branch.insertion_index))
}

/// mermaid.js / mermaid-rs-renderer 0.3.1 lane order when a branch omits `order`.
fn default_branch_order(index: usize) -> f32 {
    if index == 0 {
        return 0.0;
    }
    let mut denom = 1.0f32;
    let mut value = index;
    while value > 0 {
        denom *= 10.0;
        value /= 10;
    }
    index as f32 / denom
}