1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use crate::classify::{Kind, Rules};
12use crate::git::GitVcs;
13use crate::language;
14use crate::model::FileDiff;
15use crate::review::ReviewError;
16use crate::vcs::Vcs;
17
18const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct LanguageCount {
25 pub name: &'static str,
26 pub color: language::Rgb,
27 pub files: usize,
28 pub lines: usize,
29 pub code: usize,
30 pub comments: usize,
31 pub blanks: usize,
32 pub bytes: u64,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct RepoStats {
38 pub languages: Vec<LanguageCount>,
39 pub unknown_files: usize,
41 pub skipped_files: usize,
43 pub generated_files: usize,
45}
46
47impl RepoStats {
48 #[must_use]
49 pub fn totals(&self) -> LanguageCount {
50 let mut total = LanguageCount {
51 name: "total",
52 color: (0, 0, 0),
53 files: 0,
54 lines: 0,
55 code: 0,
56 comments: 0,
57 blanks: 0,
58 bytes: 0,
59 };
60 for language in &self.languages {
61 total.files += language.files;
62 total.lines += language.lines;
63 total.code += language.code;
64 total.comments += language.comments;
65 total.blanks += language.blanks;
66 total.bytes += language.bytes;
67 }
68 total
69 }
70}
71
72#[must_use]
80pub fn scan(root: &Path, paths: &[PathBuf], rules: &Rules) -> RepoStats {
81 let mut stats = RepoStats::default();
82 let mut counts: HashMap<&'static str, LanguageCount> = HashMap::new();
83 for path in paths {
84 let relative = path.to_string_lossy();
85 if rules.kind(&relative, None) == Kind::Generated {
86 stats.generated_files += 1;
87 continue;
88 }
89 let full = root.join(path);
90 let Ok(metadata) = std::fs::metadata(&full) else {
91 stats.skipped_files += 1;
92 continue;
93 };
94 if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES {
95 stats.skipped_files += 1;
96 continue;
97 }
98 let Ok(bytes) = std::fs::read(&full) else {
99 stats.skipped_files += 1;
100 continue;
101 };
102 if is_binary(&bytes) {
103 stats.skipped_files += 1;
104 continue;
105 }
106 let text = String::from_utf8_lossy(&bytes);
107 let Some(language) = language::of_path(&relative) else {
108 stats.unknown_files += 1;
109 continue;
110 };
111 let (code, comments, blanks) = language::count_lines(&text, Some(language));
112 let entry = counts.entry(language.name).or_insert(LanguageCount {
113 name: language.name,
114 color: language.color,
115 files: 0,
116 lines: 0,
117 code: 0,
118 comments: 0,
119 blanks: 0,
120 bytes: 0,
121 });
122 entry.files += 1;
123 entry.lines += code + comments + blanks;
124 entry.code += code;
125 entry.comments += comments;
126 entry.blanks += blanks;
127 entry.bytes += metadata.len();
128 }
129 let mut languages: Vec<LanguageCount> = counts.into_values().collect();
132 languages.sort_by(|a, b| b.code.cmp(&a.code).then_with(|| a.name.cmp(b.name)));
133 stats.languages = languages;
134 stats
135}
136
137pub fn scan_repo(
141 repo_root: &Path,
142 extra: &[String],
143 rules: &Rules,
144) -> Result<RepoStats, ReviewError> {
145 let vcs = GitVcs::open(repo_root)?;
146 let mut paths = vcs.tracked_files()?;
147 paths.extend(extra.iter().map(PathBuf::from));
148 Ok(scan(repo_root, &paths, rules))
149}
150
151fn is_binary(bytes: &[u8]) -> bool {
153 bytes.iter().take(8000).any(|byte| *byte == 0)
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct LanguageChurn {
159 pub name: &'static str,
160 pub color: language::Rgb,
161 pub files: usize,
162 pub added: usize,
163 pub deleted: usize,
164}
165
166impl LanguageChurn {
167 #[must_use]
168 pub fn churn(&self) -> usize {
169 self.added + self.deleted
170 }
171}
172
173pub fn review_mix<'a>(files: impl IntoIterator<Item = &'a FileDiff>) -> Vec<LanguageChurn> {
177 let mut totals: HashMap<&'static str, LanguageChurn> = HashMap::new();
178 for file in files {
179 let Some(language) = language::of_path(&file.path) else {
180 continue;
181 };
182 let (added, deleted) = file.diffstat();
183 let entry = totals.entry(language.name).or_insert(LanguageChurn {
184 name: language.name,
185 color: language.color,
186 files: 0,
187 added: 0,
188 deleted: 0,
189 });
190 entry.files += 1;
191 entry.added += added;
192 entry.deleted += deleted;
193 }
194 let mut mix: Vec<LanguageChurn> = totals.into_values().collect();
195 mix.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.name.cmp(b.name)));
196 mix
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::model::{DiffLine, FileStatus, HashCache, Hunk, HunkId, LineKind};
203
204 fn file(path: &str, added: usize, deleted: usize) -> FileDiff {
205 let mut lines = Vec::new();
206 for _ in 0..added {
207 lines.push(DiffLine {
208 kind: LineKind::Added,
209 old_no: None,
210 new_no: Some(1),
211 text: "x".into(),
212 emphasis: Vec::new(),
213 reformat_only: false,
214 });
215 }
216 for _ in 0..deleted {
217 lines.push(DiffLine {
218 kind: LineKind::Deleted,
219 old_no: Some(1),
220 new_no: None,
221 text: "y".into(),
222 emphasis: Vec::new(),
223 reformat_only: false,
224 });
225 }
226 FileDiff {
227 path: path.to_owned(),
228 old_path: None,
229 status: FileStatus::Modified,
230 binary: false,
231 old_text: None,
232 new_text: None,
233 hunks: vec![Hunk {
234 id: HunkId(String::new()),
235 old_start: 1,
236 old_lines: 1,
237 new_start: 1,
238 new_lines: 1,
239 context: String::new(),
240 lines,
241 }],
242 hashes: HashCache::default(),
243 }
244 }
245
246 #[test]
247 fn the_review_mix_groups_churn_by_language_busiest_first() {
248 let files = vec![
249 file("README.md", 3, 1),
250 file("src/main.rs", 40, 10),
251 file("src/lib.rs", 5, 5),
252 file("data.bin", 99, 0),
253 ];
254 let mix = review_mix(&files);
255 let shape: Vec<(&str, usize, usize, usize)> = mix
256 .iter()
257 .map(|entry| (entry.name, entry.files, entry.added, entry.deleted))
258 .collect();
259 assert_eq!(
260 shape,
261 vec![("Rust", 2, 45, 15), ("Markdown", 1, 3, 1)],
262 "two rust files fold into one row; the binary has no language"
263 );
264 }
265
266 #[test]
267 fn scanning_counts_each_language_and_reports_what_it_left_out() {
268 let dir = tempfile::tempdir().expect("tempdir");
269 let root = dir.path();
270 std::fs::create_dir_all(root.join("src")).expect("mkdir");
271 std::fs::write(
272 root.join("src/main.rs"),
273 "// note\nfn main() {\n\n let x = 1;\n}\n",
274 )
275 .expect("write");
276 std::fs::write(root.join("run.sh"), "#!/bin/sh\n# comment\necho hi\n").expect("write");
277 std::fs::write(root.join("logo.bin"), [0u8, 1, 2, 3]).expect("write");
278 std::fs::write(root.join("mystery.qqq"), "some text\n").expect("write");
279
280 let paths = [
281 PathBuf::from("src/main.rs"),
282 PathBuf::from("run.sh"),
283 PathBuf::from("logo.bin"),
284 PathBuf::from("mystery.qqq"),
285 PathBuf::from("gone.rs"),
286 ];
287 let stats = scan(root, &paths, &Rules::default());
288
289 let shape: Vec<(&str, usize, usize, usize, usize)> = stats
290 .languages
291 .iter()
292 .map(|l| (l.name, l.files, l.code, l.comments, l.blanks))
293 .collect();
294 assert_eq!(shape, vec![("Rust", 1, 3, 1, 1), ("Shell", 1, 2, 1, 0)]);
295 assert_eq!(stats.unknown_files, 1, "the .qqq file has no language");
296 assert_eq!(stats.skipped_files, 2, "the binary and the missing path");
297 assert_eq!(stats.totals().lines, 8);
298 }
299
300 #[test]
303 fn scan_repo_counts_the_index_and_the_untracked_files_it_is_given() {
304 let dir = tempfile::tempdir().expect("tempdir");
305 let root = dir.path();
306 let repo = crate::test_git::init_repo(root, Some("main"));
307 std::fs::write(root.join("tracked.rs"), "fn main() {}\n").expect("write");
308 let time = git2::Time::new(1_700_000_000, 0);
309 let sig = git2::Signature::new("test", "test@test", &time).expect("sig");
310 crate::test_git::commit_all(&repo, "base", &sig);
311 std::fs::write(root.join("fresh.py"), "print(1)\n").expect("write");
312
313 let counted = scan_repo(root, &[], &Rules::default()).expect("scan");
314 assert_eq!(
315 counted.languages.iter().map(|l| l.name).collect::<Vec<_>>(),
316 vec!["Rust"],
317 "the index alone knows nothing of fresh.py"
318 );
319
320 let both = scan_repo(root, &["fresh.py".to_owned()], &Rules::default()).expect("scan");
321 assert_eq!(
322 both.languages.iter().map(|l| l.name).collect::<Vec<_>>(),
323 vec!["Python", "Rust"],
324 "python outranks rust by a line, and the untracked file counts"
325 );
326 }
327
328 #[test]
329 fn an_empty_scan_totals_zero() {
330 let dir = tempfile::tempdir().expect("tempdir");
331 let stats = scan(dir.path(), &[], &Rules::default());
332 assert!(stats.languages.is_empty());
333 assert_eq!(stats.totals().code, 0);
334 }
335}