gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Topology view layout calculation

use super::types::{BranchStatus, BranchTopology, TopologyBranch};

/// Layout row for a branch (intended for use by TUI rendering)
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct LayoutRow {
    /// Index for referencing branch information
    pub branch_index: usize,
    /// Horizontal column position (0 is main)
    pub column: usize,
    /// Node string for display
    pub node_line: String,
    /// Display string for branch name and status
    pub info_line: String,
}

/// Laid-out topology (intended for use by TUI rendering)
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TopologyLayout {
    /// Layout rows
    pub rows: Vec<LayoutRow>,
    /// Maximum column count
    pub max_column: usize,
}

impl TopologyLayout {
    /// Calculate layout from topology
    pub fn from_topology(topology: &BranchTopology) -> Self {
        let mut rows = Vec::new();
        let mut max_column = 0;

        // Place main branch in the first column
        for (idx, branch) in topology.branches.iter().enumerate() {
            let column = if branch.name == topology.main_branch {
                0
            } else {
                1 // Other branches go to column 1 and beyond
            };

            max_column = max_column.max(column);

            let node_line = create_node_line(branch, column, &topology.main_branch);
            let info_line = create_info_line(branch, topology);

            rows.push(LayoutRow {
                branch_index: idx,
                column,
                node_line,
                info_line,
            });
        }

        Self { rows, max_column }
    }

    /// Get row count
    pub fn row_count(&self) -> usize {
        self.rows.len()
    }
}

/// Generate a node line
fn create_node_line(branch: &TopologyBranch, column: usize, main_branch: &str) -> String {
    let mut line = String::new();

    // Vertical line for main branch
    if branch.name == main_branch {
        line.push('');
    } else {
        // Indent
        line.push('');
        for _ in 0..column {
            line.push(' ');
        }
        // Indicate a branch fork
        line.push('');
        line.push('');
    }

    line
}

/// Generate an info line
fn create_info_line(branch: &TopologyBranch, topology: &BranchTopology) -> String {
    let mut parts = Vec::new();

    // Branch name
    parts.push(branch.name.clone());

    // Status label
    let label = branch.status.label();
    if !label.is_empty() {
        parts.push(format!("[{}]", label));
    }

    // ahead/behind information
    if let Some(ref relation) = branch.relation {
        if branch.name != topology.main_branch {
            let summary = relation.summary();
            if summary != "up to date" {
                parts.push(format!("({})", summary));
            }
        }
    }

    // Show days for stale branches
    if branch.status == BranchStatus::Stale {
        let days = chrono::Local::now()
            .signed_duration_since(branch.last_activity)
            .num_days();
        parts.push(format!("{}d inactive", days));
    }

    parts.join(" ")
}

/// Render topology as ASCII art
pub fn render_topology_ascii(topology: &BranchTopology, width: usize) -> Vec<String> {
    let mut lines = Vec::new();

    // Header
    lines.push(format!("Branch Topology ({})", topology.main_branch));
    lines.push("".repeat(width.min(60)));

    // Main branch line
    let main_line = create_main_branch_line(topology, width);
    lines.push(main_line);

    // Each branch
    for branch in &topology.branches {
        if branch.name == topology.main_branch {
            continue;
        }

        let branch_line = create_branch_line(branch, topology, width);
        lines.push(branch_line);
    }

    // Footer
    lines.push("".repeat(width.min(60)));
    lines.push(format!(
        "{} branches ({} stale, {} merged)",
        topology.branch_count(),
        topology.stale_count(),
        topology.merged_count()
    ));

    lines
}

/// Generate main branch line
fn create_main_branch_line(topology: &BranchTopology, width: usize) -> String {
    let main_branch = topology
        .branches
        .iter()
        .find(|b| b.name == topology.main_branch);

    let label = match main_branch {
        Some(b) if b.status == BranchStatus::Active => format!("{} [HEAD]", topology.main_branch),
        _ => topology.main_branch.clone(),
    };

    // Draw main line
    let node = "";
    let line_char = "";
    let head_marker = "HEAD";

    let content_width = width.saturating_sub(10);
    let node_count = content_width / 2;

    let mut line = format!("{} ", label);
    for i in 0..node_count {
        if i == node_count - 1 {
            line.push_str(node);
            line.push_str("── ");
            line.push_str(head_marker);
        } else {
            line.push_str(line_char);
            line.push_str(line_char);
        }
    }

    line
}

/// Generate branch line
fn create_branch_line(branch: &TopologyBranch, topology: &BranchTopology, _width: usize) -> String {
    let status_prefix = match branch.status {
        BranchStatus::Stale => "[stale] ",
        BranchStatus::Merged => "[merged] ",
        BranchStatus::Active => "[HEAD] ",
        _ => "",
    };

    let relation_suffix = match &branch.relation {
        Some(r) if !r.is_merged => format!(" ({})", r.summary()),
        _ => String::new(),
    };

    // Also show days for stale branches
    let stale_suffix = if branch.status == BranchStatus::Stale {
        let days = chrono::Local::now()
            .signed_duration_since(branch.last_activity)
            .num_days();
        format!(" ({}d ago)", days)
    } else {
        String::new()
    };

    // Draw branch line
    let indent = if branch.name == topology.main_branch {
        ""
    } else {
        ""
    };

    format!(
        "{}{}{}{}{}",
        indent, status_prefix, branch.name, relation_suffix, stale_suffix
    )
}

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

    fn create_test_topology() -> BranchTopology {
        let mut topology = BranchTopology::new("main".to_string());

        topology.add_branch(
            TopologyBranch::new("main".to_string(), "abc1234".to_string(), Local::now())
                .with_status(BranchStatus::Active),
        );

        topology.add_branch(TopologyBranch::new(
            "feature".to_string(),
            "def5678".to_string(),
            Local::now(),
        ));

        topology
    }

    #[test]
    fn test_topology_layout_from_topology() {
        let topology = create_test_topology();
        let layout = TopologyLayout::from_topology(&topology);

        assert_eq!(layout.row_count(), 2);
    }

    #[test]
    fn test_render_topology_ascii_returns_lines() {
        let topology = create_test_topology();
        let lines = render_topology_ascii(&topology, 60);

        assert!(!lines.is_empty());
        // Contains header
        assert!(lines[0].contains("Branch Topology"));
    }

    #[test]
    fn test_create_branch_line_shows_status() {
        let topology = create_test_topology();
        let stale_branch = TopologyBranch::new(
            "old-feature".to_string(),
            "ghi9012".to_string(),
            Local::now() - chrono::Duration::days(60),
        )
        .with_status(BranchStatus::Stale);

        let line = create_branch_line(&stale_branch, &topology, 60);
        assert!(line.contains("[stale]"));
    }

    #[test]
    fn test_create_info_line_shows_ahead_behind() {
        let topology = create_test_topology();
        let mut relation =
            super::super::types::BranchRelation::new("main".to_string(), "feature".to_string());
        relation.ahead_count = 3;
        relation.behind_count = 1;

        let branch =
            TopologyBranch::new("feature".to_string(), "def5678".to_string(), Local::now())
                .with_relation(relation);

        let info = create_info_line(&branch, &topology);
        assert!(info.contains("3 ahead"));
        assert!(info.contains("1 behind"));
    }
}