Skip to main content

prj_core/
stats.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use bytesize::ByteSize;
5use serde::Serialize;
6
7use crate::project::Project;
8
9#[derive(Debug, Serialize)]
10pub struct GitStatus {
11    pub branch: Option<String>,
12    pub is_dirty: bool,
13    pub changed: usize,
14    pub staged: usize,
15    pub untracked: usize,
16    pub ahead: usize,
17    pub behind: usize,
18}
19
20#[derive(Debug, Serialize)]
21pub struct LangStats {
22    pub code: usize,
23    pub comments: usize,
24    pub blanks: usize,
25    pub files: usize,
26}
27
28#[derive(Debug, Serialize)]
29pub struct LocStats {
30    pub languages: BTreeMap<String, LangStats>,
31    pub total_code: usize,
32    pub total_comments: usize,
33    pub total_blanks: usize,
34    pub total_files: usize,
35}
36
37#[derive(Debug, Serialize)]
38pub struct DiskStats {
39    pub total_bytes: u64,
40    pub artifact_bytes: u64,
41}
42
43impl DiskStats {
44    pub fn total_display(&self) -> String {
45        ByteSize(self.total_bytes).to_string()
46    }
47
48    pub fn artifact_display(&self) -> String {
49        ByteSize(self.artifact_bytes).to_string()
50    }
51}
52
53/// Aggregated statistics for a single project.
54#[derive(Debug, Serialize)]
55pub struct ProjectStats {
56    pub name: String,
57    pub git: Option<GitStatus>,
58    pub loc: LocStats,
59    pub disk: DiskStats,
60}
61
62/// Aggregated statistics across all registered projects.
63#[derive(Debug, Serialize)]
64pub struct OverviewStats {
65    pub total_projects: usize,
66    pub total_code_lines: usize,
67    pub total_disk_bytes: u64,
68    pub total_artifact_bytes: u64,
69    pub dirty_projects: usize,
70    pub projects: Vec<ProjectStats>,
71}
72
73/// Collect git status for a project path.
74pub fn collect_git_status(path: &Path) -> Option<GitStatus> {
75    let repo = git2::Repository::open(path).ok()?;
76
77    let branch = repo
78        .head()
79        .ok()
80        .and_then(|h| h.shorthand().map(|s| s.to_string()));
81
82    let statuses = repo
83        .statuses(Some(
84            git2::StatusOptions::new()
85                .include_untracked(true)
86                .recurse_untracked_dirs(false),
87        ))
88        .ok()?;
89
90    let mut changed = 0;
91    let mut staged = 0;
92    let mut untracked = 0;
93
94    for entry in statuses.iter() {
95        let s = entry.status();
96        if s.intersects(
97            git2::Status::INDEX_NEW
98                | git2::Status::INDEX_MODIFIED
99                | git2::Status::INDEX_DELETED
100                | git2::Status::INDEX_RENAMED
101                | git2::Status::INDEX_TYPECHANGE,
102        ) {
103            staged += 1;
104        }
105        if s.intersects(
106            git2::Status::WT_MODIFIED
107                | git2::Status::WT_DELETED
108                | git2::Status::WT_RENAMED
109                | git2::Status::WT_TYPECHANGE,
110        ) {
111            changed += 1;
112        }
113        if s.intersects(git2::Status::WT_NEW) {
114            untracked += 1;
115        }
116    }
117
118    let is_dirty = changed > 0 || staged > 0 || untracked > 0;
119
120    // ahead/behind
121    let (ahead, behind) = (|| -> Option<(usize, usize)> {
122        let head = repo.head().ok()?;
123        let local_oid = head.target()?;
124        let upstream = repo.branch_upstream_name(head.name()?).ok()?;
125        let upstream_ref = repo.find_reference(upstream.as_str()?).ok()?;
126        let upstream_oid = upstream_ref.target()?;
127        repo.graph_ahead_behind(local_oid, upstream_oid).ok()
128    })()
129    .unwrap_or((0, 0));
130
131    Some(GitStatus {
132        branch,
133        is_dirty,
134        changed,
135        staged,
136        untracked,
137        ahead,
138        behind,
139    })
140}
141
142/// Collect lines-of-code stats using tokei.
143pub fn collect_loc_stats(path: &Path) -> LocStats {
144    let config = tokei::Config {
145        hidden: Some(false),
146        no_ignore: Some(false),
147        ..tokei::Config::default()
148    };
149
150    let mut languages = tokei::Languages::new();
151    languages.get_statistics(&[path], &[], &config);
152
153    let mut lang_map = BTreeMap::new();
154    let mut total_code = 0;
155    let mut total_comments = 0;
156    let mut total_blanks = 0;
157    let mut total_files = 0;
158
159    for (lang_type, lang) in &languages {
160        if lang.code == 0 && lang.comments == 0 && lang.blanks == 0 {
161            continue;
162        }
163        let files = lang.reports.len();
164        lang_map.insert(
165            lang_type.to_string(),
166            LangStats {
167                code: lang.code,
168                comments: lang.comments,
169                blanks: lang.blanks,
170                files,
171            },
172        );
173        total_code += lang.code;
174        total_comments += lang.comments;
175        total_blanks += lang.blanks;
176        total_files += files;
177    }
178
179    LocStats {
180        languages: lang_map,
181        total_code,
182        total_comments,
183        total_blanks,
184        total_files,
185    }
186}
187
188/// Collect disk usage stats.
189pub fn collect_disk_stats(path: &Path, artifact_dirs: &[String]) -> DiskStats {
190    let mut total_bytes: u64 = 0;
191    let mut artifact_bytes: u64 = 0;
192
193    let walker = walkdir::WalkDir::new(path).follow_links(false);
194
195    for entry in walker.into_iter().filter_map(|e| e.ok()) {
196        if !entry.file_type().is_file() {
197            continue;
198        }
199        let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
200        total_bytes += size;
201
202        // Check if this file is inside an artifact directory
203        if let Ok(rel) = entry.path().strip_prefix(path)
204            && let Some(first_component) = rel.components().next()
205        {
206            let component = first_component.as_os_str().to_string_lossy();
207            if artifact_dirs.iter().any(|a| a == component.as_ref()) {
208                artifact_bytes += size;
209            }
210        }
211    }
212
213    DiskStats {
214        total_bytes,
215        artifact_bytes,
216    }
217}
218
219/// Collect full stats for a single project.
220pub fn collect_project_stats(project: &Project) -> ProjectStats {
221    let git = collect_git_status(&project.path);
222    let loc = collect_loc_stats(&project.path);
223    let disk = collect_disk_stats(&project.path, &project.artifact_dirs);
224
225    ProjectStats {
226        name: project.name.clone(),
227        git,
228        loc,
229        disk,
230    }
231}
232
233/// Collect overview stats across all projects (parallelized with rayon).
234pub fn collect_overview_stats(projects: &[Project]) -> OverviewStats {
235    use rayon::prelude::*;
236
237    let project_stats: Vec<ProjectStats> = projects.par_iter().map(collect_project_stats).collect();
238
239    let total_projects = project_stats.len();
240    let total_code_lines: usize = project_stats.iter().map(|s| s.loc.total_code).sum();
241    let total_disk_bytes: u64 = project_stats.iter().map(|s| s.disk.total_bytes).sum();
242    let total_artifact_bytes: u64 = project_stats.iter().map(|s| s.disk.artifact_bytes).sum();
243    let dirty_projects = project_stats
244        .iter()
245        .filter(|s| s.git.as_ref().is_some_and(|g| g.is_dirty))
246        .count();
247
248    OverviewStats {
249        total_projects,
250        total_code_lines,
251        total_disk_bytes,
252        total_artifact_bytes,
253        dirty_projects,
254        projects: project_stats,
255    }
256}