1#![allow(clippy::multiple_crate_versions)]
4
5pub mod baseline;
6pub mod coverage;
7pub mod delta;
8pub mod history;
9pub mod maintenance;
10pub use baseline::{BaselineEntry, BaselineStore, check_against_baseline, resolve_baselines_path};
11pub use coverage::{FileCoverage, aggregate_line_coverage, lookup_coverage, parse_lcov};
12pub use delta::{
13 FileChangeStatus, FileDelta, MultiFileDelta, MultiScanComparison, MultiScanPoint,
14 ScanComparison, SummaryDelta, compute_delta, compute_multi_delta,
15};
16pub use history::{
17 CleanupPolicy, CleanupPolicyStore, RegistryEntry, ScanRegistry, ScanSummarySnapshot,
18 WatchedDirsStore,
19};
20pub use maintenance::{
21 PrunePlan, PruneReport, PrunedRun, copy_tree, dir_size_bytes, execute_run_prune,
22 plan_run_prune, resolve_output_root, resolve_registry_path, rotate_log, rotated_log_paths,
23 run_output_dir,
24};
25
26use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
31
32use anyhow::{Context, Result};
33use chrono::{DateTime, Utc};
34use encoding_rs::{UTF_16BE, UTF_16LE, WINDOWS_1252};
35use globset::{Glob, GlobSet, GlobSetBuilder};
36use ignore::WalkBuilder;
37use serde::{Deserialize, Serialize};
38use uuid::Uuid;
39
40use sloc_config::{
41 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
42 FailureBehavior, MixedLinePolicy,
43};
44use sloc_languages::style::IndentStyle;
45use sloc_languages::{
46 AnalysisOptions, Language, LineCategory, ParseMode, RawLineCounts, StyleAnalysis,
47 StyleLangScope, analyze_text, classify_physical_lines, detect_language, supported_languages,
48};
49
50const MAX_ANALYSIS_THREADS: usize = 16;
54const DEFAULT_ANALYSIS_THREADS: usize = 4;
56const GENERATED_SAMPLE_BYTES: usize = 1024;
58const MINIFIED_SAMPLE_BYTES: usize = 4096;
60const MINIFIED_LINE_THRESHOLD: usize = 2000;
62const BINARY_SAMPLE_BYTES: usize = 8192;
64
65pub struct ProgressCounters {
67 pub files_done: Arc<AtomicUsize>,
69 pub files_total: Arc<AtomicUsize>,
71 pub phase: Option<Arc<std::sync::Mutex<String>>>,
76 pub attrib_done: Arc<AtomicUsize>,
80 pub attrib_total: Arc<AtomicUsize>,
83}
84
85enum MetadataPolicyOutcome {
87 Skip(Box<FileRecord>),
89 Exclude,
91 Continue,
93}
94
95#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum FileStatus {
98 AnalyzedExact,
99 AnalyzedBestEffort,
100 SkippedBinary,
101 SkippedDecodeError,
102 SkippedUnsupported,
103 SkippedByPolicy,
104 ErrorInternal,
105}
106
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
109#[serde(rename_all = "snake_case")]
110pub enum CocomoMode {
111 #[default]
113 Organic,
114 SemiDetached,
116 Embedded,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CocomoEstimate {
123 pub mode: CocomoMode,
124 pub ksloc: f64,
126 pub effort_person_months: f64,
128 pub duration_months: f64,
130 pub avg_staff: f64,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct EffectiveCounts {
136 pub code_lines: u64,
137 pub comment_lines: u64,
138 pub blank_lines: u64,
139 pub mixed_lines_separate: u64,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ToolMetadata {
144 pub name: String,
145 pub version: String,
146 pub run_id: String,
147 pub timestamp_utc: DateTime<Utc>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct EnvironmentMetadata {
152 pub operating_system: String,
153 pub architecture: String,
154 pub runtime_mode: String,
155 pub initiator_username: String,
156 pub initiator_hostname: String,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub ci_name: Option<String>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
164pub struct SummaryTotals {
165 pub files_considered: u64,
166 pub files_analyzed: u64,
167 pub files_skipped: u64,
168 pub total_physical_lines: u64,
169 pub code_lines: u64,
170 pub comment_lines: u64,
171 pub blank_lines: u64,
172 pub mixed_lines_separate: u64,
173 #[serde(default)]
174 pub functions: u64,
175 #[serde(default)]
176 pub classes: u64,
177 #[serde(default)]
178 pub variables: u64,
179 #[serde(default)]
181 pub variables_member: u64,
182 #[serde(default)]
183 pub variables_local: u64,
184 #[serde(default)]
185 pub variables_global: u64,
186 #[serde(default)]
187 pub macro_definitions: u64,
188 #[serde(default)]
189 pub imports: u64,
190 #[serde(default)]
191 pub test_count: u64,
192 #[serde(default)]
194 pub test_assertion_count: u64,
195 #[serde(default)]
197 pub test_suite_count: u64,
198 #[serde(default)]
200 pub coverage_lines_found: u64,
201 #[serde(default)]
202 pub coverage_lines_hit: u64,
203 #[serde(default)]
204 pub coverage_functions_found: u64,
205 #[serde(default)]
206 pub coverage_functions_hit: u64,
207 #[serde(default)]
208 pub coverage_branches_found: u64,
209 #[serde(default)]
210 pub coverage_branches_hit: u64,
211 #[serde(default)]
213 pub cyclomatic_complexity: u64,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub lsloc: Option<u64>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct LanguageSummary {
221 pub language: Language,
222 pub files: u64,
223 pub total_physical_lines: u64,
224 pub code_lines: u64,
225 pub comment_lines: u64,
226 pub blank_lines: u64,
227 pub mixed_lines_separate: u64,
228 #[serde(default)]
229 pub functions: u64,
230 #[serde(default)]
231 pub classes: u64,
232 #[serde(default)]
233 pub variables: u64,
234 #[serde(default)]
236 pub variables_member: u64,
237 #[serde(default)]
238 pub variables_local: u64,
239 #[serde(default)]
240 pub variables_global: u64,
241 #[serde(default)]
242 pub macro_definitions: u64,
243 #[serde(default)]
244 pub imports: u64,
245 #[serde(default)]
246 pub test_count: u64,
247 #[serde(default)]
248 pub test_assertion_count: u64,
249 #[serde(default)]
250 pub test_suite_count: u64,
251 #[serde(default)]
252 pub coverage_lines_found: u64,
253 #[serde(default)]
254 pub coverage_lines_hit: u64,
255 #[serde(default)]
256 pub coverage_functions_found: u64,
257 #[serde(default)]
258 pub coverage_functions_hit: u64,
259 #[serde(default)]
260 pub coverage_branches_found: u64,
261 #[serde(default)]
262 pub coverage_branches_hit: u64,
263 #[serde(default)]
264 pub cyclomatic_complexity: u64,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub lsloc: Option<u64>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct FileRecord {
271 pub path: String,
272 pub relative_path: String,
273 pub language: Option<Language>,
274 pub size_bytes: u64,
275 pub detected_encoding: Option<String>,
276 pub raw_line_categories: RawLineCounts,
277 pub effective_counts: EffectiveCounts,
278 pub status: FileStatus,
279 pub warnings: Vec<String>,
280 pub generated: bool,
281 pub minified: bool,
282 pub vendor: bool,
283 pub parse_mode: Option<ParseMode>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub submodule: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub coverage: Option<FileCoverage>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub style_analysis: Option<StyleAnalysis>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub cyclomatic_complexity: Option<u32>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub lsloc: Option<u32>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub commit_count: Option<u32>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub last_commit_date: Option<String>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub ownership: Option<Vec<FileOwnership>>,
311 #[serde(skip)]
314 pub content_hash: u64,
315}
316
317impl FileRecord {
318 pub fn is_test_file(&self) -> bool {
322 let rc = &self.raw_line_categories;
323 if rc.test_count > 0 || rc.test_assertion_count > 0 || rc.test_suite_count > 0 {
324 return true;
325 }
326 let p = self.relative_path.to_ascii_lowercase().replace('\\', "/");
327 p.contains("/tests/")
328 || p.contains("/test/")
329 || p.contains("/spec/")
330 || p.contains("__tests__")
331 || p.contains(".test.")
332 || p.contains(".spec.")
333 || p.contains("_test.")
334 || p.contains("_spec.")
335 || p.starts_with("test/")
336 || p.starts_with("tests/")
337 }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct LanguageStyleGroup {
343 pub language_family: String,
345 pub files_count: u32,
347 pub dominant_guide: String,
349 pub dominant_score_pct: u8,
351 pub common_indent_style: String,
353 pub guide_avg_scores: Vec<(String, u8)>,
355 pub line80_compliant_pct: u8,
357 pub line_col_compliant_pct: u8,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct StyleSummary {
364 pub files_analyzed: u32,
366 pub common_indent_style: String,
368 pub line80_compliant_pct: u8,
370 pub line_col_compliant_pct: u8,
372 pub col_threshold: u16,
374 pub by_language: Vec<LanguageStyleGroup>,
376}
377
378pub type CppStyleSummary = StyleSummary;
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct SubmoduleSummary {
385 pub name: String,
386 pub relative_path: String,
387 pub files_analyzed: u64,
388 pub total_physical_lines: u64,
389 pub code_lines: u64,
390 pub comment_lines: u64,
391 pub blank_lines: u64,
392 pub language_summaries: Vec<LanguageSummary>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub git_commit_short: Option<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub git_commit_long: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub git_branch: Option<String>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub git_commit_author: Option<String>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub git_commit_date: Option<String>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub git_remote_url: Option<String>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
416pub struct RawIdentity {
417 pub name: String,
418 pub email: String,
419}
420
421#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
424pub struct AuthorLineCounts {
425 pub code_lines: u64,
426 pub comment_lines: u64,
427 pub blank_lines: u64,
428 pub total_lines: u64,
429}
430
431impl AuthorLineCounts {
432 fn add_category(&mut self, cat: LineCategory) {
433 match cat {
434 LineCategory::Code => self.code_lines += 1,
435 LineCategory::Comment => self.comment_lines += 1,
436 LineCategory::Blank => self.blank_lines += 1,
437 }
438 self.total_lines += 1;
439 }
440
441 fn add(&mut self, other: &AuthorLineCounts) {
442 self.code_lines += other.code_lines;
443 self.comment_lines += other.comment_lines;
444 self.blank_lines += other.blank_lines;
445 self.total_lines += other.total_lines;
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct FileOwnership {
452 pub author_id: u32,
453 pub counts: AuthorLineCounts,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct Author {
462 pub id: u32,
464 pub canonical_name: String,
465 pub canonical_email: String,
466 pub aliases: Vec<RawIdentity>,
468 pub counts: AuthorLineCounts,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct AnalysisRun {
474 pub tool: ToolMetadata,
475 pub environment: EnvironmentMetadata,
476 pub effective_configuration: AppConfig,
477 pub input_roots: Vec<String>,
478 pub summary_totals: SummaryTotals,
479 pub totals_by_language: Vec<LanguageSummary>,
480 pub per_file_records: Vec<FileRecord>,
481 pub skipped_file_records: Vec<FileRecord>,
482 pub warnings: Vec<String>,
483 #[serde(default, skip_serializing_if = "Vec::is_empty")]
485 pub submodule_summaries: Vec<SubmoduleSummary>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub git_commit_short: Option<String>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub git_commit_long: Option<String>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub git_branch: Option<String>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub git_commit_author: Option<String>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub git_tags: Option<String>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub git_nearest_tag: Option<String>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub git_commit_date: Option<String>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub git_remote_url: Option<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub style_summary: Option<StyleSummary>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub cocomo: Option<CocomoEstimate>,
516 #[serde(default)]
518 pub uloc: u64,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub dryness_pct: Option<f32>,
522 #[serde(default, skip_serializing_if = "Vec::is_empty")]
524 pub duplicate_groups: Vec<Vec<String>>,
525 #[serde(default)]
527 pub duplicates_excluded: usize,
528 #[serde(default, skip_serializing_if = "Vec::is_empty")]
532 pub authors: Vec<Author>,
533}
534
535#[derive(Default)]
536struct GitInfo {
537 commit_short: Option<String>,
538 commit_long: Option<String>,
539 branch: Option<String>,
540 author: Option<String>,
541 tags: Option<String>,
542 nearest_tag: Option<String>,
543 commit_date: Option<String>,
544 remote_url: Option<String>,
545}
546
547fn is_git_root(dir: &Path) -> bool {
551 let candidate = dir.join(".git");
552 if candidate.is_dir() {
553 return true;
554 }
555 candidate.is_file() && resolve_git_file_pointer(&candidate, dir).is_some()
556}
557
558fn find_git_dir(start: &Path) -> Option<PathBuf> {
562 let mut current = Some(start);
563 while let Some(dir) = current {
564 let candidate = dir.join(".git");
565 if candidate.is_dir() {
566 return Some(candidate);
567 }
568 if candidate.is_file()
569 && let Some(resolved) = resolve_git_file_pointer(&candidate, dir)
570 {
571 return Some(resolved);
572 }
573 current = dir.parent();
574 }
575 None
576}
577
578fn resolve_git_file_pointer(file: &Path, base_dir: &Path) -> Option<PathBuf> {
582 let content = fs::read_to_string(file).ok()?;
583 let ptr = content.trim().strip_prefix("gitdir: ")?;
584 let ptr_native = ptr.replace('/', std::path::MAIN_SEPARATOR_STR);
587 let resolved = if Path::new(&ptr_native).is_absolute() {
588 PathBuf::from(&ptr_native)
589 } else {
590 base_dir.join(&ptr_native)
591 };
592 let final_path = resolved.canonicalize().unwrap_or(resolved);
596 if final_path.is_dir() {
597 Some(final_path)
598 } else {
599 None
600 }
601}
602
603fn resolve_ref(git_dir: &Path, refname: &str) -> Option<String> {
606 let ref_path = refname
610 .split('/')
611 .fold(git_dir.to_path_buf(), |p, c| p.join(c));
612 if ref_path.exists() {
613 let sha = fs::read_to_string(&ref_path)
614 .ok()
615 .map(|s| s.trim().to_string())
616 .filter(|s| s.len() >= 40 && s.chars().all(|c| c.is_ascii_hexdigit()));
617 if sha.is_some() {
618 return sha;
619 }
620 }
621 let packed = fs::read_to_string(git_dir.join("packed-refs")).ok()?;
625 for line in packed.lines() {
626 if line.starts_with('#') || line.starts_with('^') {
627 continue;
628 }
629 let mut cols = line.splitn(2, ' ');
630 let sha = cols.next()?;
631 let name = cols.next()?.trim();
632 if name == refname {
633 return Some(sha.to_string());
634 }
635 }
636 None
637}
638
639fn parse_url_line(line: &str) -> Option<&str> {
641 let rest = line.strip_prefix("url")?;
642 let rest = rest.trim_start_matches([' ', '\t']);
643 let url = rest.strip_prefix('=')?.trim();
644 if url.is_empty() { None } else { Some(url) }
645}
646
647fn read_git_remote_url(git_dir: &Path) -> Option<String> {
649 let config = fs::read_to_string(git_dir.join("config")).ok()?;
650 let mut in_origin = false;
651 for line in config.lines() {
652 let trimmed = line.trim();
653 if trimmed.starts_with('[') {
654 in_origin = trimmed == r#"[remote "origin"]"#;
655 } else if in_origin && let Some(url) = parse_url_line(trimmed) {
656 return Some(url.to_owned());
657 }
658 }
659 None
660}
661
662fn detect_git_for_run(project_path: &Path) -> GitInfo {
666 let ci_branch = ci_branch_from_env();
668
669 let Some(git_dir) = find_git_dir(project_path) else {
670 return GitInfo {
673 branch: ci_branch,
674 ..GitInfo::default()
675 };
676 };
677
678 let head_raw = match fs::read_to_string(git_dir.join("HEAD")) {
679 Ok(s) => s.trim().to_string(),
680 Err(_) => {
681 return GitInfo {
682 branch: ci_branch,
683 ..GitInfo::default()
684 };
685 }
686 };
687
688 let (branch_from_head, commit_long) = head_raw.strip_prefix("ref: ").map_or_else(
689 || {
690 if head_raw.len() >= 40 && head_raw.chars().all(|c| c.is_ascii_hexdigit()) {
691 (None, Some(head_raw[..40].to_string()))
693 } else {
694 (None, None)
695 }
696 },
697 |refname| {
698 let branch = refname
699 .strip_prefix("refs/heads/")
700 .map(|b| b.trim().to_string());
701 let sha = resolve_ref(&git_dir, refname.trim());
702 (branch, sha)
703 },
704 );
705 let branch = branch_from_head.or(ci_branch);
708
709 let commit_short = commit_long
710 .as_deref()
711 .map(|s| s.chars().take(7).collect::<String>());
712
713 let author = run_git_cmd(project_path, &["log", "-1", "--format=%an", "HEAD"]);
714 let commit_date = run_git_cmd(project_path, &["log", "-1", "--format=%aI", "HEAD"]);
715 let remote_url = read_git_remote_url(&git_dir);
716
717 let tags = run_git_cmd(project_path, &["tag", "--points-at", "HEAD"]).map(|t| {
720 t.lines()
721 .filter(|l| !l.is_empty())
722 .collect::<Vec<_>>()
723 .join(", ")
724 });
725 let nearest_tag = run_git_cmd(project_path, &["describe", "--tags", "--abbrev=0", "HEAD"]);
726
727 GitInfo {
728 commit_short,
729 commit_long,
730 branch,
731 author,
732 tags,
733 nearest_tag,
734 commit_date,
735 remote_url,
736 }
737}
738
739fn run_git_cmd(dir: &Path, args: &[&str]) -> Option<String> {
741 let candidates: &[&str] = &[
745 "git",
747 "/usr/bin/git",
749 "/usr/local/bin/git",
750 "/opt/homebrew/bin/git",
751 r"C:\Program Files\Git\cmd\git.exe",
753 r"C:\Program Files\Git\bin\git.exe",
754 r"C:\Program Files (x86)\Git\cmd\git.exe",
755 ];
756 for &exe in candidates {
757 let result = std::process::Command::new(exe)
758 .args(["-c", "safe.directory=*"])
759 .args(args)
760 .current_dir(dir)
761 .output()
762 .ok()
763 .filter(|o| o.status.success())
764 .and_then(|o| String::from_utf8(o.stdout).ok())
765 .map(|s| s.trim().to_string())
766 .filter(|s| !s.is_empty());
767 if result.is_some() {
768 return result;
769 }
770 }
771 None
772}
773
774fn detect_file_activity(
779 project_path: &Path,
780 window_days: u32,
781) -> HashMap<String, (u32, Option<String>)> {
782 let since = format!("--since={window_days} days ago");
783 let out = run_git_cmd(
787 project_path,
788 &[
789 "-c",
790 "core.quotepath=false",
791 "log",
792 since.as_str(),
793 "--no-merges",
794 "--name-status",
795 "--relative",
796 "--pretty=format:%x00%aI",
797 ],
798 );
799 out.map(|s| parse_activity_log(&s)).unwrap_or_default()
800}
801
802fn parse_activity_log(out: &str) -> HashMap<String, (u32, Option<String>)> {
806 let mut map: HashMap<String, (u32, Option<String>)> = HashMap::new();
807 let mut current_date: Option<String> = None;
808 for line in out.lines() {
809 if let Some(date) = line.strip_prefix('\u{0}') {
810 let d = date.trim();
811 current_date = (!d.is_empty()).then(|| d.to_owned());
812 continue;
813 }
814 if line.trim().is_empty() {
815 continue;
816 }
817 let mut fields = line.split('\t');
819 let status = fields.next().unwrap_or("");
820 let path = if status.starts_with('R') || status.starts_with('C') {
821 fields.next_back()
822 } else {
823 fields.next()
824 };
825 let Some(path) = path.map(str::trim).filter(|p| !p.is_empty()) else {
826 continue;
827 };
828 let entry = map.entry(path.to_owned()).or_insert((0, None));
829 entry.0 += 1;
830 if entry.1.is_none() {
831 entry.1.clone_from(¤t_date);
832 }
833 }
834 map
835}
836
837fn set_progress_phase(progress: Option<&ProgressCounters>, label: &str) {
840 if let Some(phase) = progress.and_then(|p| p.phase.as_ref())
841 && let Ok(mut current) = phase.lock()
842 {
843 *current = label.to_string();
844 }
845}
846
847type BlamePairs = Vec<(LineCategory, RawIdentity)>;
849
850fn attribute_ownership(
864 root: &Path,
865 records: &mut [FileRecord],
866 progress: Option<&ProgressCounters>,
867 cancel: Option<&AtomicBool>,
868) -> Vec<Author> {
869 set_progress_phase(progress, "Attributing authorship");
872
873 let indices: Vec<usize> = records
876 .iter()
877 .enumerate()
878 .filter(|(_, rec)| rec.language.is_some())
879 .map(|(i, _)| i)
880 .collect();
881 if let Some(p) = progress {
882 p.attrib_total.store(indices.len(), Ordering::Relaxed);
883 p.attrib_done.store(0, Ordering::Relaxed);
884 }
885
886 let attrib_done = progress.map(|p| p.attrib_done.as_ref());
887 let blamed = parallel_blame(root, records, &indices, cancel, attrib_done);
888
889 let mut resolver = AuthorResolver::default();
892 for (pos, &idx) in indices.iter().enumerate() {
893 let Some(pairs) = blamed.get(pos).and_then(Option::as_ref) else {
894 continue;
895 };
896 let mut per_file: HashMap<u32, AuthorLineCounts> = HashMap::new();
897 for (category, ident) in pairs {
898 let id = resolver.resolve(ident);
899 per_file.entry(id).or_default().add_category(*category);
900 }
901
902 let mut ownership: Vec<FileOwnership> = per_file
903 .into_iter()
904 .map(|(author_id, counts)| {
905 resolver.authors[author_id as usize].counts.add(&counts);
906 FileOwnership { author_id, counts }
907 })
908 .collect();
909 ownership.sort_by_key(|entry| std::cmp::Reverse(entry.counts.total_lines));
910 records[idx].ownership = Some(ownership);
911 }
912
913 resolver.finish(records)
914}
915
916fn blame_one(root: &Path, rec: &FileRecord) -> Option<BlamePairs> {
921 let language = rec.language?;
922 let bytes = std::fs::read(&rec.path).ok()?;
923 let text = String::from_utf8_lossy(&bytes);
924 let categories = classify_physical_lines(language, &text);
925 let blame = blame_line_identities(root, &rec.relative_path);
926 if blame.is_empty() {
927 return None;
928 }
929 Some(categories.into_iter().zip(blame).collect())
930}
931
932fn parallel_blame(
937 root: &Path,
938 records: &[FileRecord],
939 indices: &[usize],
940 cancel: Option<&AtomicBool>,
941 attrib_done: Option<&AtomicUsize>,
942) -> Vec<Option<BlamePairs>> {
943 let n = indices.len();
944 if n == 0 {
945 return Vec::new();
946 }
947 let thread_count = std::thread::available_parallelism().map_or(DEFAULT_ANALYSIS_THREADS, |t| {
948 t.get().min(MAX_ANALYSIS_THREADS)
949 });
950 let next_index = AtomicUsize::new(0);
951
952 let chunks: Vec<Vec<(usize, Option<BlamePairs>)>> = std::thread::scope(|s| {
953 let mut handles = Vec::with_capacity(thread_count);
954 for _ in 0..thread_count {
955 handles.push(s.spawn(|| {
956 let mut local: Vec<(usize, Option<BlamePairs>)> = Vec::new();
957 loop {
958 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
959 break;
960 }
961 let pos = next_index.fetch_add(1, Ordering::Relaxed);
962 if pos >= n {
963 break;
964 }
965 let payload = blame_one(root, &records[indices[pos]]);
966 if let Some(done) = attrib_done {
967 done.fetch_add(1, Ordering::Relaxed);
968 }
969 local.push((pos, payload));
970 }
971 local
972 }));
973 }
974 handles
975 .into_iter()
976 .map(|h| h.join().unwrap_or_default())
977 .collect()
978 });
979
980 let mut out: Vec<Option<BlamePairs>> = (0..n).map(|_| None).collect();
981 for chunk in chunks {
982 for (pos, payload) in chunk {
983 out[pos] = payload;
984 }
985 }
986 out
987}
988
989#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
992#[serde(rename_all = "snake_case")]
993pub enum AttributionSeverity {
994 Light,
996 Moderate,
998 Heavy,
1001}
1002
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1009pub struct AttributionEstimate {
1010 pub is_git: bool,
1012 pub blameable_files: u64,
1014 pub commit_count: u64,
1016 pub severity: AttributionSeverity,
1018 pub recommend_attribution: bool,
1022 pub estimated_seconds: u64,
1024 pub submodule_count: u64,
1026 pub combined_commit_count: u64,
1029}
1030
1031const BLAME_FILES_PER_SEC: u64 = 50;
1035const ATTRIB_HEAVY_FILES: u64 = 10_000;
1041const ATTRIB_MODERATE_FILES: u64 = 2_000;
1043const ATTRIB_DEEP_HISTORY_COMMITS: u64 = 50_000;
1046
1047#[must_use]
1051pub fn estimate_attribution_cost(root: &Path) -> AttributionEstimate {
1052 if find_git_dir(root).is_none() {
1053 return AttributionEstimate {
1054 is_git: false,
1055 blameable_files: 0,
1056 commit_count: 0,
1057 severity: AttributionSeverity::Light,
1058 recommend_attribution: true,
1059 estimated_seconds: 0,
1060 submodule_count: 0,
1061 combined_commit_count: 0,
1062 };
1063 }
1064
1065 let overrides = std::collections::BTreeMap::new();
1068 let blameable_files = run_git_cmd(root, &["ls-files", "--recurse-submodules"])
1069 .map(|out| {
1070 out.lines()
1071 .filter(|line| {
1072 !line.is_empty()
1073 && detect_language(Path::new(line), None, &overrides, false).is_some()
1074 })
1075 .count() as u64
1076 })
1077 .unwrap_or(0);
1078
1079 let commit_count = count_head_commits(root);
1080
1081 let submodules = detect_submodules(root);
1085 let submodule_commits = count_submodule_commits(root, &submodules);
1086 let combined_commit_count = commit_count.saturating_add(submodule_commits);
1087
1088 let severity = classify_attribution_severity(blameable_files, commit_count);
1089 AttributionEstimate {
1090 is_git: true,
1091 blameable_files,
1092 commit_count,
1093 severity,
1094 recommend_attribution: severity != AttributionSeverity::Heavy,
1095 estimated_seconds: blameable_files.div_ceil(BLAME_FILES_PER_SEC),
1096 submodule_count: submodules.len() as u64,
1097 combined_commit_count,
1098 }
1099}
1100
1101fn count_head_commits(dir: &Path) -> u64 {
1103 run_git_cmd(dir, &["rev-list", "--count", "HEAD"])
1104 .and_then(|s| s.trim().parse::<u64>().ok())
1105 .unwrap_or(0)
1106}
1107
1108fn count_submodule_commits(root: &Path, submodules: &[(String, PathBuf)]) -> u64 {
1111 let n = submodules.len();
1112 if n == 0 {
1113 return 0;
1114 }
1115 let thread_count = std::thread::available_parallelism()
1116 .map_or(DEFAULT_ANALYSIS_THREADS, |t| {
1117 t.get().min(MAX_ANALYSIS_THREADS)
1118 })
1119 .min(n);
1120 let next_index = AtomicUsize::new(0);
1121
1122 let partials: Vec<u64> = std::thread::scope(|s| {
1123 let mut handles = Vec::with_capacity(thread_count);
1124 for _ in 0..thread_count {
1125 handles.push(s.spawn(|| {
1126 let mut sum = 0u64;
1127 loop {
1128 let i = next_index.fetch_add(1, Ordering::Relaxed);
1129 if i >= n {
1130 break;
1131 }
1132 sum = sum.saturating_add(count_head_commits(&root.join(&submodules[i].1)));
1133 }
1134 sum
1135 }));
1136 }
1137 handles.into_iter().map(|h| h.join().unwrap_or(0)).collect()
1138 });
1139 partials.iter().sum()
1140}
1141
1142#[must_use]
1147fn classify_attribution_severity(blameable_files: u64, commit_count: u64) -> AttributionSeverity {
1148 let base = if blameable_files >= ATTRIB_HEAVY_FILES {
1149 AttributionSeverity::Heavy
1150 } else if blameable_files >= ATTRIB_MODERATE_FILES {
1151 AttributionSeverity::Moderate
1152 } else {
1153 AttributionSeverity::Light
1154 };
1155 if commit_count < ATTRIB_DEEP_HISTORY_COMMITS {
1156 return base;
1157 }
1158 match base {
1159 AttributionSeverity::Light if blameable_files >= ATTRIB_MODERATE_FILES / 2 => {
1160 AttributionSeverity::Moderate
1161 }
1162 AttributionSeverity::Moderate => AttributionSeverity::Heavy,
1163 other => other,
1164 }
1165}
1166
1167#[derive(Default)]
1169struct AuthorResolver {
1170 authors: Vec<Author>,
1171 key_to_id: HashMap<String, u32>,
1173 seen_aliases: Vec<HashSet<RawIdentity>>,
1175}
1176
1177impl AuthorResolver {
1178 fn resolve(&mut self, ident: &RawIdentity) -> u32 {
1179 let key = normalize_email_key(ident);
1180 if let Some(&id) = self.key_to_id.get(&key) {
1181 if self.seen_aliases[id as usize].insert(ident.clone()) {
1182 self.authors[id as usize].aliases.push(ident.clone());
1183 }
1184 return id;
1185 }
1186 let id = self.authors.len() as u32;
1187 let canonical_name = if ident.name.trim().is_empty() {
1188 ident.email.clone()
1189 } else {
1190 ident.name.clone()
1191 };
1192 self.authors.push(Author {
1193 id,
1194 canonical_name,
1195 canonical_email: ident.email.clone(),
1196 aliases: vec![ident.clone()],
1197 counts: AuthorLineCounts::default(),
1198 });
1199 self.seen_aliases.push(HashSet::from([ident.clone()]));
1200 self.key_to_id.insert(key, id);
1201 id
1202 }
1203
1204 fn finish(self, records: &mut [FileRecord]) -> Vec<Author> {
1207 let mut order: Vec<usize> = (0..self.authors.len()).collect();
1208 order.sort_by(|&a, &b| {
1209 self.authors[b]
1210 .counts
1211 .code_lines
1212 .cmp(&self.authors[a].counts.code_lines)
1213 .then_with(|| {
1214 self.authors[a]
1215 .canonical_name
1216 .cmp(&self.authors[b].canonical_name)
1217 })
1218 });
1219 let mut remap = vec![0u32; self.authors.len()];
1220 for (new_id, &old) in order.iter().enumerate() {
1221 remap[old] = new_id as u32;
1222 }
1223 for rec in records.iter_mut() {
1224 if let Some(ownership) = rec.ownership.as_mut() {
1225 for entry in ownership.iter_mut() {
1226 entry.author_id = remap[entry.author_id as usize];
1227 }
1228 }
1229 }
1230 let mut sorted: Vec<Author> = order.iter().map(|&old| self.authors[old].clone()).collect();
1231 for (new_id, author) in sorted.iter_mut().enumerate() {
1232 author.id = new_id as u32;
1233 }
1234 sorted
1235 }
1236}
1237
1238fn normalize_email_key(ident: &RawIdentity) -> String {
1243 let email = ident.email.trim().to_lowercase();
1244 if email.is_empty() || email == "not.committed.yet" || !email.contains('@') {
1245 return format!("name:{}", ident.name.trim().to_lowercase());
1246 }
1247 match email.split_once('@') {
1249 Some((local, domain)) => {
1250 let core = local.split('+').next().unwrap_or(local);
1251 format!("{core}@{domain}")
1252 }
1253 None => email,
1254 }
1255}
1256
1257fn blame_line_identities(root: &Path, rel: &str) -> Vec<RawIdentity> {
1268 run_git_cmd(root, &["blame", "--line-porcelain", "-w", "-M", "--", rel])
1269 .map(|out| parse_blame_porcelain(&out))
1270 .unwrap_or_default()
1271}
1272
1273fn parse_blame_porcelain(out: &str) -> Vec<RawIdentity> {
1277 let mut identities = Vec::new();
1278 let mut name = String::new();
1279 let mut email = String::new();
1280 for line in out.lines() {
1281 if let Some(rest) = line.strip_prefix("author ") {
1282 name = rest.trim().to_owned();
1283 } else if let Some(rest) = line.strip_prefix("author-mail ") {
1284 email = rest
1285 .trim()
1286 .trim_start_matches('<')
1287 .trim_end_matches('>')
1288 .to_owned();
1289 } else if line.starts_with('\t') {
1290 identities.push(RawIdentity {
1291 name: std::mem::take(&mut name),
1292 email: std::mem::take(&mut email),
1293 });
1294 }
1295 }
1296 identities
1297}
1298
1299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1308pub struct AuthorMergeGroup {
1309 pub canonical_name: String,
1311 pub canonical_email: String,
1313 pub members: Vec<String>,
1315}
1316
1317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1319pub struct IdentityMap {
1320 #[serde(default)]
1321 pub groups: Vec<AuthorMergeGroup>,
1322}
1323
1324impl IdentityMap {
1325 #[must_use]
1328 pub fn load(path: &Path) -> Self {
1329 std::fs::read_to_string(path)
1330 .ok()
1331 .and_then(|s| serde_json::from_str(&s).ok())
1332 .unwrap_or_default()
1333 }
1334
1335 pub fn save(&self, path: &Path) -> Result<()> {
1340 let json = serde_json::to_string_pretty(self)?;
1341 std::fs::write(path, json)
1342 .with_context(|| format!("failed to write identity map to {}", path.display()))
1343 }
1344
1345 #[must_use]
1347 pub fn group_for(&self, email: &str) -> Option<&AuthorMergeGroup> {
1348 let key = email.trim().to_lowercase();
1349 self.groups.iter().find(|g| g.members.contains(&key))
1350 }
1351
1352 pub fn merge(&mut self, emails: &[String], name: Option<&str>) {
1357 let mut members: Vec<String> = emails
1358 .iter()
1359 .map(|e| e.trim().to_lowercase())
1360 .filter(|e| !e.is_empty())
1361 .collect();
1362 members.sort();
1363 members.dedup();
1364 if members.len() < 2 {
1365 return;
1366 }
1367 let mut absorbed: Vec<String> = Vec::new();
1369 self.groups.retain(|g| {
1370 if g.members.iter().any(|m| members.contains(m)) {
1371 absorbed.extend(g.members.iter().cloned());
1372 false
1373 } else {
1374 true
1375 }
1376 });
1377 members.extend(absorbed);
1378 members.sort();
1379 members.dedup();
1380 let canonical_email = members[0].clone();
1381 let canonical_name = name
1382 .map(str::trim)
1383 .filter(|s| !s.is_empty())
1384 .map_or_else(|| canonical_email.clone(), ToString::to_string);
1385 self.groups.push(AuthorMergeGroup {
1386 canonical_name,
1387 canonical_email,
1388 members,
1389 });
1390 }
1391
1392 pub fn unmerge(&mut self, canonical_email: &str) {
1394 let key = canonical_email.trim().to_lowercase();
1395 self.groups
1396 .retain(|g| g.canonical_email.to_lowercase() != key);
1397 }
1398
1399 #[must_use]
1402 pub fn to_mailmap(&self) -> String {
1403 let mut out = String::from(
1404 "# Generated by oxide-sloc — maps alternate author emails to a canonical identity.\n",
1405 );
1406 for g in &self.groups {
1407 for member in &g.members {
1408 if *member == g.canonical_email.to_lowercase() {
1409 continue;
1410 }
1411 out.push_str(&format!(
1412 "{} <{}> <{}>\n",
1413 g.canonical_name, g.canonical_email, member
1414 ));
1415 }
1416 }
1417 out
1418 }
1419}
1420
1421pub fn apply_identity_map(run: &mut AnalysisRun, map: &IdentityMap) {
1426 if map.groups.is_empty() || run.authors.is_empty() {
1427 return;
1428 }
1429 let resolved = resolve_merge_keys(&run.authors, map);
1430 let (merged, old_to_new) = merge_authors(&run.authors, &resolved);
1431 fold_file_ownership(&mut run.per_file_records, &old_to_new);
1432 sort_and_reindex_authors(run, merged);
1433}
1434
1435fn resolve_merge_keys(authors: &[Author], map: &IdentityMap) -> Vec<(String, String, String)> {
1439 authors
1440 .iter()
1441 .map(|a| {
1442 map.group_for(&a.canonical_email).map_or_else(
1443 || {
1444 (
1445 a.canonical_email.to_lowercase(),
1446 a.canonical_name.clone(),
1447 a.canonical_email.clone(),
1448 )
1449 },
1450 |g| {
1451 (
1452 g.canonical_email.to_lowercase(),
1453 g.canonical_name.clone(),
1454 g.canonical_email.clone(),
1455 )
1456 },
1457 )
1458 })
1459 .collect()
1460}
1461
1462fn merge_authors(
1466 authors: &[Author],
1467 resolved: &[(String, String, String)],
1468) -> (Vec<Author>, Vec<u32>) {
1469 let mut key_to_new: HashMap<String, u32> = HashMap::new();
1470 let mut merged: Vec<Author> = Vec::new();
1471 let mut old_to_new: Vec<u32> = vec![0; authors.len()];
1472 for (old_idx, (key, name, email)) in resolved.iter().enumerate() {
1473 let new_id = *key_to_new.entry(key.clone()).or_insert_with(|| {
1474 let id = merged.len() as u32;
1475 merged.push(Author {
1476 id,
1477 canonical_name: name.clone(),
1478 canonical_email: email.clone(),
1479 aliases: Vec::new(),
1480 counts: AuthorLineCounts::default(),
1481 });
1482 id
1483 });
1484 old_to_new[old_idx] = new_id;
1485 let src = &authors[old_idx];
1486 let dst = &mut merged[new_id as usize];
1487 dst.counts.add(&src.counts);
1488 for alias in &src.aliases {
1489 if !dst.aliases.contains(alias) {
1490 dst.aliases.push(alias.clone());
1491 }
1492 }
1493 }
1494 (merged, old_to_new)
1495}
1496
1497fn fold_file_ownership(records: &mut [FileRecord], old_to_new: &[u32]) {
1499 for rec in records {
1500 if let Some(ownership) = rec.ownership.as_mut() {
1501 let mut by_new: HashMap<u32, AuthorLineCounts> = HashMap::new();
1502 for entry in ownership.iter() {
1503 let new_id = old_to_new[entry.author_id as usize];
1504 by_new.entry(new_id).or_default().add(&entry.counts);
1505 }
1506 let mut folded: Vec<FileOwnership> = by_new
1507 .into_iter()
1508 .map(|(author_id, counts)| FileOwnership { author_id, counts })
1509 .collect();
1510 folded.sort_by_key(|e| std::cmp::Reverse(e.counts.total_lines));
1511 *ownership = folded;
1512 }
1513 }
1514}
1515
1516fn sort_and_reindex_authors(run: &mut AnalysisRun, merged: Vec<Author>) {
1519 let mut order: Vec<usize> = (0..merged.len()).collect();
1520 order.sort_by(|&a, &b| {
1521 merged[b]
1522 .counts
1523 .code_lines
1524 .cmp(&merged[a].counts.code_lines)
1525 .then_with(|| merged[a].canonical_name.cmp(&merged[b].canonical_name))
1526 });
1527 let mut remap = vec![0u32; merged.len()];
1528 for (new_id, &old) in order.iter().enumerate() {
1529 remap[old] = new_id as u32;
1530 }
1531 for rec in &mut run.per_file_records {
1532 if let Some(ownership) = rec.ownership.as_mut() {
1533 for entry in ownership.iter_mut() {
1534 entry.author_id = remap[entry.author_id as usize];
1535 }
1536 }
1537 }
1538 let mut sorted: Vec<Author> = order.iter().map(|&old| merged[old].clone()).collect();
1539 for (new_id, author) in sorted.iter_mut().enumerate() {
1540 author.id = new_id as u32;
1541 }
1542 run.authors = sorted;
1543}
1544
1545fn is_github_noreply(email: &str) -> bool {
1548 email
1549 .trim()
1550 .to_lowercase()
1551 .ends_with("users.noreply.github.com")
1552}
1553
1554pub fn auto_merge_noreply_identities(run: &mut AnalysisRun) {
1567 if run.authors.len() < 2 {
1568 return;
1569 }
1570 let mut real_by_name: HashMap<String, (String, String)> = HashMap::new();
1573 for a in &run.authors {
1574 if !is_github_noreply(&a.canonical_email) {
1575 real_by_name
1576 .entry(a.canonical_name.trim().to_lowercase())
1577 .or_insert_with(|| (a.canonical_email.clone(), a.canonical_name.clone()));
1578 }
1579 }
1580 if real_by_name.is_empty() {
1581 return;
1582 }
1583 let resolved: Vec<(String, String, String)> = run
1584 .authors
1585 .iter()
1586 .map(|a| {
1587 if is_github_noreply(&a.canonical_email)
1588 && let Some((email, name)) =
1589 real_by_name.get(&a.canonical_name.trim().to_lowercase())
1590 {
1591 (email.to_lowercase(), name.clone(), email.clone())
1592 } else {
1593 (
1594 a.canonical_email.to_lowercase(),
1595 a.canonical_name.clone(),
1596 a.canonical_email.clone(),
1597 )
1598 }
1599 })
1600 .collect();
1601 let (merged, old_to_new) = merge_authors(&run.authors, &resolved);
1602 if merged.len() == run.authors.len() {
1603 return; }
1605 fold_file_ownership(&mut run.per_file_records, &old_to_new);
1606 sort_and_reindex_authors(run, merged);
1607}
1608
1609fn detect_ci_system() -> Option<&'static str> {
1611 let ev = |k: &str| std::env::var(k).is_ok();
1612 let ev_true = |k: &str| std::env::var(k).as_deref() == Ok("true");
1613 if ev("JENKINS_URL") || ev("JENKINS_HOME") || ev("BUILD_URL") {
1614 return Some("Jenkins");
1615 }
1616 if ev_true("GITHUB_ACTIONS") {
1617 return Some("GitHub Actions");
1618 }
1619 if ev_true("GITLAB_CI") {
1620 return Some("GitLab CI");
1621 }
1622 if ev_true("CIRCLECI") {
1623 return Some("CircleCI");
1624 }
1625 if ev_true("TRAVIS") {
1626 return Some("Travis CI");
1627 }
1628 if ev_true("TF_BUILD") {
1629 return Some("Azure DevOps");
1630 }
1631 if ev("TEAMCITY_VERSION") {
1632 return Some("TeamCity");
1633 }
1634 None
1635}
1636
1637fn ci_branch_from_env() -> Option<String> {
1640 const VARS: &[&str] = &[
1641 "BRANCH_NAME", "GIT_BRANCH", "GITHUB_REF_NAME", "CI_COMMIT_BRANCH", "CIRCLE_BRANCH", "TRAVIS_BRANCH", "BUILD_SOURCEBRANCH", ];
1649 for &var in VARS {
1650 if let Ok(val) = std::env::var(var) {
1651 let val = val.trim();
1652 let val = val
1653 .strip_prefix("refs/heads/")
1654 .or_else(|| val.strip_prefix("origin/"))
1655 .unwrap_or(val);
1656 if !val.is_empty() && val != "HEAD" {
1657 return Some(val.to_string());
1658 }
1659 }
1660 }
1661 None
1662}
1663
1664fn get_current_username() -> String {
1665 std::env::var("USERNAME")
1666 .or_else(|_| std::env::var("USER"))
1667 .unwrap_or_else(|_| "unknown".to_string())
1668}
1669
1670fn non_empty_env(var: &str) -> Option<String> {
1671 let v = std::env::var(var).ok()?;
1672 if v.is_empty() { None } else { Some(v) }
1673}
1674
1675fn is_jenkins_env() -> bool {
1676 std::env::var("JENKINS_URL").is_ok()
1677 || std::env::var("JENKINS_HOME").is_ok()
1678 || std::env::var("BUILD_URL").is_ok()
1679}
1680
1681fn get_hostname() -> String {
1682 if is_jenkins_env()
1685 && let Some(n) = non_empty_env("NODE_NAME")
1686 {
1687 return n;
1688 }
1689 if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true")
1690 && let Some(r) = non_empty_env("RUNNER_NAME")
1691 {
1692 return r;
1693 }
1694 if std::env::var("GITLAB_CI").as_deref() == Ok("true")
1695 && let Some(r) = non_empty_env("CI_RUNNER_DESCRIPTION")
1696 {
1697 return r;
1698 }
1699 std::env::var("COMPUTERNAME")
1700 .or_else(|_| std::env::var("HOSTNAME"))
1701 .or_else(|_| std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()))
1702 .unwrap_or_else(|_| "unknown".to_string())
1703}
1704
1705#[allow(clippy::too_many_arguments)]
1707fn walk_root(
1708 root: &Path,
1709 config: &AppConfig,
1710 include_globs: Option<&GlobSet>,
1711 exclude_globs: Option<&GlobSet>,
1712 enabled_languages: Option<&BTreeSet<Language>>,
1713 seen_paths: &mut HashSet<PathBuf>,
1714 analyzed: &mut Vec<FileRecord>,
1715 skipped: &mut Vec<FileRecord>,
1716 warnings: &mut Vec<String>,
1717 cancel: Option<&AtomicBool>,
1718 progress: Option<&ProgressCounters>,
1719) -> Result<()> {
1720 let mut builder = WalkBuilder::new(root);
1721 builder
1722 .follow_links(config.discovery.follow_symlinks)
1723 .hidden(config.discovery.ignore_hidden_files)
1724 .ignore(config.discovery.honor_ignore_files)
1725 .parents(config.discovery.honor_ignore_files)
1726 .git_ignore(config.discovery.honor_ignore_files)
1727 .git_global(config.discovery.honor_ignore_files)
1728 .git_exclude(config.discovery.honor_ignore_files);
1729
1730 let paths = collect_walk_paths(&builder, seen_paths, warnings);
1731 if paths.is_empty() {
1732 return Ok(());
1733 }
1734
1735 if let Some(p) = progress {
1736 p.files_total.fetch_add(paths.len(), Ordering::Relaxed);
1737 }
1738
1739 let chunk_results = run_parallel_analysis(
1740 &paths,
1741 root,
1742 config,
1743 include_globs,
1744 exclude_globs,
1745 enabled_languages,
1746 cancel,
1747 progress,
1748 )?;
1749 merge_chunk_results(chunk_results, analyzed, skipped, warnings)
1750}
1751
1752fn collect_walk_paths(
1753 builder: &WalkBuilder,
1754 seen_paths: &mut HashSet<PathBuf>,
1755 warnings: &mut Vec<String>,
1756) -> Vec<PathBuf> {
1757 let (tx, rx) = std::sync::mpsc::channel::<std::result::Result<PathBuf, String>>();
1761
1762 builder.build_parallel().run(|| {
1763 let tx = tx.clone();
1764 Box::new(move |entry| {
1765 match entry {
1766 Err(e) => {
1767 let _ = tx.send(Err(format!("discovery warning: {e}")));
1768 }
1769 Ok(e) => {
1770 let path = e.into_path();
1771 if !path.is_dir() {
1772 let _ = tx.send(Ok(path));
1773 }
1774 }
1775 }
1776 ignore::WalkState::Continue
1777 })
1778 });
1779
1780 drop(tx);
1783
1784 rx.into_iter()
1785 .filter_map(|msg| match msg {
1786 Ok(path) => {
1787 if seen_paths.insert(path.clone()) {
1788 Some(path)
1789 } else {
1790 None
1791 }
1792 }
1793 Err(warn) => {
1794 warnings.push(warn);
1795 None
1796 }
1797 })
1798 .collect()
1799}
1800
1801#[allow(clippy::too_many_arguments)]
1803fn worker_loop(
1804 paths: &[PathBuf],
1805 root: &Path,
1806 config: &AppConfig,
1807 include_globs: Option<&GlobSet>,
1808 exclude_globs: Option<&GlobSet>,
1809 enabled_languages: Option<&BTreeSet<Language>>,
1810 cancel: Option<&AtomicBool>,
1811 next_index: &AtomicUsize,
1812 files_done: Option<&AtomicUsize>,
1813) -> Vec<Result<Option<FileRecord>>> {
1814 let mut results = Vec::new();
1815 loop {
1816 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1817 results.push(Err(anyhow::anyhow!("analysis cancelled")));
1818 break;
1819 }
1820 let i = next_index.fetch_add(1, Ordering::Relaxed);
1821 if i >= paths.len() {
1822 break;
1823 }
1824 results.push(analyze_candidate_file(
1825 &paths[i],
1826 root,
1827 config,
1828 include_globs,
1829 exclude_globs,
1830 enabled_languages,
1831 ));
1832 if let Some(fd) = files_done {
1833 fd.fetch_add(1, Ordering::Relaxed);
1834 }
1835 }
1836 results
1837}
1838
1839#[allow(clippy::too_many_arguments)]
1840fn run_parallel_analysis(
1841 paths: &[PathBuf],
1842 root: &Path,
1843 config: &AppConfig,
1844 include_globs: Option<&GlobSet>,
1845 exclude_globs: Option<&GlobSet>,
1846 enabled_languages: Option<&BTreeSet<Language>>,
1847 cancel: Option<&AtomicBool>,
1848 progress: Option<&ProgressCounters>,
1849) -> Result<Vec<Vec<Result<Option<FileRecord>>>>> {
1850 let thread_count = std::thread::available_parallelism().map_or(DEFAULT_ANALYSIS_THREADS, |n| {
1851 n.get().min(MAX_ANALYSIS_THREADS)
1852 });
1853 let next_index = AtomicUsize::new(0);
1857 let files_done: Option<&AtomicUsize> = progress.map(|p| p.files_done.as_ref());
1858
1859 std::thread::scope(|s| -> Result<Vec<Vec<Result<Option<FileRecord>>>>> {
1860 let mut handles = Vec::with_capacity(thread_count);
1863 for _ in 0..thread_count {
1864 handles.push(s.spawn(|| {
1865 worker_loop(
1866 paths,
1867 root,
1868 config,
1869 include_globs,
1870 exclude_globs,
1871 enabled_languages,
1872 cancel,
1873 &next_index,
1874 files_done,
1875 )
1876 }));
1877 }
1878 handles
1879 .into_iter()
1880 .map(|h| {
1881 h.join()
1882 .map_err(|_| anyhow::anyhow!("analysis thread panicked"))
1883 })
1884 .collect()
1885 })
1886}
1887
1888fn merge_chunk_results(
1889 chunk_results: Vec<Vec<Result<Option<FileRecord>>>>,
1890 analyzed: &mut Vec<FileRecord>,
1891 skipped: &mut Vec<FileRecord>,
1892 warnings: &mut Vec<String>,
1893) -> Result<()> {
1894 for chunk in chunk_results {
1895 for result in chunk {
1896 if let Some(record) = result? {
1897 push_record(record, analyzed, skipped, warnings);
1898 }
1899 }
1900 }
1901 Ok(())
1902}
1903
1904fn process_submodules(config: &AppConfig, analyzed: &mut [FileRecord]) -> Vec<SubmoduleSummary> {
1906 let root = config.discovery.root_paths[0]
1907 .canonicalize()
1908 .unwrap_or_else(|_| config.discovery.root_paths[0].clone());
1909 let submodules = detect_submodules(&root);
1910 if submodules.is_empty() {
1911 return Vec::new();
1912 }
1913
1914 for file in analyzed.iter_mut() {
1915 for (name, sub_path) in &submodules {
1916 let prefix = sub_path.to_string_lossy().replace('\\', "/");
1917 let rel = &file.relative_path;
1918 if rel == &prefix || rel.starts_with(&format!("{prefix}/")) {
1919 file.submodule = Some(name.clone());
1920 break;
1921 }
1922 }
1923 }
1924
1925 build_submodule_summaries(analyzed, &submodules, &root)
1926}
1927
1928#[allow(clippy::cast_precision_loss)] fn compute_cocomo(code_lines: u64, mode: CocomoMode) -> CocomoEstimate {
1931 let ksloc = code_lines as f64 / 1_000.0;
1932 let (a, b, c, d): (f64, f64, f64, f64) = match mode {
1933 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
1934 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
1935 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
1936 };
1937 let effort = a * ksloc.powf(b);
1938 let duration = c * effort.powf(d);
1939 let avg_staff = if duration > 0.0 {
1940 effort / duration
1941 } else {
1942 0.0
1943 };
1944 CocomoEstimate {
1946 mode,
1947 ksloc: (ksloc * 100.0).round() / 100.0,
1948 effort_person_months: (effort * 100.0).round() / 100.0,
1949 duration_months: (duration * 100.0).round() / 100.0,
1950 avg_staff: (avg_staff * 100.0).round() / 100.0,
1951 }
1952}
1953
1954#[allow(clippy::cast_precision_loss)] fn compute_uloc(analyzed: &[FileRecord]) -> (u64, Option<f32>) {
1957 use std::collections::HashSet as StdHashSet;
1958 let mut unique: StdHashSet<u64> = StdHashSet::new();
1959 let mut total_code: u64 = 0;
1960 for record in analyzed {
1961 total_code += record.effective_counts.code_lines;
1962 for &hash in &record.raw_line_categories.code_line_hashes {
1963 unique.insert(hash);
1964 }
1965 }
1966 let uloc = unique.len() as u64;
1967 let dryness = if total_code > 0 {
1968 Some((uloc as f32 / total_code as f32) * 100.0)
1969 } else {
1970 None
1971 };
1972 (uloc, dryness)
1973}
1974
1975fn find_duplicate_groups(analyzed: &[FileRecord]) -> Vec<Vec<String>> {
1978 let mut by_hash: std::collections::HashMap<u64, Vec<&str>> = std::collections::HashMap::new();
1979 for record in analyzed {
1980 if record.content_hash != 0 {
1981 by_hash
1982 .entry(record.content_hash)
1983 .or_default()
1984 .push(&record.relative_path);
1985 }
1986 }
1987 let mut groups: Vec<Vec<String>> = by_hash
1988 .into_values()
1989 .filter(|v| v.len() >= 2)
1990 .map(|v| {
1991 let mut paths: Vec<String> = v.into_iter().map(str::to_owned).collect();
1992 paths.sort();
1993 paths
1994 })
1995 .collect();
1996 groups.sort_by(|a, b| a[0].cmp(&b[0]));
1997 groups
1998}
1999
2000#[allow(clippy::too_many_arguments)]
2004fn assemble_run(
2005 config: &AppConfig,
2006 runtime_mode: &str,
2007 mut analyzed: Vec<FileRecord>,
2008 skipped: Vec<FileRecord>,
2009 warnings: Vec<String>,
2010 submodule_summaries: Vec<SubmoduleSummary>,
2011 progress: Option<&ProgressCounters>,
2012 cancel: Option<&AtomicBool>,
2013) -> AnalysisRun {
2014 set_progress_phase(progress, "Computing metrics");
2015 let summary = build_summary(&analyzed, &skipped);
2016 let language_summaries = build_language_summaries(&analyzed);
2017 let col_threshold = config.analysis.style_col_threshold;
2018 let style_summary = build_style_summary(&analyzed, col_threshold);
2019
2020 let (uloc, dryness_pct) = compute_uloc(&analyzed);
2022 let duplicate_groups = find_duplicate_groups(&analyzed);
2023 let cocomo = if summary.code_lines > 0 {
2024 Some(compute_cocomo(summary.code_lines, CocomoMode::Organic))
2025 } else {
2026 None
2027 };
2028
2029 let first_root = config
2030 .discovery
2031 .root_paths
2032 .first()
2033 .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()));
2034 let git = first_root
2035 .as_deref()
2036 .map(detect_git_for_run)
2037 .unwrap_or_default();
2038
2039 let activity_window = config.analysis.activity_window_days.unwrap_or(0);
2042 if let (true, Some(root)) = (activity_window > 0, first_root.as_deref()) {
2043 set_progress_phase(progress, "Reading git history");
2044 apply_file_activity(root, activity_window, &mut analyzed);
2045 }
2046
2047 let authors = if config.analysis.attribution {
2050 first_root
2051 .as_deref()
2052 .map(|root| attribute_ownership(root, &mut analyzed, progress, cancel))
2053 .unwrap_or_default()
2054 } else {
2055 Vec::new()
2056 };
2057
2058 let now = Utc::now();
2059 let run_id = {
2060 let uuid_suffix = Uuid::new_v4().simple().to_string();
2061 format!("{}-{}", now.format("%Y%m%d-%H%M"), uuid_suffix)
2062 };
2063
2064 let mut run = AnalysisRun {
2065 tool: ToolMetadata {
2066 name: "sloc".into(),
2067 version: env!("CARGO_PKG_VERSION").into(),
2068 run_id,
2069 timestamp_utc: now,
2070 },
2071 environment: EnvironmentMetadata {
2072 operating_system: std::env::consts::OS.into(),
2073 architecture: std::env::consts::ARCH.into(),
2074 runtime_mode: runtime_mode.into(),
2075 initiator_username: get_current_username(),
2076 initiator_hostname: get_hostname(),
2077 ci_name: if is_jenkins_env() {
2078 Some(format!("Jenkins\t{}", get_hostname()))
2079 } else {
2080 detect_ci_system().map(str::to_string)
2081 },
2082 },
2083 effective_configuration: config.clone(),
2084 input_roots: config
2085 .discovery
2086 .root_paths
2087 .iter()
2088 .map(|p| path_to_string(p))
2089 .collect(),
2090 summary_totals: summary,
2091 totals_by_language: language_summaries,
2092 per_file_records: analyzed,
2093 skipped_file_records: skipped,
2094 warnings,
2095 submodule_summaries,
2096 git_commit_short: git.commit_short,
2097 git_commit_long: git.commit_long,
2098 git_branch: git.branch,
2099 git_commit_author: git.author,
2100 git_tags: git.tags,
2101 git_nearest_tag: git.nearest_tag,
2102 git_commit_date: git.commit_date,
2103 git_remote_url: git.remote_url,
2104 style_summary,
2105 cocomo,
2106 uloc,
2107 dryness_pct,
2108 duplicate_groups,
2109 duplicates_excluded: 0,
2110 authors,
2111 };
2112 auto_merge_noreply_identities(&mut run);
2115 run
2116}
2117
2118fn apply_file_activity(root: &Path, window_days: u32, analyzed: &mut [FileRecord]) {
2122 let activity = detect_file_activity(root, window_days);
2123 if activity.is_empty() {
2124 return;
2125 }
2126 for rec in analyzed {
2127 if let Some((count, date)) = activity.get(&rec.relative_path) {
2128 rec.commit_count = Some(*count);
2129 rec.last_commit_date.clone_from(date);
2130 }
2131 }
2132}
2133
2134#[allow(clippy::too_many_lines)]
2139pub fn analyze(
2140 config: &AppConfig,
2141 runtime_mode: &str,
2142 cancel: Option<&AtomicBool>,
2143 progress: Option<&ProgressCounters>,
2144) -> Result<AnalysisRun> {
2145 config.validate()?;
2146
2147 if config.discovery.root_paths.is_empty() {
2148 anyhow::bail!("no input paths were provided");
2149 }
2150
2151 let include_globs = compile_globset(&config.discovery.include_globs)?;
2152 let exclude_globs = compile_globset(&config.discovery.exclude_globs)?;
2153 let enabled_languages = parse_enabled_languages(&config.analysis.enabled_languages)?;
2154
2155 let mut analyzed = Vec::new();
2156 let mut skipped = Vec::new();
2157 let mut warnings = Vec::new();
2158 let mut seen_paths = HashSet::new();
2159
2160 for root in &config.discovery.root_paths {
2161 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
2162 anyhow::bail!("analysis cancelled");
2163 }
2164
2165 let root = root.canonicalize().unwrap_or_else(|_| root.clone());
2166
2167 if root.is_file() {
2168 if let Some(record) = analyze_candidate_file(
2169 &root,
2170 root.parent().unwrap_or_else(|| Path::new(".")),
2171 config,
2172 include_globs.as_ref(),
2173 exclude_globs.as_ref(),
2174 enabled_languages.as_ref(),
2175 )? {
2176 push_record(record, &mut analyzed, &mut skipped, &mut warnings);
2177 }
2178 continue;
2179 }
2180
2181 let layout = detect_repository_layout(&root);
2182 if layout.has_multiple_repos() {
2183 warnings.push(format_multi_repo_warning(&layout));
2184 }
2185
2186 walk_root(
2187 &root,
2188 config,
2189 include_globs.as_ref(),
2190 exclude_globs.as_ref(),
2191 enabled_languages.as_ref(),
2192 &mut seen_paths,
2193 &mut analyzed,
2194 &mut skipped,
2195 &mut warnings,
2196 cancel,
2197 progress,
2198 )?;
2199 }
2200
2201 analyzed.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
2202 skipped.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
2203
2204 let submodule_summaries = if config.discovery.submodule_breakdown {
2206 set_progress_phase(progress, "Summarizing submodules");
2207 process_submodules(config, &mut analyzed)
2208 } else {
2209 Vec::new()
2210 };
2211
2212 attach_coverage(config, &mut analyzed, &mut warnings);
2213
2214 Ok(assemble_run(
2215 config,
2216 runtime_mode,
2217 analyzed,
2218 skipped,
2219 warnings,
2220 submodule_summaries,
2221 progress,
2222 cancel,
2223 ))
2224}
2225
2226fn attach_coverage(config: &AppConfig, analyzed: &mut [FileRecord], warnings: &mut Vec<String>) {
2227 let Some(cov_path) = coverage::resolve_coverage_file(config.analysis.coverage_file.as_deref())
2228 else {
2229 return;
2230 };
2231 tracing::debug!(path = %cov_path.display(), "loading coverage file");
2232 match fs::read_to_string(&cov_path) {
2233 Ok(content) => {
2234 let cov_map = coverage::parse_coverage_auto(&cov_path, &content);
2235 let mut matched: u32 = 0;
2236 let mut unmatched: u32 = 0;
2237 for record in analyzed.iter_mut() {
2238 record.coverage =
2239 coverage::lookup_coverage(&cov_map, &record.relative_path).cloned();
2240 if record.coverage.is_some() {
2241 matched += 1;
2242 } else {
2243 unmatched += 1;
2244 }
2245 }
2246 tracing::debug!(
2247 path = %cov_path.display(),
2248 coverage_entries = cov_map.len(),
2249 files_matched = matched,
2250 files_unmatched = unmatched,
2251 "coverage attached"
2252 );
2253 if unmatched > 0 && matched == 0 {
2254 tracing::warn!(
2255 path = %cov_path.display(),
2256 "coverage file loaded but no source files could be matched — check that paths in the coverage report match the scanned directory"
2257 );
2258 }
2259 }
2260 Err(e) => {
2261 tracing::warn!(path = %cov_path.display(), error = %e, "coverage file could not be read");
2262 warnings.push(format!(
2263 "coverage file '{}' could not be read: {e}",
2264 cov_path.display()
2265 ));
2266 }
2267 }
2268}
2269
2270fn push_record(
2271 record: FileRecord,
2272 analyzed: &mut Vec<FileRecord>,
2273 skipped: &mut Vec<FileRecord>,
2274 warnings: &mut Vec<String>,
2275) {
2276 warnings.extend(
2277 record
2278 .warnings
2279 .iter()
2280 .map(|warning| format!("{}: {warning}", record.relative_path)),
2281 );
2282
2283 match record.status {
2284 FileStatus::AnalyzedExact | FileStatus::AnalyzedBestEffort => analyzed.push(record),
2285 _ => skipped.push(record),
2286 }
2287}
2288
2289#[inline]
2291fn skip_with_reason(
2292 path: &Path,
2293 root: &Path,
2294 size: u64,
2295 reason: impl Into<String>,
2296) -> MetadataPolicyOutcome {
2297 MetadataPolicyOutcome::Skip(Box::new(skipped_record(
2298 path,
2299 root,
2300 size,
2301 FileStatus::SkippedByPolicy,
2302 vec![reason.into()],
2303 )))
2304}
2305
2306#[allow(clippy::too_many_arguments)]
2310fn check_metadata_policy(
2311 path: &Path,
2312 root: &Path,
2313 relative_path: &str,
2314 metadata: &fs::Metadata,
2315 config: &AppConfig,
2316 include_globs: Option<&GlobSet>,
2317 exclude_globs: Option<&GlobSet>,
2318) -> MetadataPolicyOutcome {
2319 let size = metadata.len();
2320
2321 if metadata.file_type().is_symlink() && !config.discovery.follow_symlinks {
2322 return skip_with_reason(path, root, size, "symlink skipped by policy");
2323 }
2324 if file_name_eq(path, ".gitignore") {
2325 return skip_with_reason(path, root, size, ".gitignore is always excluded");
2326 }
2327 if is_excluded_dir_path(path, &config.discovery.excluded_directories) {
2328 return skip_with_reason(path, root, size, "path matched excluded directory setting");
2329 }
2330 if size > config.discovery.max_file_size_bytes {
2331 return skip_with_reason(
2332 path,
2333 root,
2334 size,
2335 format!(
2336 "file exceeded max_file_size_bytes ({})",
2337 config.discovery.max_file_size_bytes
2338 ),
2339 );
2340 }
2341 if let Some(globs) = include_globs
2342 && !globs.is_match(Path::new(relative_path))
2343 && !globs.is_match(path)
2344 {
2345 return MetadataPolicyOutcome::Exclude;
2346 }
2347 if let Some(globs) = exclude_globs
2348 && (globs.is_match(Path::new(relative_path)) || globs.is_match(path))
2349 {
2350 return skip_with_reason(path, root, size, "path matched exclude glob");
2351 }
2352 if is_known_lockfile(path) && !config.analysis.include_lockfiles {
2353 return skip_with_reason(path, root, size, "lockfile skipped by default policy");
2354 }
2355
2356 MetadataPolicyOutcome::Continue
2357}
2358
2359struct ContentPolicyResult {
2360 vendor: bool,
2361 generated: bool,
2362 minified: bool,
2363 skip_record: Option<FileRecord>,
2364}
2365
2366fn check_content_policy(
2369 path: &Path,
2370 root: &Path,
2371 size_bytes: u64,
2372 bytes: &[u8],
2373 config: &AppConfig,
2374) -> ContentPolicyResult {
2375 let vendor = is_vendor_path(path);
2376 if vendor && config.analysis.vendor_directory_detection {
2377 return ContentPolicyResult {
2378 vendor,
2379 generated: false,
2380 minified: false,
2381 skip_record: Some(skipped_record(
2382 path,
2383 root,
2384 size_bytes,
2385 FileStatus::SkippedByPolicy,
2386 vec!["vendor file skipped by policy".into()],
2387 )),
2388 };
2389 }
2390
2391 let generated = config.analysis.generated_file_detection && looks_generated(path, bytes);
2392 if generated {
2393 return ContentPolicyResult {
2394 vendor,
2395 generated,
2396 minified: false,
2397 skip_record: Some(skipped_record(
2398 path,
2399 root,
2400 size_bytes,
2401 FileStatus::SkippedByPolicy,
2402 vec!["generated file skipped by policy".into()],
2403 )),
2404 };
2405 }
2406
2407 let minified = config.analysis.minified_file_detection && looks_minified(path, bytes);
2408 if minified {
2409 return ContentPolicyResult {
2410 vendor,
2411 generated,
2412 minified,
2413 skip_record: Some(skipped_record(
2414 path,
2415 root,
2416 size_bytes,
2417 FileStatus::SkippedByPolicy,
2418 vec!["minified file skipped by policy".into()],
2419 )),
2420 };
2421 }
2422
2423 ContentPolicyResult {
2424 vendor,
2425 generated,
2426 minified,
2427 skip_record: None,
2428 }
2429}
2430
2431fn decode_file_contents(
2433 path: &Path,
2434 root: &Path,
2435 size_bytes: u64,
2436 bytes: &[u8],
2437 config: &AppConfig,
2438) -> Result<Option<(String, String, Vec<String>)>> {
2439 if is_binary(bytes) {
2440 return match config.analysis.binary_file_behavior {
2441 BinaryFileBehavior::Skip => Ok(None),
2442 BinaryFileBehavior::Fail => {
2443 anyhow::bail!("binary file encountered: {}", path.display())
2444 }
2445 };
2446 }
2447
2448 match decode_bytes(bytes) {
2449 Ok(result) => Ok(Some(result)),
2450 Err(err) => match config.analysis.decode_failure_behavior {
2451 FailureBehavior::WarnSkip => {
2452 let _ = (path, root, size_bytes); Err(anyhow::anyhow!("__decode_warn__: {err}"))
2457 }
2458 FailureBehavior::Fail => {
2459 anyhow::bail!("decode failure for {}: {err}", path.display())
2460 }
2461 },
2462 }
2463}
2464
2465enum LanguageOutcome {
2468 Resolved(Language),
2469 Skip(Box<FileRecord>),
2470}
2471
2472fn resolve_language(
2476 path: &Path,
2477 root: &Path,
2478 size_bytes: u64,
2479 text: &str,
2480 config: &AppConfig,
2481 enabled_languages: Option<&BTreeSet<Language>>,
2482) -> LanguageOutcome {
2483 let first_line = text.lines().next();
2484 let language = detect_language(
2485 path,
2486 first_line,
2487 &config.analysis.extension_overrides,
2488 config.analysis.shebang_detection,
2489 );
2490
2491 let Some(mut language) = language else {
2492 return LanguageOutcome::Skip(Box::new(skipped_record(
2493 path,
2494 root,
2495 size_bytes,
2496 FileStatus::SkippedUnsupported,
2497 vec!["unsupported or undetected language".into()],
2498 )));
2499 };
2500
2501 if language == Language::C
2505 && path.extension().and_then(|e| e.to_str()) == Some("h")
2506 && sloc_languages::looks_like_cpp(text)
2507 {
2508 language = Language::Cpp;
2509 }
2510
2511 if let Some(enabled) = enabled_languages
2512 && !enabled.contains(&language)
2513 {
2514 return LanguageOutcome::Skip(Box::new(skipped_record(
2515 path,
2516 root,
2517 size_bytes,
2518 FileStatus::SkippedByPolicy,
2519 vec![format!(
2520 "language {} disabled by configuration",
2521 language.display_name()
2522 )],
2523 )));
2524 }
2525
2526 LanguageOutcome::Resolved(language)
2527}
2528
2529#[allow(clippy::too_many_lines)]
2530fn analyze_candidate_file(
2531 path: &Path,
2532 root: &Path,
2533 config: &AppConfig,
2534 include_globs: Option<&GlobSet>,
2535 exclude_globs: Option<&GlobSet>,
2536 enabled_languages: Option<&BTreeSet<Language>>,
2537) -> Result<Option<FileRecord>> {
2538 let metadata = match fs::symlink_metadata(path) {
2539 Ok(metadata) => metadata,
2540 Err(err) => {
2541 return Ok(Some(skipped_record(
2542 path,
2543 root,
2544 0,
2545 FileStatus::ErrorInternal,
2546 vec![format!("failed to read metadata: {err}")],
2547 )));
2548 }
2549 };
2550
2551 let relative_path = relative_path_string(path, root);
2552
2553 match check_metadata_policy(
2555 path,
2556 root,
2557 &relative_path,
2558 &metadata,
2559 config,
2560 include_globs,
2561 exclude_globs,
2562 ) {
2563 MetadataPolicyOutcome::Skip(record) => return Ok(Some(*record)),
2564 MetadataPolicyOutcome::Exclude => return Ok(None),
2565 MetadataPolicyOutcome::Continue => {}
2566 }
2567
2568 let bytes = match fs::read(path) {
2569 Ok(bytes) => bytes,
2570 Err(err) => {
2571 return Ok(Some(skipped_record(
2572 path,
2573 root,
2574 metadata.len(),
2575 FileStatus::ErrorInternal,
2576 vec![format!("failed to read file: {err}")],
2577 )));
2578 }
2579 };
2580
2581 let content_policy = check_content_policy(path, root, metadata.len(), &bytes, config);
2583 if let Some(record) = content_policy.skip_record {
2584 return Ok(Some(record));
2585 }
2586 let (vendor, generated, minified) = (
2587 content_policy.vendor,
2588 content_policy.generated,
2589 content_policy.minified,
2590 );
2591
2592 let (text, encoding, decode_warnings) =
2594 match decode_file_contents(path, root, metadata.len(), &bytes, config) {
2595 Ok(Some(result)) => result,
2596 Ok(None) => {
2597 return Ok(Some(skipped_record(
2598 path,
2599 root,
2600 metadata.len(),
2601 FileStatus::SkippedBinary,
2602 vec!["binary file skipped by default".into()],
2603 )));
2604 }
2605 Err(err) => {
2606 let msg = err.to_string();
2607 if let Some(warn_msg) = msg.strip_prefix("__decode_warn__: ") {
2608 return Ok(Some(skipped_record(
2609 path,
2610 root,
2611 metadata.len(),
2612 FileStatus::SkippedDecodeError,
2613 vec![warn_msg.to_string()],
2614 )));
2615 }
2616 return Err(err);
2617 }
2618 };
2619
2620 let language =
2621 match resolve_language(path, root, metadata.len(), &text, config, enabled_languages) {
2622 LanguageOutcome::Resolved(language) => language,
2623 LanguageOutcome::Skip(record) => return Ok(Some(*record)),
2624 };
2625
2626 let style_scope = match config.analysis.style_lang_scope.as_str() {
2627 "c_family" => StyleLangScope::CFamilyOnly,
2628 _ => StyleLangScope::All,
2629 };
2630 let ieee_opts = AnalysisOptions {
2631 blank_in_block_comment_as_comment: config.analysis.blank_in_block_comment_policy
2632 == BlankInBlockCommentPolicy::CountAsComment,
2633 collapse_continuation_lines: config.analysis.continuation_line_policy
2634 == ContinuationLinePolicy::CollapseToLogical,
2635 enable_style: config.analysis.style_analysis_enabled,
2636 style_lang_scope: style_scope,
2637 };
2638 let analysis = analyze_text(language, &text, ieee_opts);
2639 let effective_counts = compute_effective_counts(
2640 &analysis.raw,
2641 config.analysis.mixed_line_policy,
2642 config.analysis.python_docstrings_as_comments,
2643 config.analysis.count_compiler_directives,
2644 );
2645
2646 let mut warnings = decode_warnings;
2647 warnings.extend(analysis.warnings.clone());
2648
2649 let content_hash = {
2651 use std::hash::{DefaultHasher, Hash, Hasher};
2652 let mut h = DefaultHasher::new();
2653 bytes.hash(&mut h);
2654 h.finish()
2655 };
2656
2657 let cyclomatic_complexity = if analysis.raw.cyclomatic_complexity > 0 {
2659 Some(analysis.raw.cyclomatic_complexity)
2660 } else {
2661 None
2662 };
2663 let lsloc = analysis.raw.lsloc;
2664
2665 Ok(Some(FileRecord {
2666 path: path_to_string(path),
2667 relative_path,
2668 language: Some(language),
2669 size_bytes: metadata.len(),
2670 detected_encoding: Some(encoding),
2671 raw_line_categories: analysis.raw,
2672 effective_counts,
2673 status: match analysis.parse_mode {
2674 ParseMode::Lexical | ParseMode::TreeSitter => FileStatus::AnalyzedExact,
2675 ParseMode::LexicalBestEffort => FileStatus::AnalyzedBestEffort,
2676 },
2677 warnings,
2678 generated,
2679 minified,
2680 vendor,
2681 parse_mode: Some(analysis.parse_mode),
2682 submodule: None,
2683 coverage: None,
2684 style_analysis: analysis.style_analysis,
2685 cyclomatic_complexity,
2686 lsloc,
2687 commit_count: None,
2688 last_commit_date: None,
2689 ownership: None,
2690 content_hash,
2691 }))
2692}
2693
2694const fn compute_effective_counts(
2695 raw: &RawLineCounts,
2696 mixed_line_policy: MixedLinePolicy,
2697 python_docstrings_as_comments: bool,
2698 count_compiler_directives: bool,
2699) -> EffectiveCounts {
2700 let mut effective = EffectiveCounts {
2701 code_lines: raw.code_only_lines,
2702 comment_lines: raw.single_comment_only_lines + raw.multi_comment_only_lines,
2703 blank_lines: raw.blank_only_lines,
2704 mixed_lines_separate: 0,
2705 };
2706
2707 if python_docstrings_as_comments {
2708 effective.comment_lines += raw.docstring_comment_lines;
2709 } else {
2710 effective.code_lines += raw.docstring_comment_lines;
2711 }
2712
2713 let mixed_total = raw.mixed_code_single_comment_lines + raw.mixed_code_multi_comment_lines;
2714 match mixed_line_policy {
2715 MixedLinePolicy::CodeOnly => effective.code_lines += mixed_total,
2716 MixedLinePolicy::CodeAndComment => {
2717 effective.code_lines += mixed_total;
2718 effective.comment_lines += mixed_total;
2719 }
2720 MixedLinePolicy::CommentOnly => effective.comment_lines += mixed_total,
2721 MixedLinePolicy::SeparateMixedCategory => effective.mixed_lines_separate += mixed_total,
2722 }
2723
2724 if !count_compiler_directives {
2727 effective.code_lines = effective
2728 .code_lines
2729 .saturating_sub(raw.compiler_directive_lines);
2730 }
2731
2732 effective
2733}
2734
2735fn build_summary(analyzed: &[FileRecord], skipped: &[FileRecord]) -> SummaryTotals {
2736 let mut summary = SummaryTotals {
2737 files_considered: (analyzed.len() + skipped.len()) as u64,
2738 files_analyzed: analyzed.len() as u64,
2739 files_skipped: skipped.len() as u64,
2740 ..Default::default()
2741 };
2742
2743 for record in analyzed {
2744 summary.total_physical_lines += record.raw_line_categories.total_physical_lines;
2745 summary.code_lines += record.effective_counts.code_lines;
2746 summary.comment_lines += record.effective_counts.comment_lines;
2747 summary.blank_lines += record.effective_counts.blank_lines;
2748 summary.mixed_lines_separate += record.effective_counts.mixed_lines_separate;
2749 summary.functions += record.raw_line_categories.functions;
2750 summary.classes += record.raw_line_categories.classes;
2751 summary.variables += record.raw_line_categories.variables;
2752 summary.variables_member += record.raw_line_categories.variables_member;
2753 summary.variables_local += record.raw_line_categories.variables_local;
2754 summary.variables_global += record.raw_line_categories.variables_global;
2755 summary.macro_definitions += record.raw_line_categories.macro_definitions;
2756 summary.imports += record.raw_line_categories.imports;
2757 summary.test_count += record.raw_line_categories.test_count;
2758 summary.test_assertion_count += record.raw_line_categories.test_assertion_count;
2759 summary.test_suite_count += record.raw_line_categories.test_suite_count;
2760 summary.cyclomatic_complexity +=
2761 u64::from(record.raw_line_categories.cyclomatic_complexity);
2762 if let Some(lsloc) = record.raw_line_categories.lsloc {
2763 *summary.lsloc.get_or_insert(0) += u64::from(lsloc);
2764 }
2765 if let Some(cov) = &record.coverage {
2766 summary.coverage_lines_found += u64::from(cov.lines_found);
2767 summary.coverage_lines_hit += u64::from(cov.lines_hit);
2768 summary.coverage_functions_found += u64::from(cov.functions_found);
2769 summary.coverage_functions_hit += u64::from(cov.functions_hit);
2770 summary.coverage_branches_found += u64::from(cov.branches_found);
2771 summary.coverage_branches_hit += u64::from(cov.branches_hit);
2772 }
2773 }
2774
2775 summary
2776}
2777
2778const fn zeroed_summary(language: Language) -> LanguageSummary {
2780 LanguageSummary {
2781 language,
2782 files: 0,
2783 total_physical_lines: 0,
2784 code_lines: 0,
2785 comment_lines: 0,
2786 blank_lines: 0,
2787 mixed_lines_separate: 0,
2788 functions: 0,
2789 classes: 0,
2790 variables: 0,
2791 variables_member: 0,
2792 variables_local: 0,
2793 variables_global: 0,
2794 macro_definitions: 0,
2795 imports: 0,
2796 test_count: 0,
2797 test_assertion_count: 0,
2798 test_suite_count: 0,
2799 coverage_lines_found: 0,
2800 coverage_lines_hit: 0,
2801 coverage_functions_found: 0,
2802 coverage_functions_hit: 0,
2803 coverage_branches_found: 0,
2804 coverage_branches_hit: 0,
2805 cyclomatic_complexity: 0,
2806 lsloc: None,
2807 }
2808}
2809
2810fn accumulate_record_into_summary(entry: &mut LanguageSummary, record: &FileRecord) {
2812 entry.files += 1;
2813 let r = &record.raw_line_categories;
2814 entry.total_physical_lines += r.total_physical_lines;
2815 entry.code_lines += record.effective_counts.code_lines;
2816 entry.comment_lines += record.effective_counts.comment_lines;
2817 entry.blank_lines += record.effective_counts.blank_lines;
2818 entry.mixed_lines_separate += record.effective_counts.mixed_lines_separate;
2819 entry.functions += r.functions;
2820 entry.classes += r.classes;
2821 entry.variables += r.variables;
2822 entry.variables_member += r.variables_member;
2823 entry.variables_local += r.variables_local;
2824 entry.variables_global += r.variables_global;
2825 entry.macro_definitions += r.macro_definitions;
2826 entry.imports += r.imports;
2827 entry.test_count += r.test_count;
2828 entry.test_assertion_count += r.test_assertion_count;
2829 entry.test_suite_count += r.test_suite_count;
2830 entry.cyclomatic_complexity += u64::from(r.cyclomatic_complexity);
2831 if let Some(lsloc) = r.lsloc {
2832 *entry.lsloc.get_or_insert(0) += u64::from(lsloc);
2833 }
2834 if let Some(cov) = &record.coverage {
2835 entry.coverage_lines_found += u64::from(cov.lines_found);
2836 entry.coverage_lines_hit += u64::from(cov.lines_hit);
2837 entry.coverage_functions_found += u64::from(cov.functions_found);
2838 entry.coverage_functions_hit += u64::from(cov.functions_hit);
2839 entry.coverage_branches_found += u64::from(cov.branches_found);
2840 entry.coverage_branches_hit += u64::from(cov.branches_hit);
2841 }
2842}
2843
2844fn build_language_summaries(analyzed: &[FileRecord]) -> Vec<LanguageSummary> {
2845 let mut by_language: BTreeMap<Language, LanguageSummary> = BTreeMap::new();
2846 for record in analyzed {
2847 let Some(language) = record.language else {
2848 continue;
2849 };
2850 let entry = by_language
2851 .entry(language)
2852 .or_insert_with(|| zeroed_summary(language));
2853 accumulate_record_into_summary(entry, record);
2854 }
2855 by_language.into_values().collect()
2856}
2857
2858fn skipped_record(
2859 path: &Path,
2860 root: &Path,
2861 size_bytes: u64,
2862 status: FileStatus,
2863 warnings: Vec<String>,
2864) -> FileRecord {
2865 FileRecord {
2866 path: path_to_string(path),
2867 relative_path: relative_path_string(path, root),
2868 language: None,
2869 size_bytes,
2870 detected_encoding: None,
2871 raw_line_categories: RawLineCounts::default(),
2872 effective_counts: EffectiveCounts::default(),
2873 status,
2874 warnings,
2875 generated: false,
2876 minified: false,
2877 vendor: false,
2878 parse_mode: None,
2879 submodule: None,
2880 coverage: None,
2881 style_analysis: None,
2882 cyclomatic_complexity: None,
2883 lsloc: None,
2884 commit_count: None,
2885 last_commit_date: None,
2886 ownership: None,
2887 content_hash: 0,
2888 }
2889}
2890
2891fn normalize_path_str(raw: &str) -> String {
2904 if let Some(unc) = raw.strip_prefix(r"\\?\UNC\") {
2905 format!("//{}", unc.replace('\\', "/"))
2907 } else if let Some(rest) = raw.strip_prefix(r"\\?\") {
2908 rest.replace('\\', "/")
2909 } else {
2910 raw.replace('\\', "/")
2911 }
2912}
2913
2914fn relative_path_string(path: &Path, root: &Path) -> String {
2915 normalize_path_str(&path.strip_prefix(root).unwrap_or(path).to_string_lossy())
2916}
2917
2918fn path_to_string(path: &Path) -> String {
2919 normalize_path_str(&path.to_string_lossy())
2920}
2921
2922#[derive(Debug, Clone, Default)]
2930pub struct RepositoryLayout {
2931 pub root: PathBuf,
2933 pub root_is_repo: bool,
2935 pub submodule_paths: Vec<PathBuf>,
2937 pub nested_repos: Vec<PathBuf>,
2939}
2940
2941impl RepositoryLayout {
2942 #[must_use]
2948 pub const fn has_multiple_repos(&self) -> bool {
2949 if self.root_is_repo {
2950 !self.nested_repos.is_empty()
2951 } else {
2952 self.nested_repos.len() >= 2
2953 }
2954 }
2955}
2956
2957const REPO_SCAN_MAX_DEPTH: usize = 6;
2959const REPO_SCAN_MAX_DIRS: usize = 4000;
2962
2963#[must_use]
2970pub fn detect_repository_layout(root: &Path) -> RepositoryLayout {
2971 let mut layout = RepositoryLayout {
2972 root: root.to_path_buf(),
2973 root_is_repo: is_git_root(root),
2974 submodule_paths: detect_submodules(root)
2975 .into_iter()
2976 .map(|(_, path)| path)
2977 .collect(),
2978 nested_repos: Vec::new(),
2979 };
2980
2981 let submodule_dirs: HashSet<PathBuf> = layout
2983 .submodule_paths
2984 .iter()
2985 .map(|rel| root.join(rel))
2986 .collect();
2987
2988 let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
2990 let mut visited = 0usize;
2991
2992 while let Some((dir, depth)) = stack.pop() {
2993 if visited >= REPO_SCAN_MAX_DIRS {
2994 break;
2995 }
2996 let Ok(entries) = fs::read_dir(&dir) else {
2997 continue;
2998 };
2999 for entry in entries.flatten() {
3000 let child = entry.path();
3001 if !child.is_dir() || child.file_name().and_then(|n| n.to_str()) == Some(".git") {
3003 continue;
3004 }
3005 visited += 1;
3006 match classify_child(&child, &submodule_dirs, root) {
3007 ChildAction::RecordRepo(rel) => layout.nested_repos.push(rel),
3008 ChildAction::Recurse if depth + 1 < REPO_SCAN_MAX_DEPTH => {
3009 stack.push((child, depth + 1));
3010 }
3011 ChildAction::Skip | ChildAction::Recurse => {}
3012 }
3013 }
3014 }
3015
3016 layout.nested_repos.sort();
3017 layout
3018}
3019
3020enum ChildAction {
3022 Skip,
3024 RecordRepo(PathBuf),
3026 Recurse,
3028}
3029
3030fn classify_child(child: &Path, submodule_dirs: &HashSet<PathBuf>, root: &Path) -> ChildAction {
3033 if submodule_dirs.contains(child) {
3034 ChildAction::Skip
3035 } else if is_git_root(child) {
3036 ChildAction::RecordRepo(relative_path_buf(child, root))
3037 } else {
3038 ChildAction::Recurse
3039 }
3040}
3041
3042fn relative_path_buf(path: &Path, root: &Path) -> PathBuf {
3044 path.strip_prefix(root).unwrap_or(path).to_path_buf()
3045}
3046
3047fn format_multi_repo_warning(layout: &RepositoryLayout) -> String {
3049 const MAX_LISTED: usize = 5;
3050 let total = layout.nested_repos.len();
3051 let listed: Vec<String> = layout
3052 .nested_repos
3053 .iter()
3054 .take(MAX_LISTED)
3055 .map(|p| path_to_string(p))
3056 .collect();
3057 let mut joined = listed.join(", ");
3058 if total > MAX_LISTED {
3059 use std::fmt::Write as _;
3060 let _ = write!(joined, ", … and {} more", total - MAX_LISTED);
3061 }
3062 if layout.root_is_repo {
3063 format!(
3064 "This repository contains {total} nested git {} ({joined}) that are not registered \
3065 submodules. Their files are being counted as part of this project; if that is not \
3066 intended, exclude them or scan each repository separately.",
3067 if total == 1 {
3068 "repository"
3069 } else {
3070 "repositories"
3071 }
3072 )
3073 } else {
3074 format!(
3075 "The selected folder contains {total} independent git repositories ({joined}). \
3076 oxide-sloc analyzes one repository at a time — git metrics and totals are only \
3077 meaningful when the root is a single repository. Select one repository as the root \
3078 (submodules are fine).",
3079 )
3080 }
3081}
3082
3083#[must_use]
3085pub fn detect_submodules(root: &Path) -> Vec<(String, PathBuf)> {
3086 let gitmodules = root.join(".gitmodules");
3087 if !gitmodules.is_file() {
3088 return Vec::new();
3089 }
3090 let Ok(content) = fs::read_to_string(&gitmodules) else {
3091 return Vec::new();
3092 };
3093
3094 let mut result = Vec::new();
3095 let mut current_name: Option<String> = None;
3096 let mut current_path: Option<PathBuf> = None;
3097
3098 for line in content.lines() {
3099 let trimmed = line.trim();
3100 if trimmed.starts_with("[submodule \"") && trimmed.ends_with("\"]") {
3101 if let (Some(name), Some(path)) = (current_name.take(), current_path.take()) {
3102 result.push((name, path));
3103 }
3104 let name = trimmed["[submodule \"".len()..trimmed.len() - 2].to_string();
3105 current_name = Some(name);
3106 } else if let Some(rest) = trimmed.strip_prefix("path")
3107 && let Some(eq_pos) = rest.find('=')
3108 {
3109 let path_str = rest[eq_pos + 1..].trim();
3110 current_path = Some(PathBuf::from(path_str));
3111 }
3112 }
3113 if let (Some(name), Some(path)) = (current_name, current_path) {
3114 result.push((name, path));
3115 }
3116
3117 result
3118}
3119
3120fn build_submodule_summaries(
3121 analyzed: &[FileRecord],
3122 submodules: &[(String, PathBuf)],
3123 root: &Path,
3124) -> Vec<SubmoduleSummary> {
3125 let git_infos = parallel_submodule_git(submodules, root);
3130
3131 submodules
3132 .iter()
3133 .zip(git_infos)
3134 .map(|((name, path), git)| {
3135 let files: Vec<&FileRecord> = analyzed
3136 .iter()
3137 .filter(|f| f.submodule.as_deref() == Some(name.as_str()))
3138 .collect();
3139
3140 let files_analyzed = files.len() as u64;
3141 let total_physical_lines = files
3142 .iter()
3143 .map(|f| f.raw_line_categories.total_physical_lines)
3144 .sum();
3145 let code_lines = files.iter().map(|f| f.effective_counts.code_lines).sum();
3146 let comment_lines = files.iter().map(|f| f.effective_counts.comment_lines).sum();
3147 let blank_lines = files.iter().map(|f| f.effective_counts.blank_lines).sum();
3148 let language_summaries = build_language_summaries_from_slice(&files);
3149
3150 SubmoduleSummary {
3151 name: name.clone(),
3152 relative_path: path.to_string_lossy().replace('\\', "/"),
3153 files_analyzed,
3154 total_physical_lines,
3155 code_lines,
3156 comment_lines,
3157 blank_lines,
3158 language_summaries,
3159 git_commit_short: git.commit_short,
3160 git_commit_long: git.commit_long,
3161 git_branch: git.branch,
3162 git_commit_author: git.author,
3163 git_commit_date: git.commit_date,
3164 git_remote_url: git.remote_url,
3165 }
3166 })
3167 .filter(|s| s.files_analyzed > 0)
3168 .collect()
3169}
3170
3171fn parallel_submodule_git(submodules: &[(String, PathBuf)], root: &Path) -> Vec<GitInfo> {
3176 let n = submodules.len();
3177 if n == 0 {
3178 return Vec::new();
3179 }
3180 let thread_count = std::thread::available_parallelism()
3181 .map_or(DEFAULT_ANALYSIS_THREADS, |t| {
3182 t.get().min(MAX_ANALYSIS_THREADS)
3183 })
3184 .min(n);
3185 let next_index = AtomicUsize::new(0);
3186
3187 let chunks: Vec<Vec<(usize, GitInfo)>> = std::thread::scope(|s| {
3188 let mut handles = Vec::with_capacity(thread_count);
3189 for _ in 0..thread_count {
3190 handles.push(s.spawn(|| {
3191 let mut local: Vec<(usize, GitInfo)> = Vec::new();
3192 loop {
3193 let i = next_index.fetch_add(1, Ordering::Relaxed);
3194 if i >= n {
3195 break;
3196 }
3197 local.push((i, detect_git_for_run(&root.join(&submodules[i].1))));
3198 }
3199 local
3200 }));
3201 }
3202 handles
3203 .into_iter()
3204 .map(|h| h.join().unwrap_or_default())
3205 .collect()
3206 });
3207
3208 let mut out: Vec<GitInfo> = (0..n).map(|_| GitInfo::default()).collect();
3209 for chunk in chunks {
3210 for (i, info) in chunk {
3211 out[i] = info;
3212 }
3213 }
3214 out
3215}
3216
3217#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3219fn dominant_indent_label(files: &[&StyleAnalysis]) -> String {
3220 let mut votes = [0u32; 6];
3221 for f in files {
3222 let idx = match f.indent_style {
3223 IndentStyle::Tabs => 0,
3224 IndentStyle::Spaces2 => 1,
3225 IndentStyle::Spaces4 => 2,
3226 IndentStyle::Spaces8 => 3,
3227 IndentStyle::Mixed => 4,
3228 IndentStyle::Unknown => 5,
3229 };
3230 votes[idx] += 1;
3231 }
3232 let labels = ["Tabs", "2-Space", "4-Space", "8-Space", "Mixed", "\u{2014}"];
3233 labels[votes
3234 .iter()
3235 .enumerate()
3236 .max_by_key(|(_, v)| *v)
3237 .map_or(5, |(i, _)| i)]
3238 .to_string()
3239}
3240
3241#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3243fn line80_pct(files: &[&StyleAnalysis]) -> u8 {
3244 if files.is_empty() {
3245 return 0;
3246 }
3247 let compliant = files
3248 .iter()
3249 .filter(|f| f.total_lines == 0 || (f.lines_over_80 as f32 / f.total_lines as f32) <= 0.05)
3250 .count() as u32;
3251 ((compliant * 100) / files.len() as u32) as u8
3252}
3253
3254#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3257fn line_col_pct(files: &[&StyleAnalysis], threshold: u16) -> u8 {
3258 if files.is_empty() {
3259 return 0;
3260 }
3261 let compliant = files
3262 .iter()
3263 .filter(|f| {
3264 let over = if threshold <= 80 {
3265 f.lines_over_80
3266 } else if threshold <= 100 {
3267 f.lines_over_100
3268 } else {
3269 f.lines_over_120
3270 };
3271 f.total_lines == 0 || (over as f32 / f.total_lines as f32) <= 0.05
3272 })
3273 .count() as u32;
3274 ((compliant * 100) / files.len() as u32) as u8
3275}
3276
3277#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3279fn build_language_group(
3280 family: &str,
3281 files: &[&StyleAnalysis],
3282 col_threshold: u16,
3283) -> LanguageStyleGroup {
3284 let count = files.len() as u32;
3285
3286 let mut all_names: Vec<String> = Vec::new();
3288 for f in files {
3289 for g in &f.guide_scores {
3290 if !all_names.contains(&g.name) {
3291 all_names.push(g.name.clone());
3292 }
3293 }
3294 }
3295
3296 let mut guide_avg_scores: Vec<(String, u8)> = all_names
3297 .into_iter()
3298 .map(|name| {
3299 let sum: u32 = files
3300 .iter()
3301 .filter_map(|f| f.guide_scores.iter().find(|g| g.name == name))
3302 .map(|g| u32::from(g.score_pct))
3303 .sum();
3304 let avg = (sum / count) as u8;
3305 (name, avg)
3306 })
3307 .collect();
3308 guide_avg_scores.sort_by_key(|s| std::cmp::Reverse(s.1));
3309
3310 let (dominant_guide, dominant_score_pct) = guide_avg_scores
3311 .first()
3312 .map(|(n, s)| (n.clone(), *s))
3313 .unwrap_or_default();
3314
3315 let lcp = line_col_pct(files, col_threshold);
3316 LanguageStyleGroup {
3317 language_family: family.to_string(),
3318 files_count: count,
3319 dominant_guide,
3320 dominant_score_pct,
3321 common_indent_style: dominant_indent_label(files),
3322 guide_avg_scores,
3323 line80_compliant_pct: line80_pct(files),
3324 line_col_compliant_pct: lcp,
3325 }
3326}
3327
3328#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3331fn build_style_summary(analyzed: &[FileRecord], col_threshold: u16) -> Option<StyleSummary> {
3332 let all_style: Vec<&StyleAnalysis> = analyzed
3333 .iter()
3334 .filter_map(|f| f.style_analysis.as_ref())
3335 .collect();
3336
3337 if all_style.is_empty() {
3338 return None;
3339 }
3340
3341 let mut families: std::collections::BTreeMap<&str, Vec<&StyleAnalysis>> =
3343 std::collections::BTreeMap::new();
3344 for sa in &all_style {
3345 families
3346 .entry(sa.language_family.as_str())
3347 .or_default()
3348 .push(sa);
3349 }
3350
3351 let mut by_language: Vec<LanguageStyleGroup> = families
3352 .iter()
3353 .map(|(family, files)| build_language_group(family, files, col_threshold))
3354 .collect();
3355 by_language.sort_by_key(|g| std::cmp::Reverse(g.files_count));
3356
3357 let files_analyzed = all_style.len() as u32;
3358 let common_indent_style = dominant_indent_label(&all_style);
3359 let line80_compliant_pct = line80_pct(&all_style);
3360 let line_col_compliant_pct = line_col_pct(&all_style, col_threshold);
3361
3362 Some(StyleSummary {
3363 files_analyzed,
3364 common_indent_style,
3365 line80_compliant_pct,
3366 line_col_compliant_pct,
3367 col_threshold,
3368 by_language,
3369 })
3370}
3371
3372fn build_language_summaries_from_slice(files: &[&FileRecord]) -> Vec<LanguageSummary> {
3373 let mut map: BTreeMap<String, LanguageSummary> = BTreeMap::new();
3374 for file in files {
3375 let Some(lang) = file.language else { continue };
3376 let entry = map
3377 .entry(lang.display_name().to_string())
3378 .or_insert_with(|| zeroed_summary(lang));
3379 accumulate_record_into_summary(entry, file);
3380 }
3381 map.into_values().collect()
3382}
3383
3384fn file_name_eq(path: &Path, expected: &str) -> bool {
3385 path.file_name()
3386 .and_then(|name| name.to_str())
3387 .is_some_and(|name| name == expected)
3388}
3389
3390fn is_excluded_dir_path(path: &Path, excluded_dirs: &[String]) -> bool {
3391 path.components().any(|component| {
3392 component
3393 .as_os_str()
3394 .to_str()
3395 .is_some_and(|part| excluded_dirs.iter().any(|excluded| excluded == part))
3396 })
3397}
3398
3399fn is_vendor_path(path: &Path) -> bool {
3400 path.components().any(|component| {
3401 component
3402 .as_os_str()
3403 .to_str()
3404 .is_some_and(|part| matches!(part, "vendor" | "node_modules" | "packages"))
3405 })
3406}
3407
3408fn is_known_lockfile(path: &Path) -> bool {
3409 path.file_name()
3410 .and_then(|name| name.to_str())
3411 .is_some_and(|name| {
3412 matches!(
3413 name,
3414 "Cargo.lock"
3415 | "package-lock.json"
3416 | "yarn.lock"
3417 | "pnpm-lock.yaml"
3418 | "Pipfile.lock"
3419 | "poetry.lock"
3420 | "composer.lock"
3421 )
3422 })
3423}
3424
3425fn looks_generated(path: &Path, bytes: &[u8]) -> bool {
3426 let file_name = path
3427 .file_name()
3428 .and_then(|name| name.to_str())
3429 .unwrap_or_default();
3430 if file_name.contains(".generated.") || file_name.contains(".g.") {
3431 return true;
3432 }
3433
3434 let sample = String::from_utf8_lossy(&bytes[..bytes.len().min(GENERATED_SAMPLE_BYTES)])
3435 .to_ascii_lowercase();
3436 sample.contains("@generated") || sample.contains("generated by")
3437}
3438
3439fn looks_minified(path: &Path, bytes: &[u8]) -> bool {
3440 let file_name = path
3441 .file_name()
3442 .and_then(|name| name.to_str())
3443 .unwrap_or_default();
3444 if file_name.contains(".min.") {
3445 return true;
3446 }
3447
3448 let sample = String::from_utf8_lossy(&bytes[..bytes.len().min(MINIFIED_SAMPLE_BYTES)]);
3449 let longest_line = sample.lines().map(str::len).max().unwrap_or(0);
3450 let whitespace = sample.chars().filter(|c| c.is_whitespace()).count();
3451 longest_line > MINIFIED_LINE_THRESHOLD && whitespace * 100 < sample.len().max(1)
3452}
3453
3454fn is_binary(bytes: &[u8]) -> bool {
3455 if bytes.starts_with(&[0xEF, 0xBB, 0xBF])
3456 || bytes.starts_with(&[0xFF, 0xFE])
3457 || bytes.starts_with(&[0xFE, 0xFF])
3458 {
3459 return false;
3460 }
3461
3462 let sample = &bytes[..bytes.len().min(BINARY_SAMPLE_BYTES)];
3463 sample.contains(&0)
3464}
3465
3466fn decode_utf16_bom(
3469 bom_stripped: &[u8],
3470 encoding: &'static encoding_rs::Encoding,
3471 label: &str,
3472) -> (String, String, Vec<String>) {
3473 let (cow, _, had_errors) = encoding.decode(bom_stripped);
3474 let mut warnings = Vec::new();
3475 if had_errors {
3476 warnings.push(format!("{label} decode contained replacement characters"));
3477 }
3478 (cow.into_owned(), label.into(), warnings)
3479}
3480
3481fn decode_bytes(bytes: &[u8]) -> std::result::Result<(String, String, Vec<String>), String> {
3482 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
3483 let text = String::from_utf8(bytes[3..].to_vec()).map_err(|err| err.to_string())?;
3484 return Ok((text, "utf-8-bom".into(), vec![]));
3485 }
3486 if bytes.starts_with(&[0xFF, 0xFE]) {
3487 return Ok(decode_utf16_bom(&bytes[2..], UTF_16LE, "utf-16le"));
3488 }
3489 if bytes.starts_with(&[0xFE, 0xFF]) {
3490 return Ok(decode_utf16_bom(&bytes[2..], UTF_16BE, "utf-16be"));
3491 }
3492
3493 #[allow(clippy::option_if_let_else)]
3495 if let Ok(text) = String::from_utf8(bytes.to_vec()) {
3496 Ok((text, "utf-8".into(), vec![]))
3497 } else {
3498 let (cow, _, had_errors) = WINDOWS_1252.decode(bytes);
3499 let mut warnings = vec!["decoded using windows-1252 fallback".into()];
3500 if had_errors {
3501 warnings.push("fallback decode contained replacement characters".into());
3502 }
3503 Ok((cow.into_owned(), "windows-1252".into(), warnings))
3504 }
3505}
3506
3507fn compile_globset(patterns: &[String]) -> Result<Option<GlobSet>> {
3508 if patterns.is_empty() {
3509 return Ok(None);
3510 }
3511
3512 let mut builder = GlobSetBuilder::new();
3513 for pattern in patterns {
3514 builder
3515 .add(Glob::new(pattern).with_context(|| format!("invalid glob pattern: {pattern}"))?);
3516 }
3517 Ok(Some(
3518 builder.build().context("failed to compile glob filters")?,
3519 ))
3520}
3521
3522fn parse_enabled_languages(enabled: &[String]) -> Result<Option<BTreeSet<Language>>> {
3523 if enabled.is_empty() {
3524 return Ok(None);
3525 }
3526
3527 let supported = supported_languages();
3528 let mut set = BTreeSet::new();
3529 for name in enabled {
3530 let language = Language::from_name(name)
3531 .with_context(|| format!("unsupported language in config: {name}"))?;
3532 if !supported.contains(&language) {
3533 anyhow::bail!("language {name} is not supported in this build");
3534 }
3535 set.insert(language);
3536 }
3537 Ok(Some(set))
3538}
3539
3540pub fn write_json(run: &AnalysisRun, output_path: &Path) -> Result<()> {
3544 let json = serde_json::to_string_pretty(run).context("failed to serialize analysis run")?;
3545 fs::write(output_path, json)
3546 .with_context(|| format!("failed to write JSON output to {}", output_path.display()))
3547}
3548
3549pub fn read_json(path: &Path) -> Result<AnalysisRun> {
3553 let contents = fs::read_to_string(path)
3554 .with_context(|| format!("failed to read result file {}", path.display()))?;
3555 serde_json::from_str(&contents)
3556 .with_context(|| format!("failed to parse JSON result {}", path.display()))
3557}
3558
3559#[cfg(test)]
3560mod tests {
3561 use super::*;
3562
3563 #[test]
3564 fn normalize_path_str_strips_verbatim_drive_prefix() {
3565 assert_eq!(
3566 normalize_path_str(r"\\?\C:\jenkins-agent\repo\CMakeLists.txt"),
3567 "C:/jenkins-agent/repo/CMakeLists.txt"
3568 );
3569 }
3570
3571 #[test]
3572 fn normalize_path_str_strips_verbatim_unc_prefix() {
3573 assert_eq!(
3574 normalize_path_str(r"\\?\UNC\server\share\proj\main.rs"),
3575 "//server/share/proj/main.rs"
3576 );
3577 }
3578
3579 #[test]
3580 fn normalize_path_str_leaves_plain_paths_unchanged() {
3581 assert_eq!(normalize_path_str(r"src\foo\bar.rs"), "src/foo/bar.rs");
3583 assert_eq!(normalize_path_str("src/foo/bar.rs"), "src/foo/bar.rs");
3585 assert_eq!(normalize_path_str(r"C:\foo\bar.rs"), "C:/foo/bar.rs");
3587 }
3588
3589 #[test]
3590 fn effective_counts_respect_code_only_policy() {
3591 let raw = RawLineCounts {
3592 code_only_lines: 2,
3593 single_comment_only_lines: 1,
3594 mixed_code_single_comment_lines: 3,
3595 docstring_comment_lines: 2,
3596 ..RawLineCounts::default()
3597 };
3598 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, true);
3599 assert_eq!(counts.code_lines, 5);
3600 assert_eq!(counts.comment_lines, 3);
3601 }
3602
3603 #[test]
3604 fn effective_counts_can_separate_mixed() {
3605 let raw = RawLineCounts {
3606 mixed_code_single_comment_lines: 2,
3607 mixed_code_multi_comment_lines: 1,
3608 ..RawLineCounts::default()
3609 };
3610 let counts =
3611 compute_effective_counts(&raw, MixedLinePolicy::SeparateMixedCategory, true, true);
3612 assert_eq!(counts.mixed_lines_separate, 3);
3613 assert_eq!(counts.code_lines, 0);
3614 assert_eq!(counts.comment_lines, 0);
3615 }
3616
3617 #[test]
3618 fn windows_1252_fallback_decodes() {
3619 let bytes = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x96, 0x57];
3620 let (text, encoding, warnings) = decode_bytes(&bytes).unwrap();
3621 assert_eq!(encoding, "windows-1252");
3622 assert!(text.contains('\u{2013}'));
3624 assert!(!warnings.is_empty());
3625 }
3626
3627 #[test]
3630 fn is_binary_detects_null_byte() {
3631 let bytes = b"hello\x00world";
3632 assert!(is_binary(bytes));
3633 }
3634
3635 #[test]
3636 fn is_binary_clean_text_is_not_binary() {
3637 let bytes = b"fn main() { println!(\"hello\"); }";
3638 assert!(!is_binary(bytes));
3639 }
3640
3641 #[test]
3642 fn is_binary_utf8_bom_not_binary() {
3643 let bytes = b"\xef\xbb\xbffn main() {}";
3644 assert!(!is_binary(bytes));
3645 }
3646
3647 #[test]
3648 fn looks_generated_at_generated_marker() {
3649 let bytes = b"// @generated by protoc-gen-rust\nfn foo() {}";
3650 assert!(looks_generated(Path::new("foo.rs"), bytes));
3651 }
3652
3653 #[test]
3654 fn looks_generated_do_not_edit_marker() {
3655 let bytes = b"// Code generated by build.rs. DO NOT EDIT.\nuse foo;";
3657 assert!(looks_generated(Path::new("foo.rs"), bytes));
3658 let bytes2 = b"// @generated\nuse foo;";
3660 assert!(looks_generated(Path::new("foo.rs"), bytes2));
3661 }
3662
3663 #[test]
3664 fn looks_generated_normal_file_not_generated() {
3665 let bytes = b"fn main() {\n println!(\"hello\");\n}\n";
3666 assert!(!looks_generated(Path::new("main.rs"), bytes));
3667 }
3668
3669 #[test]
3670 fn looks_minified_dot_min_filename() {
3671 let bytes = b"function a(){return 1}";
3672 assert!(looks_minified(Path::new("bundle.min.js"), bytes));
3673 }
3674
3675 #[test]
3676 fn looks_minified_normal_file_not_minified() {
3677 let bytes = b"function hello() {\n return 1;\n}\n";
3678 assert!(!looks_minified(Path::new("app.js"), bytes));
3679 }
3680
3681 #[test]
3682 fn looks_minified_very_long_line() {
3683 let long_line: Vec<u8> = b"x".repeat(MINIFIED_LINE_THRESHOLD + 1);
3684 assert!(looks_minified(Path::new("app.js"), &long_line));
3685 }
3686
3687 #[test]
3688 fn is_known_lockfile_cargo_lock() {
3689 assert!(is_known_lockfile(Path::new("Cargo.lock")));
3690 }
3691
3692 #[test]
3693 fn is_known_lockfile_package_lock_json() {
3694 assert!(is_known_lockfile(Path::new("package-lock.json")));
3695 }
3696
3697 #[test]
3698 fn is_known_lockfile_yarn_lock() {
3699 assert!(is_known_lockfile(Path::new("yarn.lock")));
3700 }
3701
3702 #[test]
3703 fn is_known_lockfile_normal_file_is_not_lockfile() {
3704 assert!(!is_known_lockfile(Path::new("src/lib.rs")));
3705 }
3706
3707 #[test]
3708 fn is_vendor_path_node_modules() {
3709 assert!(is_vendor_path(Path::new("node_modules/react/index.js")));
3710 }
3711
3712 #[test]
3713 fn is_vendor_path_vendor_dir() {
3714 assert!(is_vendor_path(Path::new("vendor/anyhow/src/lib.rs")));
3715 }
3716
3717 #[test]
3718 fn is_vendor_path_normal_src_is_not_vendor() {
3719 assert!(!is_vendor_path(Path::new("src/lib.rs")));
3720 }
3721
3722 #[test]
3723 fn is_excluded_dir_path_matches_excluded() {
3724 let excluded = vec![".git".into(), "target".into()];
3725 assert!(is_excluded_dir_path(Path::new(".git/config"), &excluded));
3726 }
3727
3728 #[test]
3729 fn is_excluded_dir_path_non_excluded_is_ok() {
3730 let excluded = vec![".git".into(), "target".into()];
3731 assert!(!is_excluded_dir_path(Path::new("src/main.rs"), &excluded));
3732 }
3733
3734 #[test]
3735 fn decode_bytes_utf8_bom_stripped() {
3736 let bytes = b"\xef\xbb\xbffn main() {}";
3737 let (text, encoding, _) = decode_bytes(bytes).unwrap();
3738 assert!(
3740 encoding.contains("utf-8"),
3741 "should be utf-8 variant, got {encoding}"
3742 );
3743 assert!(text.starts_with("fn"));
3744 }
3745
3746 #[test]
3747 fn decode_bytes_plain_utf8() {
3748 let bytes = b"hello world";
3749 let (text, encoding, warnings) = decode_bytes(bytes).unwrap();
3750 assert_eq!(encoding, "utf-8");
3751 assert_eq!(text, "hello world");
3752 assert!(warnings.is_empty());
3753 }
3754
3755 #[test]
3758 fn decode_bytes_utf16le_bom() {
3759 let mut bytes = vec![0xFF, 0xFE];
3761 for ch in "hi\n".encode_utf16() {
3762 bytes.extend_from_slice(&ch.to_le_bytes());
3763 }
3764 let (text, encoding, _warnings) = decode_bytes(&bytes).unwrap();
3765 assert_eq!(encoding, "utf-16le");
3766 assert!(text.contains('h') && text.contains('i'));
3767 }
3768
3769 #[test]
3770 fn decode_bytes_utf16be_bom() {
3771 let mut bytes = vec![0xFE, 0xFF];
3773 for ch in "ok\n".encode_utf16() {
3774 bytes.extend_from_slice(&ch.to_be_bytes());
3775 }
3776 let (text, encoding, _warnings) = decode_bytes(&bytes).unwrap();
3777 assert_eq!(encoding, "utf-16be");
3778 assert!(text.contains('o') && text.contains('k'));
3779 }
3780
3781 #[test]
3782 fn is_binary_utf16le_bom_not_binary() {
3783 let bytes = &[0xFF, 0xFE, 0x68, 0x00];
3785 assert!(!is_binary(bytes));
3786 }
3787
3788 #[test]
3789 fn is_binary_utf16be_bom_not_binary() {
3790 let bytes = &[0xFE, 0xFF, 0x00, 0x68];
3791 assert!(!is_binary(bytes));
3792 }
3793
3794 #[test]
3797 fn effective_counts_code_and_comment_policy() {
3798 let raw = RawLineCounts {
3799 mixed_code_single_comment_lines: 3,
3800 mixed_code_multi_comment_lines: 2,
3801 ..RawLineCounts::default()
3802 };
3803 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeAndComment, true, true);
3804 assert_eq!(counts.code_lines, 5);
3806 assert_eq!(counts.comment_lines, 5);
3807 assert_eq!(counts.mixed_lines_separate, 0);
3808 }
3809
3810 #[test]
3811 fn effective_counts_comment_only_policy() {
3812 let raw = RawLineCounts {
3813 mixed_code_single_comment_lines: 4,
3814 mixed_code_multi_comment_lines: 1,
3815 ..RawLineCounts::default()
3816 };
3817 let counts = compute_effective_counts(&raw, MixedLinePolicy::CommentOnly, true, true);
3818 assert_eq!(counts.code_lines, 0);
3819 assert_eq!(counts.comment_lines, 5);
3820 assert_eq!(counts.mixed_lines_separate, 0);
3821 }
3822
3823 #[test]
3824 fn effective_counts_docstrings_as_code_when_flag_false() {
3825 let raw = RawLineCounts {
3826 code_only_lines: 10,
3827 docstring_comment_lines: 3,
3828 ..RawLineCounts::default()
3829 };
3830 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, false, true);
3832 assert_eq!(counts.code_lines, 13);
3833 assert_eq!(counts.comment_lines, 0);
3834 }
3835
3836 #[test]
3837 fn effective_counts_exclude_compiler_directives() {
3838 let raw = RawLineCounts {
3839 code_only_lines: 10,
3840 compiler_directive_lines: 3,
3841 ..RawLineCounts::default()
3842 };
3843 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, false);
3845 assert_eq!(counts.code_lines, 7);
3846 }
3847
3848 #[test]
3849 fn effective_counts_directives_not_subtracted_below_zero() {
3850 let raw = RawLineCounts {
3851 code_only_lines: 2,
3852 compiler_directive_lines: 5, ..RawLineCounts::default()
3854 };
3855 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, false);
3856 assert_eq!(counts.code_lines, 0); }
3858
3859 #[test]
3862 fn cocomo_organic_computes_positive_values() {
3863 let est = compute_cocomo(5_000, CocomoMode::Organic);
3864 assert!(est.ksloc > 0.0);
3865 assert!(est.effort_person_months > 0.0);
3866 assert!(est.duration_months > 0.0);
3867 assert!(est.avg_staff > 0.0);
3868 assert_eq!(est.mode, CocomoMode::Organic);
3869 }
3870
3871 #[test]
3872 fn cocomo_semi_detached_computes_positive_values() {
3873 let est = compute_cocomo(20_000, CocomoMode::SemiDetached);
3874 assert!(est.ksloc > 0.0);
3875 assert!(est.effort_person_months > 0.0);
3876 assert!(est.duration_months > 0.0);
3877 assert_eq!(est.mode, CocomoMode::SemiDetached);
3878 }
3879
3880 #[test]
3881 fn cocomo_embedded_computes_positive_values() {
3882 let est = compute_cocomo(100_000, CocomoMode::Embedded);
3883 assert!(est.effort_person_months > 0.0);
3884 assert_eq!(est.mode, CocomoMode::Embedded);
3885 }
3886
3887 #[test]
3888 fn cocomo_zero_lines_produces_zero_effort() {
3889 let est = compute_cocomo(0, CocomoMode::Organic);
3890 assert!((est.ksloc).abs() < f64::EPSILON);
3891 assert!((est.effort_person_months - 0.0).abs() < 0.01);
3893 }
3894
3895 #[test]
3898 fn parse_activity_log_counts_and_dates_per_file() {
3899 let out = "\u{0}2024-03-02T10:00:00+00:00\n\
3900 M\tsrc/a.rs\n\
3901 A\tsrc/b.rs\n\
3902 \u{0}2024-03-01T09:00:00+00:00\n\
3903 M\tsrc/a.rs\n";
3904 let map = parse_activity_log(out);
3905 assert_eq!(map["src/a.rs"].0, 2, "a.rs touched in two commits");
3906 assert_eq!(map["src/b.rs"].0, 1, "b.rs touched once");
3907 assert_eq!(
3909 map["src/a.rs"].1.as_deref(),
3910 Some("2024-03-02T10:00:00+00:00")
3911 );
3912 }
3913
3914 #[test]
3915 fn parse_activity_log_attributes_rename_to_new_path() {
3916 let out = "\u{0}2024-03-02T10:00:00+00:00\nR100\tsrc/old.rs\tsrc/new.rs\n";
3917 let map = parse_activity_log(out);
3918 assert_eq!(map["src/new.rs"].0, 1);
3919 assert!(!map.contains_key("src/old.rs"));
3920 }
3921
3922 #[test]
3923 fn parse_activity_log_empty_is_empty() {
3924 assert!(parse_activity_log("").is_empty());
3925 }
3926
3927 #[test]
3929 fn attribution_severity_buckets_by_file_count() {
3930 assert_eq!(
3931 classify_attribution_severity(0, 0),
3932 AttributionSeverity::Light
3933 );
3934 assert_eq!(
3935 classify_attribution_severity(1_999, 0),
3936 AttributionSeverity::Light
3937 );
3938 assert_eq!(
3939 classify_attribution_severity(2_000, 0),
3940 AttributionSeverity::Moderate
3941 );
3942 assert_eq!(
3943 classify_attribution_severity(9_999, 0),
3944 AttributionSeverity::Moderate
3945 );
3946 assert_eq!(
3947 classify_attribution_severity(10_000, 0),
3948 AttributionSeverity::Heavy
3949 );
3950 }
3951
3952 #[test]
3953 fn attribution_severity_promoted_by_deep_history() {
3954 assert_eq!(
3956 classify_attribution_severity(3_000, 60_000),
3957 AttributionSeverity::Heavy
3958 );
3959 assert_eq!(
3961 classify_attribution_severity(1_500, 60_000),
3962 AttributionSeverity::Moderate
3963 );
3964 assert_eq!(
3966 classify_attribution_severity(100, 60_000),
3967 AttributionSeverity::Light
3968 );
3969 assert_eq!(
3971 classify_attribution_severity(20_000, 60_000),
3972 AttributionSeverity::Heavy
3973 );
3974 }
3975
3976 #[test]
3977 fn attribution_estimate_holds_invariants() {
3978 let est = estimate_attribution_cost(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
3982 assert_eq!(
3983 est.recommend_attribution,
3984 est.severity != AttributionSeverity::Heavy
3985 );
3986 assert_eq!(est.estimated_seconds, est.blameable_files.div_ceil(50));
3987 if !est.is_git {
3988 assert_eq!(est.blameable_files, 0);
3989 assert_eq!(est.commit_count, 0);
3990 }
3991 }
3992
3993 #[test]
3996 fn parse_blame_porcelain_extracts_one_identity_per_line() {
3997 let out = "\
3998abc123 1 1 2
3999author Nima Shafie
4000author-mail <nimzshafie@gmail.com>
4001author-time 1700000000
4002summary first
4003filename src/a.rs
4004\tfirst line of code
4005abc123 2 2
4006author Nima Shafie
4007author-mail <nimzshafie@gmail.com>
4008\tsecond line
4009def456 3 3
4010author Other Dev
4011author-mail <other@example.com>
4012\tthird line
4013";
4014 let ids = parse_blame_porcelain(out);
4015 assert_eq!(ids.len(), 3, "one identity per TAB-prefixed content line");
4016 assert_eq!(ids[0].name, "Nima Shafie");
4017 assert_eq!(ids[0].email, "nimzshafie@gmail.com");
4018 assert_eq!(ids[2].name, "Other Dev");
4019 assert_eq!(ids[2].email, "other@example.com");
4020 }
4021
4022 #[test]
4023 fn parse_blame_porcelain_empty_is_empty() {
4024 assert!(parse_blame_porcelain("").is_empty());
4025 }
4026
4027 #[test]
4028 fn normalize_email_key_merges_case_and_plus_tag() {
4029 let a = RawIdentity {
4030 name: "Nima Shafie".into(),
4031 email: "Nima@Example.COM".into(),
4032 };
4033 let b = RawIdentity {
4034 name: "nshafie".into(),
4035 email: "nima+work@example.com".into(),
4036 };
4037 assert_eq!(normalize_email_key(&a), normalize_email_key(&b));
4038 }
4039
4040 #[test]
4041 fn normalize_email_key_distinct_emails_do_not_merge() {
4042 let a = RawIdentity {
4043 name: "Nima Shafie".into(),
4044 email: "nima@corp.example".into(),
4045 };
4046 let b = RawIdentity {
4047 name: "Nima Shafie".into(),
4048 email: "nima@personal.example".into(),
4049 };
4050 assert_ne!(normalize_email_key(&a), normalize_email_key(&b));
4052 }
4053
4054 #[test]
4055 fn normalize_email_key_missing_email_falls_back_to_name() {
4056 let a = RawIdentity {
4057 name: "Anon Dev".into(),
4058 email: String::new(),
4059 };
4060 let b = RawIdentity {
4061 name: "anon dev".into(),
4062 email: "not.committed.yet".into(),
4063 };
4064 assert_eq!(normalize_email_key(&a), "name:anon dev");
4065 assert_eq!(normalize_email_key(&a), normalize_email_key(&b));
4066 }
4067
4068 #[test]
4069 fn author_resolver_folds_same_email_and_orders_by_code() {
4070 let mut r = AuthorResolver::default();
4071 let id_a1 = r.resolve(&RawIdentity {
4072 name: "Nima Shafie".into(),
4073 email: "nima@example.com".into(),
4074 });
4075 let id_a2 = r.resolve(&RawIdentity {
4076 name: "nshafie".into(),
4077 email: "NIMA@example.com".into(),
4078 });
4079 let id_b = r.resolve(&RawIdentity {
4080 name: "Other".into(),
4081 email: "other@example.com".into(),
4082 });
4083 assert_eq!(id_a1, id_a2, "same email folds into one author");
4084 assert_ne!(id_a1, id_b);
4085
4086 r.authors[id_a1 as usize].counts.code_lines = 10;
4088 r.authors[id_b as usize].counts.code_lines = 50;
4089 let mut records: Vec<FileRecord> = Vec::new();
4090 let authors = r.finish(&mut records);
4091 assert_eq!(authors.len(), 2);
4092 assert_eq!(authors[0].canonical_email, "other@example.com");
4093 assert_eq!(authors[0].id, 0);
4094 assert_eq!(authors[1].aliases.len(), 2, "two spellings recorded");
4095 }
4096
4097 fn author(id: u32, name: &str, email: &str, code: u64) -> Author {
4100 Author {
4101 id,
4102 canonical_name: name.into(),
4103 canonical_email: email.into(),
4104 aliases: vec![RawIdentity {
4105 name: name.into(),
4106 email: email.into(),
4107 }],
4108 counts: AuthorLineCounts {
4109 code_lines: code,
4110 comment_lines: 0,
4111 blank_lines: 0,
4112 total_lines: code,
4113 },
4114 }
4115 }
4116
4117 fn minimal_run_with_authors(authors: Vec<Author>) -> AnalysisRun {
4119 AnalysisRun {
4120 tool: ToolMetadata {
4121 name: "sloc".into(),
4122 version: "0.0.1".into(),
4123 run_id: "merge-test".into(),
4124 timestamp_utc: Utc::now(),
4125 },
4126 environment: EnvironmentMetadata {
4127 operating_system: "test".into(),
4128 architecture: "x86_64".into(),
4129 runtime_mode: "test".into(),
4130 initiator_username: "tester".into(),
4131 initiator_hostname: "testhost".into(),
4132 ci_name: None,
4133 },
4134 effective_configuration: AppConfig::default(),
4135 input_roots: vec!["/tmp/test".into()],
4136 summary_totals: SummaryTotals::default(),
4137 totals_by_language: vec![],
4138 per_file_records: vec![FileRecord {
4139 path: "a.rs".into(),
4140 relative_path: "a.rs".into(),
4141 language: Some(Language::Rust),
4142 size_bytes: 50,
4143 detected_encoding: Some("utf-8".into()),
4144 raw_line_categories: RawLineCounts::default(),
4145 effective_counts: EffectiveCounts::default(),
4146 status: FileStatus::AnalyzedExact,
4147 warnings: vec![],
4148 generated: false,
4149 minified: false,
4150 vendor: false,
4151 parse_mode: Some(ParseMode::Lexical),
4152 submodule: None,
4153 coverage: None,
4154 style_analysis: None,
4155 cyclomatic_complexity: None,
4156 lsloc: None,
4157 commit_count: None,
4158 last_commit_date: None,
4159 ownership: None,
4160 content_hash: 0,
4161 }],
4162 skipped_file_records: vec![],
4163 warnings: vec![],
4164 submodule_summaries: vec![],
4165 git_commit_short: None,
4166 git_branch: None,
4167 git_commit_long: None,
4168 git_commit_author: None,
4169 git_tags: None,
4170 git_nearest_tag: None,
4171 git_commit_date: None,
4172 git_remote_url: None,
4173 style_summary: None,
4174 cocomo: None,
4175 uloc: 0,
4176 dryness_pct: None,
4177 duplicate_groups: vec![],
4178 duplicates_excluded: 0,
4179 authors,
4180 }
4181 }
4182
4183 #[test]
4184 fn identity_map_merge_and_unmerge() {
4185 let mut map = IdentityMap::default();
4186 map.merge(
4187 &["nima@corp.com".into(), "nima@personal.com".into()],
4188 Some("Nima Shafie"),
4189 );
4190 assert_eq!(map.groups.len(), 1);
4191 assert!(map.group_for("NIMA@CORP.COM").is_some(), "case-insensitive");
4192 map.merge(
4194 &["nima@corp.com".into(), "nima@laptop.com".into()],
4195 Some("Nima Shafie"),
4196 );
4197 assert_eq!(map.groups.len(), 1);
4198 assert_eq!(map.groups[0].members.len(), 3);
4199 let canonical = map.groups[0].canonical_email.clone();
4200 map.unmerge(&canonical);
4201 assert!(map.groups.is_empty());
4202 }
4203
4204 #[test]
4205 fn identity_map_to_mailmap_lists_aliases() {
4206 let mut map = IdentityMap::default();
4207 map.merge(&["a@x.com".into(), "b@y.com".into()], Some("Real Name"));
4208 let mm = map.to_mailmap();
4209 assert!(mm.contains("Real Name <a@x.com> <b@y.com>"));
4211 assert_eq!(mm.matches("Real Name <").count(), 1);
4212 }
4213
4214 #[test]
4215 fn apply_identity_map_folds_authors_and_ownership() {
4216 let mut run = minimal_run_with_authors(vec![
4217 author(0, "Nima Shafie", "nima@corp.com", 100),
4218 author(1, "nshafie", "nima@personal.com", 40),
4219 author(2, "Other", "other@x.com", 30),
4220 ]);
4221 run.per_file_records[0].ownership = Some(vec![
4223 FileOwnership {
4224 author_id: 0,
4225 counts: AuthorLineCounts {
4226 code_lines: 100,
4227 comment_lines: 0,
4228 blank_lines: 0,
4229 total_lines: 100,
4230 },
4231 },
4232 FileOwnership {
4233 author_id: 1,
4234 counts: AuthorLineCounts {
4235 code_lines: 40,
4236 comment_lines: 0,
4237 blank_lines: 0,
4238 total_lines: 40,
4239 },
4240 },
4241 ]);
4242
4243 let mut map = IdentityMap::default();
4244 map.merge(
4245 &["nima@corp.com".into(), "nima@personal.com".into()],
4246 Some("Nima Shafie"),
4247 );
4248 apply_identity_map(&mut run, &map);
4249
4250 assert_eq!(run.authors.len(), 2, "two identities folded into one");
4251 let nima = run
4252 .authors
4253 .iter()
4254 .find(|a| a.canonical_name == "Nima Shafie")
4255 .expect("merged author present");
4256 assert_eq!(nima.counts.code_lines, 140, "counts summed");
4257 assert_eq!(nima.aliases.len(), 2, "both aliases retained");
4258 assert_eq!(run.authors[0].canonical_name, "Nima Shafie", "sorts first");
4259 let own = run.per_file_records[0].ownership.as_ref().unwrap();
4261 let nima_own = own
4262 .iter()
4263 .find(|o| o.author_id == run.authors[0].id)
4264 .unwrap();
4265 assert_eq!(nima_own.counts.code_lines, 140);
4266 }
4267
4268 #[test]
4269 fn auto_merge_folds_github_noreply_into_real_email() {
4270 let mut run = minimal_run_with_authors(vec![
4271 author(0, "Nima Shafie", "nima@gmail.com", 200),
4272 author(
4273 1,
4274 "Nima Shafie",
4275 "69773301+NimaShafie@users.noreply.github.com",
4276 30,
4277 ),
4278 author(
4279 2,
4280 "copilot-swe-agent[bot]",
4281 "1+Copilot@users.noreply.github.com",
4282 10,
4283 ),
4284 ]);
4285 run.per_file_records[0].ownership = Some(vec![
4286 FileOwnership {
4287 author_id: 0,
4288 counts: AuthorLineCounts {
4289 code_lines: 200,
4290 comment_lines: 0,
4291 blank_lines: 0,
4292 total_lines: 200,
4293 },
4294 },
4295 FileOwnership {
4296 author_id: 1,
4297 counts: AuthorLineCounts {
4298 code_lines: 30,
4299 comment_lines: 0,
4300 blank_lines: 0,
4301 total_lines: 30,
4302 },
4303 },
4304 ]);
4305
4306 auto_merge_noreply_identities(&mut run);
4307
4308 assert_eq!(
4310 run.authors.len(),
4311 2,
4312 "noreply Nima folded into real-email Nima"
4313 );
4314 let nima = run
4315 .authors
4316 .iter()
4317 .find(|a| a.canonical_name == "Nima Shafie")
4318 .expect("merged author present");
4319 assert_eq!(
4320 nima.canonical_email, "nima@gmail.com",
4321 "real email preferred"
4322 );
4323 assert_eq!(nima.counts.code_lines, 230, "counts summed");
4324 assert!(
4325 run.authors
4326 .iter()
4327 .any(|a| a.canonical_name == "copilot-swe-agent[bot]"),
4328 "bot identity not merged into a same-named person",
4329 );
4330 let own = run.per_file_records[0].ownership.as_ref().unwrap();
4332 let nima_own = own.iter().find(|o| o.author_id == nima.id).unwrap();
4333 assert_eq!(nima_own.counts.code_lines, 230);
4334 }
4335
4336 #[test]
4339 fn parse_url_line_extracts_url() {
4340 assert_eq!(
4341 parse_url_line("url = https://example.com/repo.git"),
4342 Some("https://example.com/repo.git")
4343 );
4344 }
4345
4346 #[test]
4347 fn parse_url_line_returns_none_for_non_url_key() {
4348 assert_eq!(
4349 parse_url_line("fetch = +refs/heads/*:refs/remotes/origin/*"),
4350 None
4351 );
4352 }
4353
4354 #[test]
4355 fn parse_url_line_returns_none_for_empty_url() {
4356 assert_eq!(parse_url_line("url = "), None);
4357 }
4358
4359 #[test]
4360 fn looks_generated_generated_filename_extension() {
4361 let bytes = b"// normal code\n";
4363 assert!(looks_generated(Path::new("schema.generated.ts"), bytes));
4364 }
4365
4366 #[test]
4367 fn looks_generated_dot_g_extension() {
4368 let bytes = b"// normal code\n";
4369 assert!(looks_generated(Path::new("parser.g.cs"), bytes));
4370 }
4371
4372 #[test]
4373 fn looks_minified_whitespace_ratio_is_ok() {
4374 let normal = b"var x=1,y=2,z=3;\n";
4376 assert!(!looks_minified(Path::new("app.js"), normal));
4377 }
4378
4379 #[test]
4380 fn is_known_lockfile_pnpm() {
4381 assert!(is_known_lockfile(Path::new("pnpm-lock.yaml")));
4382 }
4383
4384 #[test]
4385 fn is_known_lockfile_pipfile() {
4386 assert!(is_known_lockfile(Path::new("Pipfile.lock")));
4387 }
4388
4389 #[test]
4390 fn is_known_lockfile_poetry() {
4391 assert!(is_known_lockfile(Path::new("poetry.lock")));
4392 }
4393
4394 #[test]
4395 fn is_known_lockfile_composer() {
4396 assert!(is_known_lockfile(Path::new("composer.lock")));
4397 }
4398
4399 #[test]
4402 fn relative_path_string_strips_root_prefix() {
4403 let path = Path::new("/tmp/project/src/lib.rs");
4404 let root = Path::new("/tmp/project");
4405 let rel = relative_path_string(path, root);
4406 assert_eq!(rel, "src/lib.rs");
4407 }
4408
4409 #[test]
4410 fn relative_path_string_falls_back_to_full_path() {
4411 let path = Path::new("/other/dir/file.rs");
4413 let root = Path::new("/tmp/project");
4414 let rel = relative_path_string(path, root);
4415 assert!(!rel.is_empty());
4417 }
4418
4419 #[test]
4422 fn find_duplicate_groups_returns_empty_for_unique_hashes() {
4423 use sloc_languages::{Language, ParseMode, RawLineCounts};
4424 let make_rec = |hash: u64, path: &str| FileRecord {
4425 path: path.into(),
4426 relative_path: path.into(),
4427 language: Some(Language::Rust),
4428 size_bytes: 10,
4429 detected_encoding: Some("utf-8".into()),
4430 raw_line_categories: RawLineCounts::default(),
4431 effective_counts: EffectiveCounts::default(),
4432 status: FileStatus::AnalyzedExact,
4433 warnings: vec![],
4434 generated: false,
4435 minified: false,
4436 vendor: false,
4437 parse_mode: Some(ParseMode::Lexical),
4438 submodule: None,
4439 coverage: None,
4440 style_analysis: None,
4441 cyclomatic_complexity: None,
4442 lsloc: None,
4443 commit_count: None,
4444 last_commit_date: None,
4445 ownership: None,
4446 content_hash: hash,
4447 };
4448 let analyzed = vec![make_rec(111, "a.rs"), make_rec(222, "b.rs")];
4449 let groups = find_duplicate_groups(&analyzed);
4450 assert!(groups.is_empty());
4451 }
4452
4453 #[test]
4454 fn find_duplicate_groups_returns_group_for_same_hash() {
4455 use sloc_languages::{Language, ParseMode, RawLineCounts};
4456 let make_rec = |hash: u64, path: &str| FileRecord {
4457 path: path.into(),
4458 relative_path: path.into(),
4459 language: Some(Language::Rust),
4460 size_bytes: 10,
4461 detected_encoding: Some("utf-8".into()),
4462 raw_line_categories: RawLineCounts::default(),
4463 effective_counts: EffectiveCounts::default(),
4464 status: FileStatus::AnalyzedExact,
4465 warnings: vec![],
4466 generated: false,
4467 minified: false,
4468 vendor: false,
4469 parse_mode: Some(ParseMode::Lexical),
4470 submodule: None,
4471 coverage: None,
4472 style_analysis: None,
4473 cyclomatic_complexity: None,
4474 lsloc: None,
4475 commit_count: None,
4476 last_commit_date: None,
4477 ownership: None,
4478 content_hash: hash,
4479 };
4480 let analyzed = vec![
4481 make_rec(999, "a.rs"),
4482 make_rec(999, "b.rs"),
4483 make_rec(123, "c.rs"),
4484 ];
4485 let groups = find_duplicate_groups(&analyzed);
4486 assert_eq!(groups.len(), 1);
4487 assert_eq!(groups[0].len(), 2);
4488 }
4489
4490 #[test]
4491 fn find_duplicate_groups_ignores_zero_hash() {
4492 use sloc_languages::{Language, ParseMode, RawLineCounts};
4493 let make_rec = |hash: u64, path: &str| FileRecord {
4494 path: path.into(),
4495 relative_path: path.into(),
4496 language: Some(Language::Rust),
4497 size_bytes: 10,
4498 detected_encoding: Some("utf-8".into()),
4499 raw_line_categories: RawLineCounts::default(),
4500 effective_counts: EffectiveCounts::default(),
4501 status: FileStatus::AnalyzedExact,
4502 warnings: vec![],
4503 generated: false,
4504 minified: false,
4505 vendor: false,
4506 parse_mode: Some(ParseMode::Lexical),
4507 submodule: None,
4508 coverage: None,
4509 style_analysis: None,
4510 cyclomatic_complexity: None,
4511 lsloc: None,
4512 commit_count: None,
4513 last_commit_date: None,
4514 ownership: None,
4515 content_hash: hash,
4516 };
4517 let analyzed = vec![make_rec(0, "a.rs"), make_rec(0, "b.rs")];
4519 let groups = find_duplicate_groups(&analyzed);
4520 assert!(
4521 groups.is_empty(),
4522 "zero-hash files must not be grouped as duplicates"
4523 );
4524 }
4525
4526 #[test]
4529 fn detect_submodules_no_gitmodules_returns_empty() {
4530 let dir = tempfile::tempdir().unwrap();
4531 let result = detect_submodules(dir.path());
4532 assert!(result.is_empty());
4533 }
4534
4535 #[test]
4536 fn detect_submodules_parses_gitmodules_file() {
4537 let dir = tempfile::tempdir().unwrap();
4538 let content = "[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = https://github.com/example/lib.git\n";
4539 std::fs::write(dir.path().join(".gitmodules"), content).unwrap();
4540 let result = detect_submodules(dir.path());
4541 assert_eq!(result.len(), 1);
4542 assert_eq!(result[0].0, "vendor/lib");
4543 }
4544
4545 #[test]
4548 fn write_json_read_json_roundtrip() {
4549 use chrono::Utc;
4550 use sloc_config::AppConfig;
4551 use sloc_languages::{Language, ParseMode, RawLineCounts};
4552 let dir = tempfile::tempdir().unwrap();
4553 let run = AnalysisRun {
4554 tool: ToolMetadata {
4555 name: "sloc".into(),
4556 version: "0.0.1".into(),
4557 run_id: "test-roundtrip".into(),
4558 timestamp_utc: Utc::now(),
4559 },
4560 environment: EnvironmentMetadata {
4561 operating_system: "test".into(),
4562 architecture: "x86_64".into(),
4563 runtime_mode: "test".into(),
4564 initiator_username: "tester".into(),
4565 initiator_hostname: "testhost".into(),
4566 ci_name: None,
4567 },
4568 effective_configuration: AppConfig::default(),
4569 input_roots: vec!["/tmp/test".into()],
4570 summary_totals: SummaryTotals {
4571 files_analyzed: 1,
4572 code_lines: 5,
4573 ..SummaryTotals::default()
4574 },
4575 totals_by_language: vec![],
4576 per_file_records: vec![FileRecord {
4577 path: "a.rs".into(),
4578 relative_path: "a.rs".into(),
4579 language: Some(Language::Rust),
4580 size_bytes: 50,
4581 detected_encoding: Some("utf-8".into()),
4582 raw_line_categories: RawLineCounts {
4583 code_only_lines: 5,
4584 ..RawLineCounts::default()
4585 },
4586 effective_counts: EffectiveCounts {
4587 code_lines: 5,
4588 ..EffectiveCounts::default()
4589 },
4590 status: FileStatus::AnalyzedExact,
4591 warnings: vec![],
4592 generated: false,
4593 minified: false,
4594 vendor: false,
4595 parse_mode: Some(ParseMode::Lexical),
4596 submodule: None,
4597 coverage: None,
4598 style_analysis: None,
4599 cyclomatic_complexity: None,
4600 lsloc: None,
4601 commit_count: None,
4602 last_commit_date: None,
4603 ownership: None,
4604 content_hash: 0,
4605 }],
4606 skipped_file_records: vec![],
4607 warnings: vec![],
4608 submodule_summaries: vec![],
4609 git_commit_short: Some("abc1234".into()),
4610 git_branch: Some("main".into()),
4611 git_commit_long: None,
4612 git_commit_author: None,
4613 git_tags: None,
4614 git_nearest_tag: None,
4615 git_commit_date: None,
4616 git_remote_url: None,
4617 style_summary: None,
4618 cocomo: None,
4619 uloc: 0,
4620 dryness_pct: None,
4621 duplicate_groups: vec![],
4622 duplicates_excluded: 0,
4623 authors: Vec::new(),
4624 };
4625 let json_path = dir.path().join("test.json");
4626 write_json(&run, &json_path).unwrap();
4627 let loaded = read_json(&json_path).unwrap();
4628 assert_eq!(loaded.summary_totals.files_analyzed, 1);
4629 assert_eq!(loaded.summary_totals.code_lines, 5);
4630 assert_eq!(loaded.git_commit_short.as_deref(), Some("abc1234"));
4631 assert_eq!(loaded.git_branch.as_deref(), Some("main"));
4632 assert_eq!(loaded.per_file_records.len(), 1);
4633 }
4634
4635 #[test]
4638 fn detect_ci_system_returns_none_without_env_vars() {
4639 for var in &[
4641 "JENKINS_URL",
4642 "JENKINS_HOME",
4643 "BUILD_URL",
4644 "GITHUB_ACTIONS",
4645 "GITLAB_CI",
4646 "CIRCLECI",
4647 "TRAVIS",
4648 "TF_BUILD",
4649 "TEAMCITY_VERSION",
4650 ] {
4651 unsafe { std::env::remove_var(var) };
4653 }
4654 let _ = detect_ci_system();
4656 }
4657
4658 #[test]
4661 fn resolve_git_file_pointer_valid_absolute_gitdir() {
4662 let dir = tempfile::tempdir().unwrap();
4663 let real_git = dir.path().join("real.git");
4665 fs::create_dir_all(&real_git).unwrap();
4666 let git_file = dir.path().join(".git");
4668 fs::write(&git_file, format!("gitdir: {}\n", real_git.display())).unwrap();
4669
4670 let result = resolve_git_file_pointer(&git_file, dir.path());
4671 assert!(
4673 result.is_some(),
4674 "should resolve a valid absolute gitdir pointer"
4675 );
4676 assert!(result.unwrap().is_dir());
4677 }
4678
4679 #[test]
4680 fn resolve_git_file_pointer_missing_gitdir_prefix_returns_none() {
4681 let dir = tempfile::tempdir().unwrap();
4682 let git_file = dir.path().join(".git");
4683 fs::write(&git_file, "not a gitdir line\n").unwrap();
4684 assert!(resolve_git_file_pointer(&git_file, dir.path()).is_none());
4685 }
4686
4687 #[test]
4688 fn resolve_git_file_pointer_unreadable_path_returns_none() {
4689 assert!(
4690 resolve_git_file_pointer(
4691 Path::new("/nonexistent/__sloc_test_git_file__"),
4692 Path::new("/nonexistent")
4693 )
4694 .is_none()
4695 );
4696 }
4697
4698 #[test]
4699 fn resolve_git_file_pointer_nonexistent_target_returns_none() {
4700 let dir = tempfile::tempdir().unwrap();
4701 let git_file = dir.path().join(".git");
4702 fs::write(&git_file, "gitdir: /nonexistent/__sloc_fake_gitdir_xyz__\n").unwrap();
4703 assert!(resolve_git_file_pointer(&git_file, dir.path()).is_none());
4705 }
4706
4707 #[test]
4708 fn resolve_git_file_pointer_relative_path() {
4709 let dir = tempfile::tempdir().unwrap();
4710 let real_git = dir.path().join("real_git_dir");
4711 fs::create_dir_all(&real_git).unwrap();
4712 let git_file = dir.path().join(".git");
4713 fs::write(&git_file, "gitdir: real_git_dir\n").unwrap();
4715 let result = resolve_git_file_pointer(&git_file, dir.path());
4716 assert!(result.is_some());
4717 }
4718
4719 #[test]
4722 fn resolve_ref_from_loose_file() {
4723 let dir = tempfile::tempdir().unwrap();
4724 let git_dir = dir.path();
4725 fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4726 let sha = "abc1234567890abcdef1234567890abcdef123456";
4727 fs::write(git_dir.join("refs/heads/main"), format!("{sha}\n")).unwrap();
4728
4729 let result = resolve_ref(git_dir, "refs/heads/main");
4730 assert_eq!(result.as_deref(), Some(sha));
4731 }
4732
4733 #[test]
4734 fn resolve_ref_from_packed_refs() {
4735 let dir = tempfile::tempdir().unwrap();
4736 let git_dir = dir.path();
4737 let sha = "def5678def5678def5678def5678def5678def56";
4738 fs::write(
4739 git_dir.join("packed-refs"),
4740 format!("# pack-refs with: peeled fully-peeled sorted\n{sha} refs/heads/feature\n"),
4741 )
4742 .unwrap();
4743
4744 let result = resolve_ref(git_dir, "refs/heads/feature");
4745 assert_eq!(result.as_deref(), Some(sha));
4746 }
4747
4748 #[test]
4749 fn resolve_ref_not_found_returns_none() {
4750 let dir = tempfile::tempdir().unwrap();
4751 let result = resolve_ref(dir.path(), "refs/heads/nonexistent-branch-xyz");
4752 assert!(result.is_none());
4753 }
4754
4755 #[test]
4756 fn resolve_ref_packed_refs_skips_comment_and_peeled() {
4757 let dir = tempfile::tempdir().unwrap();
4758 let git_dir = dir.path();
4759 let sha = "aaa1111aaa1111aaa1111aaa1111aaa1111aaa11";
4760 fs::write(
4761 git_dir.join("packed-refs"),
4762 format!("# comment\n^peeled-object-sha\n{sha} refs/tags/v1.0\n"),
4763 )
4764 .unwrap();
4765
4766 let result = resolve_ref(git_dir, "refs/tags/v1.0");
4767 assert_eq!(result.as_deref(), Some(sha));
4768 }
4769
4770 #[test]
4771 fn resolve_ref_loose_sha_too_short_falls_through_to_packed() {
4772 let dir = tempfile::tempdir().unwrap();
4773 let git_dir = dir.path();
4774 fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4775 fs::write(git_dir.join("refs/heads/main"), "short\n").unwrap();
4777 let result = resolve_ref(git_dir, "refs/heads/main");
4779 assert!(result.is_none());
4780 }
4781
4782 #[test]
4785 fn read_git_remote_url_parses_origin_url() {
4786 let dir = tempfile::tempdir().unwrap();
4787 let git_dir = dir.path().join(".git");
4788 fs::create_dir_all(&git_dir).unwrap();
4789 fs::write(
4790 git_dir.join("config"),
4791 "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://github.com/org/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
4792 )
4793 .unwrap();
4794 let url = read_git_remote_url(&git_dir);
4795 assert_eq!(url.as_deref(), Some("https://github.com/org/repo.git"));
4796 }
4797
4798 #[test]
4799 fn read_git_remote_url_no_config_returns_none() {
4800 let dir = tempfile::tempdir().unwrap();
4801 let git_dir = dir.path().join(".git");
4802 fs::create_dir_all(&git_dir).unwrap();
4803 let url = read_git_remote_url(&git_dir);
4805 assert!(url.is_none());
4806 }
4807
4808 #[test]
4811 fn detect_git_for_run_no_git_dir_returns_default() {
4812 let dir = tempfile::tempdir().unwrap();
4813 let info = detect_git_for_run(dir.path());
4815 assert!(info.commit_long.is_none());
4816 }
4817
4818 #[test]
4819 fn detect_git_for_run_unreadable_head_returns_default() {
4820 let dir = tempfile::tempdir().unwrap();
4821 let git_dir = dir.path().join(".git");
4822 fs::create_dir_all(&git_dir).unwrap();
4823 let info = detect_git_for_run(dir.path());
4825 assert!(info.commit_long.is_none());
4826 }
4827
4828 #[test]
4829 fn detect_git_for_run_detached_head_with_sha() {
4830 let dir = tempfile::tempdir().unwrap();
4831 let git_dir = dir.path().join(".git");
4832 fs::create_dir_all(&git_dir).unwrap();
4833 let sha = "abc1234567890abcdef1234567890abcdef12345";
4835 fs::write(git_dir.join("HEAD"), sha).unwrap();
4836 let info = detect_git_for_run(dir.path());
4837 assert_eq!(info.commit_long.as_deref(), Some(sha));
4839 assert_eq!(info.commit_short.as_deref(), Some("abc1234"));
4840 }
4841
4842 #[test]
4843 fn detect_git_for_run_with_packed_ref() {
4844 let dir = tempfile::tempdir().unwrap();
4845 let git_dir = dir.path().join(".git");
4846 fs::create_dir_all(&git_dir).unwrap();
4847 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
4849 let sha = "deadbeef00000000000000000000000000000000";
4850 fs::write(
4851 git_dir.join("packed-refs"),
4852 format!("# pack-refs\n{sha} refs/heads/main\n"),
4853 )
4854 .unwrap();
4855 let info = detect_git_for_run(dir.path());
4856 assert_eq!(info.commit_long.as_deref(), Some(sha));
4857 assert_eq!(info.branch.as_deref(), Some("main"));
4858 }
4859
4860 #[test]
4861 fn detect_git_for_run_reads_origin_remote_url() {
4862 let dir = tempfile::tempdir().unwrap();
4865 let git_dir = dir.path().join(".git");
4866 fs::create_dir_all(&git_dir).unwrap();
4867 let sha = "deadbeef00000000000000000000000000000000";
4868 fs::write(git_dir.join("HEAD"), sha).unwrap();
4869 fs::write(
4870 git_dir.join("config"),
4871 "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = https://example.com/repo.git\n\tfetch = +refs/heads/*\n",
4872 )
4873 .unwrap();
4874 let info = detect_git_for_run(dir.path());
4875 assert_eq!(
4876 info.remote_url.as_deref(),
4877 Some("https://example.com/repo.git")
4878 );
4879 }
4880
4881 #[test]
4882 fn detect_git_for_run_follows_git_file_worktree_pointer() {
4883 let tmp = tempfile::tempdir().unwrap();
4887 let gitdata = tmp.path().join("gitdata");
4888 fs::create_dir_all(&gitdata).unwrap();
4889 let sha = "abc1234567890abcdef1234567890abcdef12345";
4890 fs::write(gitdata.join("HEAD"), sha).unwrap();
4891
4892 let project = tmp.path().join("project");
4893 fs::create_dir_all(&project).unwrap();
4894 let pointer = format!("gitdir: {}\n", gitdata.to_string_lossy().replace('\\', "/"));
4896 fs::write(project.join(".git"), pointer).unwrap();
4897
4898 let info = detect_git_for_run(&project);
4899 assert_eq!(info.commit_long.as_deref(), Some(sha));
4900 }
4901
4902 use std::sync::{Mutex, OnceLock};
4906 static CI_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4907 fn ci_env_lock() -> std::sync::MutexGuard<'static, ()> {
4908 CI_ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
4909 }
4910
4911 fn clear_branch_env_vars() {
4912 for v in &[
4913 "BRANCH_NAME",
4914 "GIT_BRANCH",
4915 "GITHUB_REF_NAME",
4916 "CI_COMMIT_BRANCH",
4917 "CIRCLE_BRANCH",
4918 "TRAVIS_BRANCH",
4919 "BUILD_SOURCEBRANCH",
4920 ] {
4921 unsafe { std::env::remove_var(v) };
4923 }
4924 }
4925
4926 #[test]
4927 fn ci_branch_from_env_strips_refs_heads_prefix() {
4928 let _lock = ci_env_lock();
4929 clear_branch_env_vars();
4930 unsafe { std::env::set_var("BUILD_SOURCEBRANCH", "refs/heads/my-branch") };
4933 let branch = ci_branch_from_env();
4934 clear_branch_env_vars();
4935 assert_eq!(branch.as_deref(), Some("my-branch"));
4936 }
4937
4938 #[test]
4939 fn ci_branch_from_env_strips_origin_prefix() {
4940 let _lock = ci_env_lock();
4941 clear_branch_env_vars();
4942 unsafe { std::env::set_var("GIT_BRANCH", "origin/develop") };
4944 let branch = ci_branch_from_env();
4945 clear_branch_env_vars();
4946 assert_eq!(branch.as_deref(), Some("develop"));
4947 }
4948
4949 #[test]
4950 fn ci_branch_from_env_returns_none_for_head() {
4951 let _lock = ci_env_lock();
4952 clear_branch_env_vars();
4953 unsafe { std::env::set_var("BRANCH_NAME", "HEAD") };
4956 let branch = ci_branch_from_env();
4957 clear_branch_env_vars();
4958 assert!(branch.is_none(), "HEAD should be filtered, got: {branch:?}");
4960 }
4961
4962 fn make_git_dir(dir: &Path) {
4966 fs::create_dir_all(dir.join(".git")).unwrap();
4967 }
4968
4969 #[test]
4970 fn multi_repo_dir_warns() {
4971 let tmp = tempfile::tempdir().unwrap();
4972 let root = tmp.path();
4973 for name in ["repo-a", "repo-b", "repo-c"] {
4974 make_git_dir(&root.join(name));
4975 }
4976 let layout = detect_repository_layout(root);
4977 assert!(!layout.root_is_repo);
4978 assert_eq!(layout.nested_repos.len(), 3);
4979 assert!(layout.has_multiple_repos());
4980 }
4981
4982 #[test]
4983 fn repo_with_submodules_does_not_warn() {
4984 let tmp = tempfile::tempdir().unwrap();
4985 let root = tmp.path();
4986 make_git_dir(root);
4987 fs::write(
4988 root.join(".gitmodules"),
4989 "[submodule \"vendor/json\"]\n\tpath = vendor/json\n\turl = https://example/json.git\n\
4990 [submodule \"vendor/gtest\"]\n\tpath = vendor/gtest\n\turl = https://example/gtest.git\n",
4991 )
4992 .unwrap();
4993 make_git_dir(&root.join("vendor/json"));
4996 make_git_dir(&root.join("vendor/gtest"));
4997 let layout = detect_repository_layout(root);
4998 assert!(layout.root_is_repo);
4999 assert!(layout.nested_repos.is_empty());
5000 assert!(!layout.has_multiple_repos());
5001 }
5002
5003 #[test]
5004 fn format_multi_repo_warning_root_repo_singular_and_truncated() {
5005 let one = RepositoryLayout {
5008 root: PathBuf::from("/proj"),
5009 root_is_repo: true,
5010 submodule_paths: vec![],
5011 nested_repos: vec![PathBuf::from("vendor/foreign")],
5012 };
5013 let msg = format_multi_repo_warning(&one);
5014 assert!(
5015 msg.contains("1 nested git repository"),
5016 "singular wording: {msg}"
5017 );
5018 assert!(!msg.contains("repositories"), "must not pluralise: {msg}");
5019
5020 let many = RepositoryLayout {
5023 root: PathBuf::from("/proj"),
5024 root_is_repo: true,
5025 submodule_paths: vec![],
5026 nested_repos: (0..7)
5027 .map(|i| PathBuf::from(format!("nested-{i}")))
5028 .collect(),
5029 };
5030 let msg = format_multi_repo_warning(&many);
5031 assert!(
5032 msg.contains("7 nested git repositories"),
5033 "plural wording: {msg}"
5034 );
5035 assert!(
5036 msg.contains("and 2 more"),
5037 "must truncate the listed set: {msg}"
5038 );
5039 }
5040
5041 #[test]
5042 fn repo_with_vendored_foreign_repo_warns() {
5043 let tmp = tempfile::tempdir().unwrap();
5044 let root = tmp.path();
5045 make_git_dir(root); make_git_dir(&root.join("vendor/foreign")); let layout = detect_repository_layout(root);
5048 assert!(layout.root_is_repo);
5049 assert_eq!(layout.nested_repos, vec![PathBuf::from("vendor/foreign")]);
5050 assert!(layout.has_multiple_repos());
5051 }
5052
5053 #[test]
5054 fn single_plain_dir_no_warn() {
5055 let tmp = tempfile::tempdir().unwrap();
5056 let root = tmp.path();
5057 fs::create_dir_all(root.join("src")).unwrap();
5058 fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
5059 let layout = detect_repository_layout(root);
5060 assert!(!layout.root_is_repo);
5061 assert!(layout.nested_repos.is_empty());
5062 assert!(!layout.has_multiple_repos());
5063 }
5064
5065 #[test]
5066 fn analyze_surfaces_multi_repo_warning() {
5067 let tmp = tempfile::tempdir().unwrap();
5068 let root = tmp.path();
5069 for name in ["repo-a", "repo-b"] {
5070 let repo = root.join(name);
5071 make_git_dir(&repo);
5072 fs::write(repo.join("main.rs"), "fn main() {}\n").unwrap();
5073 }
5074 let mut config = AppConfig::default();
5075 config.discovery.root_paths = vec![root.to_path_buf()];
5076 let run = analyze(&config, "analyze", None, None).unwrap();
5077 assert!(
5078 run.warnings
5079 .iter()
5080 .any(|w| w.contains("independent git repositories")),
5081 "expected multi-repo warning, got: {:?}",
5082 run.warnings
5083 );
5084 }
5085}