Skip to main content

diffler_core/
stats.rs

1//! Counting a repo, and counting a review.
2//!
3//! Two tallies over the same [`crate::language`] table: the working tree's
4//! lines per language, which needs a read per file, and the diff's churn per
5//! language, which the model already holds. Both are pure functions of what
6//! they are handed, so the caller decides which thread pays.
7
8use 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
18/// Files past this size count as data: a checked-in dump would otherwise
19/// decide the whole breakdown.
20const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
21
22/// How much of one language a repo holds.
23#[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/// The whole scan: one entry per language, heaviest first, plus what it skipped.
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct RepoStats {
38    pub languages: Vec<LanguageCount>,
39    /// Files read but written in no language the table knows.
40    pub unknown_files: usize,
41    /// Files not read at all: binary, oversized, or unreadable.
42    pub skipped_files: usize,
43    /// Files left out as generated, lockfiles included.
44    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/// Count every path under `root`, grouped by language and ordered by code
73/// lines. Reads each file once; a path that is binary, oversized or unreadable
74/// is counted as skipped and never parsed.
75///
76/// What `rules` calls generated is left out, lockfiles included, the way a
77/// repository page counts: a lockfile runs to thousands of lines and would
78/// outrank the code beside it.
79#[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    // heaviest first, and by name where two languages tie, so the table never
130    // reshuffles between two scans of the same tree
131    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
137/// Scan a checkout from its root, opening the backend here so the whole job,
138/// the index read included, happens on the caller's thread. `extra` carries
139/// what git does not track yet, the status screen's untracked files.
140pub 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
151/// A NUL byte in the first block is what git itself treats as binary.
152fn is_binary(bytes: &[u8]) -> bool {
153    bytes.iter().take(8000).any(|byte| *byte == 0)
154}
155
156/// One language's share of a review.
157#[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
173/// What the review is written in, busiest language first. Files in no known
174/// language are left out: naming them would take a row from the languages the
175/// reader can act on.
176pub 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    /// The worker hands `scan_repo` a root and nothing else, so it has to find
301    /// the tracked files itself and fold in what git has not seen yet.
302    #[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}