Skip to main content

gitkit_cli/metrics/
silo.rs

1use std::collections::{HashMap, HashSet};
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use git2::{Patch, TreeWalkMode, TreeWalkResult};
5use ratatui::Frame;
6use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};
7use ratatui::style::Color::{self};
8use ratatui::style::palette::material::WHITE;
9use ratatui::style::{Modifier, Style, Stylize};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{
12    Block, Borders, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
13    TableState,
14};
15
16use crate::error::Result;
17use crate::tui::ACCENT;
18use crate::{git::kit::KitRepo, tui::Renderable};
19
20#[derive(Default)]
21pub struct SiloData {
22    pub files: Vec<FileSilo>,
23}
24
25#[derive(Default, Debug)]
26pub struct FileSilo {
27    pub file: String,
28    pub gatekeeper: String,
29    pub contributors: u16,
30    pub risk: u8,
31    pub total_churn: usize,
32    pub author_churn: HashMap<String, usize>,
33}
34
35impl SiloData {
36    pub fn new(repo: &KitRepo) -> Self {
37        SiloData::get_churn(repo).unwrap_or_default()
38    }
39
40    pub fn get_churn(repo: &KitRepo) -> Result<Self> {
41        let head_files = Self::get_head_files(repo)?;
42
43        let raw_churn_map = Self::accumulate_churn(repo)?;
44
45        let active_files = Self::process_silos(raw_churn_map, &head_files);
46
47        Ok(Self {
48            files: active_files,
49        })
50    }
51
52    pub fn get_head_files(repo: &KitRepo) -> Result<HashSet<String>> {
53        let mut current_files = HashSet::new();
54        let head = repo.inner.head()?;
55        let head_tree = head.peel_to_tree()?;
56
57        head_tree.walk(TreeWalkMode::PreOrder, |root, entry| {
58            if entry.kind() == Some(git2::ObjectType::Blob) {
59                if let Some(name) = entry.name().ok() {
60                    current_files.insert(format!("{}{}", root, name));
61                }
62            }
63            TreeWalkResult::Ok
64        })?;
65
66        Ok(current_files)
67    }
68
69    pub fn accumulate_churn(repo: &KitRepo) -> Result<HashMap<String, HashMap<String, usize>>> {
70        let mut churn_map: HashMap<String, HashMap<String, usize>> = HashMap::new();
71
72        for (commit, diff) in repo.iter_diff_history()? {
73            let author_name = commit.email;
74
75            for i in 0..diff.deltas().len() {
76                if let Ok(Some(patch)) = Patch::from_diff(&diff, i) {
77                    if let Some(path) = patch.delta().new_file().path() {
78                        let file_path = path.to_string_lossy().to_string();
79
80                        if let Ok((insertions, deletions, _)) = patch.line_stats() {
81                            let churn = insertions + deletions;
82
83                            if churn > 0 {
84                                *churn_map
85                                    .entry(file_path)
86                                    .or_default()
87                                    .entry(author_name.clone())
88                                    .or_default() += churn;
89                            }
90                        }
91                    }
92                }
93            }
94        }
95
96        Ok(churn_map)
97    }
98
99    pub fn process_silos(
100        churn_map: HashMap<String, HashMap<String, usize>>,
101        head_files: &HashSet<String>,
102    ) -> Vec<FileSilo> {
103        let mut active_files = Vec::new();
104
105        for (file, author_churn) in churn_map {
106            if !head_files.contains(&file) {
107                continue;
108            }
109
110            let total_churn: usize = author_churn.values().sum();
111            let contributors = author_churn.len() as u16;
112
113            let mut gatekeeper = String::from("Unknown");
114            let mut top_churn = 0;
115
116            for (author, churn) in &author_churn {
117                if *churn > top_churn {
118                    top_churn = *churn;
119                    gatekeeper = author.clone();
120                }
121            }
122
123            let risk = if total_churn > 0 {
124                ((top_churn as f64 / total_churn as f64) * 100.0).round() as u8
125            } else {
126                0
127            };
128
129            active_files.push(FileSilo {
130                file,
131                gatekeeper,
132                contributors,
133                risk,
134                total_churn,
135                author_churn,
136            });
137        }
138
139        active_files.sort_by(|a, b| b.risk.cmp(&a.risk).then(b.total_churn.cmp(&a.total_churn)));
140
141        active_files
142    }
143}
144
145pub struct SiloPage {
146    data: SiloData,
147    scroll_state: ScrollbarState,
148    table_state: TableState,
149    selected_index: usize,
150}
151
152impl Renderable for SiloPage {
153    fn render(&mut self, frame: &mut Frame, area: Rect) {
154        let chunks = Layout::vertical(vec![
155            Constraint::Percentage(50),
156            Constraint::Length(1),
157            Constraint::Percentage(50),
158        ])
159        .split(area);
160
161        self.render_churn_table(frame, chunks[0]);
162        self.render_churn_info(frame, chunks[2]);
163    }
164}
165
166impl SiloPage {
167    pub fn new(data: SiloData) -> Self {
168        let churn_size = &data.files.len();
169        let scroll_state = ScrollbarState::new(churn_size.clone()).position(0);
170        let table_state = TableState::default().with_selected(0);
171        Self {
172            data,
173            scroll_state,
174            table_state,
175            selected_index: 0,
176        }
177    }
178
179    pub fn handle_key(&mut self, key_event: KeyEvent, repo: &KitRepo) {
180        match (key_event.code, key_event.modifiers) {
181            (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => self.next(1),
182            (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => self.prev(1),
183
184            (KeyCode::Char('g'), KeyModifiers::NONE) => self.top(),
185            // G (with caps lock) or G (with shift)
186            (KeyCode::Char('G'), _) | (KeyCode::Char('g'), KeyModifiers::SHIFT) => self.bottom(),
187
188            // shift + j/k = 5 skips
189            (KeyCode::Char('J'), _) | (KeyCode::Char('j'), KeyModifiers::SHIFT) => self.next(5),
190            (KeyCode::Char('K'), _) | (KeyCode::Char('k'), KeyModifiers::SHIFT) => self.prev(5),
191            _ => {}
192        }
193    }
194
195    fn select_index(&mut self, index: usize) {
196        self.table_state.select(Some(self.selected_index));
197        self.scroll_state = self.scroll_state.position(self.selected_index);
198    }
199
200    pub fn top(&mut self) {
201        if !self.data.files.is_empty() {
202            self.selected_index = 0;
203            self.select_index(self.selected_index);
204        }
205    }
206
207    pub fn bottom(&mut self) {
208        if !self.data.files.is_empty() {
209            self.selected_index = self.data.files.len() - 1;
210            self.select_index(self.selected_index);
211        }
212    }
213
214    pub fn next(&mut self, skip: usize) {
215        if !self.data.files.is_empty() {
216            self.selected_index = (self.selected_index + skip) % self.data.files.len();
217            self.select_index(self.selected_index);
218        }
219    }
220
221    pub fn prev(&mut self, skip: usize) {
222        if !self.data.files.is_empty() {
223            let len = self.data.files.len();
224            if self.selected_index < skip {
225                self.selected_index = (len + self.selected_index - (skip % len)) % len;
226            } else {
227                self.selected_index -= skip;
228            }
229
230            self.select_index(self.selected_index);
231        }
232    }
233
234    pub fn render_scrollbar(&mut self, frame: &mut Frame, area: Rect) {
235        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
236        self.scroll_state = self
237            .scroll_state
238            .viewport_content_length(area.height as usize);
239        frame.render_stateful_widget(
240            scrollbar,
241            area.inner(Margin {
242                vertical: 1,
243                horizontal: 0,
244            }),
245            &mut self.scroll_state.position(self.selected_index),
246        );
247    }
248
249    pub fn render_churn_table(&mut self, frame: &mut Frame, area: Rect) {
250        let rows: Vec<Row> = self
251            .data
252            .files
253            .iter()
254            .map(|churn| {
255                let ratio = churn.risk as f64 / 100.0;
256                let bar = generate_silo_bar(ratio, 20); // TODO change fixed width 20
257                Row::new(vec![
258                    format!("{}", churn.file).fg(WHITE),
259                    format!("{}", churn.gatekeeper).fg(WHITE),
260                    format!("{}", churn.contributors).fg(WHITE),
261                    format!("{} {}%", bar, churn.risk).into(),
262                ])
263            })
264            .collect();
265
266        let widths = [
267            Constraint::Percentage(50),
268            Constraint::Length(20),
269            Constraint::Length(20),
270            Constraint::Min(0),
271        ];
272        let table = Table::new(rows, widths)
273            .header(Row::new(vec![
274                "PATH".bold(),
275                "GATEKEEPER".bold(),
276                "CONTRIBUTORS".bold(),
277                "SILO RISK".bold(),
278            ]))
279            .block(
280                Block::bordered()
281                    .title("Silos")
282                    .title_alignment(Alignment::Left),
283            )
284            .row_highlight_style(ACCENT)
285            .highlight_symbol("> ");
286
287        frame.render_stateful_widget(table, area, &mut self.table_state);
288
289        self.render_scrollbar(frame, area);
290    }
291
292    pub fn render_churn_info(&self, frame: &mut Frame, area: Rect) {
293        let chunks =
294            Layout::horizontal(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
295                .split(area);
296
297        let left = chunks[0];
298        let right = chunks[1];
299
300        self.render_foo(frame, left);
301
302        let block = Block::bordered();
303
304        frame.render_widget(block, right);
305    }
306
307    pub fn render_foo(&self, frame: &mut Frame, area: Rect) {
308        let silo = match self.data.files.get(self.selected_index) {
309            Some(silo) => silo,
310            None => return,
311        };
312
313        let mut top_contributors: Vec<(&String, &usize)> = silo.author_churn.iter().collect();
314        top_contributors.sort_by(|a, b| b.1.cmp(a.1));
315
316        let mut info_lines = vec![
317            Line::from(vec![
318                Span::styled("Total File Churn: ", Style::default().fg(Color::White)),
319                Span::styled(
320                    silo.total_churn.to_string(),
321                    Style::default().fg(Color::White),
322                ),
323                Span::raw(" lines"),
324            ]),
325            Line::from(""),
326            Line::from(Span::styled(
327                "Top Contributors:",
328                Style::default().add_modifier(Modifier::BOLD),
329            )),
330        ];
331
332        for (author, churn) in top_contributors.iter().take(3) {
333            let percentage = (**churn as f64 / silo.total_churn as f64) * 100.0;
334            info_lines.push(Line::from(format!(
335                "  - {}: {} lines ({:.0}%)",
336                author, churn, percentage
337            )));
338        }
339
340        let info_paragraph = Paragraph::new(info_lines).block(
341            Block::default()
342                .borders(Borders::ALL)
343                .title(silo.file.clone())
344                .style(Style::default().fg(Color::Gray)),
345        );
346
347        frame.render_widget(info_paragraph, area);
348    }
349}
350
351fn generate_silo_bar(percentage: f64, width: usize) -> String {
352    let filled = ((percentage) * width as f64).round() as usize;
353    let empty = width.saturating_sub(filled);
354
355    let filled_blocks = "█".repeat(filled);
356    let empty_blocks = "░".repeat(empty);
357    format!("[{}{}]", filled_blocks, empty_blocks)
358}