This commit is contained in:
dualfroz committed 2026-06-10 12:41:14 +02:00
commit 4cf726709f
36 files changed
+8617

No files matched your search

+292
View File
@@ -0,0 +1,292 @@
pub mod ui;
use crate::cli::{StatsAction, StatsArgs};
use crate::config::AppConfig;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct CodeStats {
pub total_lines: usize,
pub file_count: usize,
pub function_count: usize,
pub avg_fn_length: f64,
pub languages: Vec<(String, f64)>,
pub todos: Vec<TodoItem>,
pub sec_warnings: Vec<SecWarning>,
pub time_entries: Vec<TimeEntry>,
}
#[derive(Debug, Clone)]
pub struct TodoItem {
pub file: String,
pub line: usize,
pub kind: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct SecWarning {
pub file: String,
pub line: usize,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct TimeEntry {
pub file: String,
pub duration_mins: u64, // stored as minutes
}
// ── VSC time data ─────────────────────────────────────────────────────────────
/// Load time-per-file data from ~/.fxg/vsc-data/<repo_name>.json
/// Expected format: `{ "files": { "src/main.rs": 3600 } }` (seconds)
pub fn load_vsc_data(vsc_dir: &Path, repo_name: &str) -> Vec<TimeEntry> {
let path = vsc_dir.join(format!("{}.json", repo_name));
let Ok(raw) = std::fs::read_to_string(&path) else { return vec![] };
let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) else { return vec![] };
let Some(files) = val.get("files").and_then(|f| f.as_object()) else { return vec![] };
let mut entries: Vec<TimeEntry> = files.iter()
.filter_map(|(file, secs)| {
let s = secs.as_u64()?;
Some(TimeEntry { file: file.clone(), duration_mins: s / 60 })
}).collect();
entries.sort_by(|a, b| b.duration_mins.cmp(&a.duration_mins));
entries
}
// ── language detection ────────────────────────────────────────────────────────
fn detect_lang(ext: &str) -> Option<&'static str> {
Some(match ext {
"rs" => "Rust",
"ts" | "tsx" => "TypeScript",
"js" | "jsx" => "JavaScript",
"py" => "Python",
"go" => "Go",
"java" => "Java",
"sql" => "SQL",
"sh" => "Shell",
"toml" => "TOML",
"json" => "JSON",
"md" => "Markdown",
_ => return None,
})
}
fn count_fns(content: &str, lang: &str) -> usize {
let pat = match lang { "Rust" => " fn ", "Python" => "def ", "Go" => "func ", _ => "function " };
content.matches(pat).count()
}
// ── secret heuristics ─────────────────────────────────────────────────────────
fn scan_secrets(content: &str, file: &str) -> Vec<SecWarning> {
let mut warnings = Vec::new();
for (i, line) in content.lines().enumerate() {
let l = line.to_lowercase();
if (l.contains("secret") || l.contains("api_key") || l.contains("password") || l.contains("jwt_secret"))
&& line.contains('=') && !line.trim().starts_with("//") && !line.trim().starts_with('#')
&& !line.ends_with("\"\"") && !line.ends_with("''")
{
warnings.push(SecWarning { file: file.to_string(), line: i + 1, reason: "Possible hardcoded secret".into() });
}
}
warnings
}
// ── file walker ───────────────────────────────────────────────────────────────
fn walk_collect(root: &Path, stats: &mut CodeStats) {
let Ok(entries) = std::fs::read_dir(root) else { return };
let mut lang_lines: std::collections::HashMap<String, usize> = Default::default();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !matches!(name.as_ref(), ".git" | "target" | "node_modules") { walk_collect(&path, stats); }
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let lang = match detect_lang(ext) { Some(l) => l, None => continue };
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let line_count = content.lines().count();
stats.total_lines += line_count;
stats.file_count += 1;
*lang_lines.entry(lang.to_string()).or_default() += line_count;
stats.function_count += count_fns(&content, lang);
let rel = path.to_string_lossy().to_string();
for (i, line) in content.lines().enumerate() {
for kw in &["TODO", "FIXME", "HACK", "XXX"] {
if line.contains(kw) {
stats.todos.push(TodoItem { file: rel.clone(), line: i + 1, kind: kw.to_string(), text: line.trim().chars().take(80).collect() });
break;
}
}
}
stats.sec_warnings.extend(scan_secrets(&content, &rel));
}
let total: usize = lang_lines.values().sum();
stats.languages = lang_lines.iter()
.map(|(l, c)| (l.clone(), if total > 0 { *c as f64 / total as f64 * 100.0 } else { 0.0 }))
.collect();
stats.languages.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
}
pub fn collect_stats(root: &Path, vsc_dir: Option<&Path>) -> Result<CodeStats> {
let mut stats = CodeStats::default();
walk_collect(root, &mut stats);
if stats.function_count > 0 {
stats.avg_fn_length = stats.total_lines as f64 / stats.function_count as f64;
}
if let Some(vsc) = vsc_dir {
let repo_name = root.file_name().unwrap_or_default().to_string_lossy();
stats.time_entries = load_vsc_data(vsc, &repo_name);
}
Ok(stats)
}
pub async fn run(args: StatsArgs, cfg: &AppConfig) -> Result<()> {
let root = args.repo.as_ref().map(|r| cfg.workspace_root.join(r)).unwrap_or_else(|| cfg.workspace_root.clone());
let vsc_dir = cfg.vsc_data_dir.exists().then(|| cfg.vsc_data_dir.as_path());
let stats = collect_stats(&root, vsc_dir)?;
let save = args.save;
let mut output = String::new();
match args.action {
None => {
if save {
let md = stats_to_md(&stats, &root);
save_stats_md(&root, &md)?;
println!(" ✓ Saved to {}/.fxg/stats.md", root.file_name().unwrap_or_default().to_string_lossy());
} else {
return ui::run_stats_tui(stats);
}
}
Some(StatsAction::Security) => {
if stats.sec_warnings.is_empty() {
output.push_str(" ✓ No security warnings found\n");
} else {
for w in &stats.sec_warnings {
output.push_str(&format!(" ⚠ {}:{} — {}\n", w.file, w.line, w.reason));
}
}
}
Some(StatsAction::Lines) => {
output.push_str(&format!(" Lines: {} Files: {}\n", stats.total_lines, stats.file_count));
}
Some(StatsAction::Languages) => {
for (l, p) in &stats.languages {
output.push_str(&format!(" {:15} {:.1}%\n", l, p));
}
}
Some(StatsAction::Functions) => {
output.push_str(&format!(" Functions: {} Avg length: {:.1} lines\n", stats.function_count, stats.avg_fn_length));
}
Some(StatsAction::Time { repo, since }) => {
let r = repo.as_ref().map(|r| cfg.workspace_root.join(r)).unwrap_or_else(|| root.clone());
let name = r.file_name().unwrap_or_default().to_string_lossy();
let mut entries = load_vsc_data(&cfg.vsc_data_dir, &name);
if let Some(since_str) = &since {
use chrono::NaiveDate;
if let Ok(date) = NaiveDate::parse_from_str(since_str, "%Y-%m-%d") {
let since_ts = date.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_utc().timestamp())
.unwrap_or(0);
entries.retain(|e| {
let full_path = r.join(&e.file);
std::fs::metadata(&full_path)
.and_then(|m| m.modified())
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64 >= since_ts)
.unwrap_or(true)
})
.unwrap_or(true)
});
}
}
if entries.is_empty() {
output.push_str(&format!(" No VSC data at {}/{}.json\n", cfg.vsc_data_dir.display(), name));
} else {
let max = entries.first().map(|e| e.duration_mins).unwrap_or(1);
output.push_str(&format!(" {:40} {:>8} BAR\n", "FILE", "TIME"));
output.push_str(&format!(" {}\n", "─".repeat(70)));
for e in &entries {
let bar_w = (e.duration_mins as f64 / max as f64 * 20.0).round() as usize;
output.push_str(&format!(" {:40} {:5}m {}\n",
e.file.split('/').last().unwrap_or(&e.file),
e.duration_mins,
"█".repeat(bar_w)));
}
let total_mins: u64 = entries.iter().map(|e| e.duration_mins).sum();
output.push_str(&format!("\n Total: {}h {}m\n", total_mins / 60, total_mins % 60));
}
}
}
if !output.is_empty() {
print!("{}", output);
if save {
let md = format!("# Stats\n\n```\n{}```\n", output);
save_stats_md(&root, &md)?;
println!(" ✓ Saved to {}/.fxg/stats.md", root.file_name().unwrap_or_default().to_string_lossy());
}
}
Ok(())
}
fn save_stats_md(root: &Path, content: &str) -> Result<()> {
let dir = root.join(".fxg");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("stats.md"), content)?;
Ok(())
}
fn stats_to_md(stats: &CodeStats, root: &Path) -> String {
let name = root.file_name().unwrap_or_default().to_string_lossy();
let mut md = format!("# Stats — {}\n\n", name);
md.push_str(&format!("> Generated {}\n\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")));
md.push_str("## Summary\n\n");
md.push_str(&format!("| Metric | Value |\n|---|---|\n"));
md.push_str(&format!("| Total lines | {} |\n", stats.total_lines));
md.push_str(&format!("| Files | {} |\n", stats.file_count));
md.push_str(&format!("| Functions | {} |\n", stats.function_count));
md.push_str(&format!("| Avg fn length | {:.1} lines |\n", stats.avg_fn_length));
md.push_str(&format!("| TODOs | {} |\n", stats.todos.len()));
md.push_str(&format!("| Security warnings | {} |\n\n", stats.sec_warnings.len()));
if !stats.languages.is_empty() {
md.push_str("## Languages\n\n| Language | Share |\n|---|---|\n");
for (lang, pct) in &stats.languages {
md.push_str(&format!("| {} | {:.1}% |\n", lang, pct));
}
md.push('\n');
}
if !stats.todos.is_empty() {
md.push_str("## TODOs\n\n| Kind | File | Line |\n|---|---|---|\n");
for t in &stats.todos {
let f = t.file.split(['/', '\\']).last().unwrap_or(&t.file);
md.push_str(&format!("| `{}` | `{}` | {} |\n", t.kind, f, t.line));
}
md.push('\n');
}
if !stats.sec_warnings.is_empty() {
md.push_str("## Security Warnings\n\n| File | Line | Reason |\n|---|---|---|\n");
for w in &stats.sec_warnings {
let f = w.file.split(['/', '\\']).last().unwrap_or(&w.file);
md.push_str(&format!("| `{}` | {} | {} |\n", f, w.line, w.reason));
}
md.push('\n');
}
md
}
+176
View File
@@ -0,0 +1,176 @@
use crate::stats::{CodeStats, SecWarning, TodoItem};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, List, ListItem, Paragraph, Row, Table, Tabs},
Frame,
};
#[derive(Clone, Copy, PartialEq)]
enum Tab { Overview, Security, Todos, Languages }
struct State {
stats: CodeStats,
tab: Tab,
scroll: usize,
quit: bool,
}
const TABS: &[&str] = &["Overview", "Security", "TODOs", "Languages"];
fn tab_index(t: Tab) -> usize {
match t { Tab::Overview => 0, Tab::Security => 1, Tab::Todos => 2, Tab::Languages => 3 }
}
fn make_bar(value: f64, max: f64, width: usize) -> String {
if max == 0.0 { return " ".repeat(width); }
let filled = ((value / max) * width as f64).round() as usize;
let filled = filled.min(width);
format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
}
fn render_overview(f: &mut Frame, stats: &CodeStats, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(8), Constraint::Fill(1)])
.split(area);
// Summary block
let summary = vec![
Line::from(vec![Span::raw(" Lines of code : "), Span::styled(stats.total_lines.to_string(), Style::default().fg(Color::Cyan))]),
Line::from(vec![Span::raw(" Files : "), Span::raw(stats.file_count.to_string())]),
Line::from(vec![Span::raw(" Functions : "), Span::raw(format!("{} avg length: {:.1} lines", stats.function_count, stats.avg_fn_length))]),
Line::from(""),
Line::from(vec![
Span::raw(" TODOs: "),
Span::styled(stats.todos.len().to_string(), Style::default().fg(Color::Yellow)),
Span::raw(" Security warnings: "),
Span::styled(stats.sec_warnings.len().to_string(),
if stats.sec_warnings.is_empty() { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Red) }),
]),
];
f.render_widget(Paragraph::new(summary).block(Block::default().title(" Summary ").borders(Borders::ALL)), chunks[0]);
// Language bars
let lang_block = Block::default().title(" Languages ").borders(Borders::ALL);
let max_pct = stats.languages.iter().map(|(_, p)| *p).fold(0.0_f64, f64::max);
let items: Vec<ListItem> = stats.languages.iter().take(8).map(|(lang, pct)| {
let bar = make_bar(*pct, max_pct, 20);
ListItem::new(format!(" {:15} {:5.1}% {}", lang, pct, bar))
}).collect();
f.render_widget(List::new(items).block(lang_block), chunks[1]);
}
fn render_security(f: &mut Frame, warnings: &[SecWarning], scroll: usize, area: Rect) {
let block = Block::default().title(format!(" Security — {} warning(s) ", warnings.len())).borders(Borders::ALL);
let items: Vec<ListItem> = warnings.iter().skip(scroll).map(|w| {
ListItem::new(format!(" ⚠ {}:{} — {}", w.file.split('/').last().unwrap_or(&w.file), w.line, w.reason))
.style(Style::default().fg(Color::Yellow))
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_todos(f: &mut Frame, todos: &[TodoItem], scroll: usize, area: Rect) {
let block = Block::default().title(format!(" TODOs / FIXMEs — {} item(s) ", todos.len())).borders(Borders::ALL);
let rows: Vec<Row> = todos.iter().skip(scroll).map(|t| {
let kind_color = match t.kind.as_str() {
"FIXME" => Color::Red, "HACK" => Color::Magenta, _ => Color::Yellow,
};
Row::new(vec![
Cell::from(t.kind.clone()).style(Style::default().fg(kind_color)),
Cell::from(t.file.split('/').last().unwrap_or(&t.file).to_string()),
Cell::from(t.line.to_string()),
Cell::from(t.text.clone()),
])
}).collect();
let widths = [Constraint::Length(7), Constraint::Length(22), Constraint::Length(6), Constraint::Fill(1)];
let header = Row::new(["KIND", "FILE", "LINE", "TEXT"])
.style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(Table::new(rows, widths).header(header).block(block), area);
}
fn render_languages(f: &mut Frame, stats: &CodeStats, area: Rect) {
let block = Block::default().title(" Language breakdown ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let _bar_height: u16 = 3;
let bars: Vec<_> = stats.languages.iter().take(6).collect();
if bars.is_empty() { return; }
let constraints: Vec<Constraint> = bars.iter().map(|_| Constraint::Fill(1)).collect();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(inner);
let max = bars.iter().map(|(_, p)| *p).fold(0.0_f64, f64::max);
for (i, (lang, pct)) in bars.iter().enumerate() {
let gauge = Gauge::default()
.block(Block::default().title(lang.as_str()))
.gauge_style(Style::default().fg(Color::Cyan))
.ratio((pct / max).min(1.0));
f.render_widget(gauge, cols[i]);
}
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg stats ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
// Tab bar
let tab_titles: Vec<Line> = TABS.iter().map(|t| Line::from(*t)).collect();
f.render_widget(
Tabs::new(tab_titles).select(tab_index(state.tab))
.style(Style::default())
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::BOTTOM)),
chunks[0],
);
match state.tab {
Tab::Overview => render_overview(f, &state.stats, chunks[1]),
Tab::Security => render_security(f, &state.stats.sec_warnings, state.scroll, chunks[1]),
Tab::Todos => render_todos(f, &state.stats.todos, state.scroll, chunks[1]),
Tab::Languages => render_languages(f, &state.stats, chunks[1]),
}
f.render_widget(
Paragraph::new(" [1-4] tabs [j/k] scroll [e] export [q] quit")
.style(Style::default().fg(Color::DarkGray)),
chunks[2],
);
}
pub fn run_stats_tui(stats: CodeStats) -> Result<()> {
let mut state = State { stats, tab: Tab::Overview, scroll: 0, quit: false };
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Tab | KeyCode::Right => state.tab = match state.tab { Tab::Overview => Tab::Security, Tab::Security => Tab::Todos, Tab::Todos => Tab::Languages, Tab::Languages => Tab::Overview },
KeyCode::BackTab | KeyCode::Left => state.tab = match state.tab { Tab::Overview => Tab::Languages, Tab::Security => Tab::Overview, Tab::Todos => Tab::Security, Tab::Languages => Tab::Todos },
KeyCode::Char('1') => { state.tab = Tab::Overview; state.scroll = 0; }
KeyCode::Char('2') => { state.tab = Tab::Security; state.scroll = 0; }
KeyCode::Char('3') => { state.tab = Tab::Todos; state.scroll = 0; }
KeyCode::Char('4') => { state.tab = Tab::Languages; state.scroll = 0; }
KeyCode::Down | KeyCode::Char('j') => state.scroll += 1,
KeyCode::Up | KeyCode::Char('k') => { if state.scroll > 0 { state.scroll -= 1; } }
_ => {}
}
}
Ok(state.quit)
})
}