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