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#[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#[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
73pub 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 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
142pub 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
188pub 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 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
219pub 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
233pub 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}