Skip to main content

sloc_core/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3#![allow(clippy::multiple_crate_versions)]
4
5pub mod baseline;
6pub mod coverage;
7pub mod delta;
8pub mod history;
9pub mod maintenance;
10pub use baseline::{BaselineEntry, BaselineStore, check_against_baseline, resolve_baselines_path};
11pub use coverage::{FileCoverage, aggregate_line_coverage, lookup_coverage, parse_lcov};
12pub use delta::{
13    FileChangeStatus, FileDelta, MultiFileDelta, MultiScanComparison, MultiScanPoint,
14    ScanComparison, SummaryDelta, compute_delta, compute_multi_delta,
15};
16pub use history::{
17    CleanupPolicy, CleanupPolicyStore, RegistryEntry, ScanRegistry, ScanSummarySnapshot,
18    WatchedDirsStore,
19};
20pub use maintenance::{
21    PrunePlan, PruneReport, PrunedRun, copy_tree, dir_size_bytes, execute_run_prune,
22    plan_run_prune, resolve_output_root, resolve_registry_path, rotate_log, rotated_log_paths,
23    run_output_dir,
24};
25
26use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
31
32use anyhow::{Context, Result};
33use chrono::{DateTime, Utc};
34use encoding_rs::{UTF_16BE, UTF_16LE, WINDOWS_1252};
35use globset::{Glob, GlobSet, GlobSetBuilder};
36use ignore::WalkBuilder;
37use serde::{Deserialize, Serialize};
38use uuid::Uuid;
39
40use sloc_config::{
41    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
42    FailureBehavior, MixedLinePolicy,
43};
44use sloc_languages::style::IndentStyle;
45use sloc_languages::{
46    AnalysisOptions, Language, LineCategory, ParseMode, RawLineCounts, StyleAnalysis,
47    StyleLangScope, analyze_text, classify_physical_lines, detect_language, supported_languages,
48};
49
50// ── Detection sample sizes and thresholds ────────────────────────────────────
51
52/// Maximum number of worker threads used for parallel file analysis.
53const MAX_ANALYSIS_THREADS: usize = 16;
54/// Fallback thread count when `available_parallelism` is unavailable.
55const DEFAULT_ANALYSIS_THREADS: usize = 4;
56/// Byte sample used to detect `@generated` markers.
57const GENERATED_SAMPLE_BYTES: usize = 1024;
58/// Byte sample used to detect minified files via line-length heuristic.
59const MINIFIED_SAMPLE_BYTES: usize = 4096;
60/// Longest line length above which a file is considered minified.
61const MINIFIED_LINE_THRESHOLD: usize = 2000;
62/// Byte sample used to detect binary files via null-byte scan.
63const BINARY_SAMPLE_BYTES: usize = 8192;
64
65/// Atomics shared between `analyze()` and the caller so the caller can poll scan progress.
66pub struct ProgressCounters {
67    /// Number of candidate files processed so far (incremented per file, across all threads).
68    pub files_done: Arc<AtomicUsize>,
69    /// Total candidate files discovered (set before parallel analysis begins).
70    pub files_total: Arc<AtomicUsize>,
71    /// Current high-level phase label, shared with the web poll endpoint so the UI can show which
72    /// stage the scan is in. Set by `analyze()` when it enters the long per-file `git blame`
73    /// attribution pass — otherwise the caller looks frozen after file counting finishes. Optional
74    /// so non-web callers can leave it out.
75    pub phase: Option<Arc<std::sync::Mutex<String>>>,
76    /// Files blamed so far during the authorship-attribution pass. Tracked separately from
77    /// `files_done` (the discovery/analysis pass) because the two passes overlap the same files but
78    /// run at very different speeds.
79    pub attrib_done: Arc<AtomicUsize>,
80    /// Total files to blame during attribution — 0 until the pass starts, and it stays 0 when
81    /// attribution is disabled or the scanned path is not a git repository.
82    pub attrib_total: Arc<AtomicUsize>,
83}
84
85/// Three-way outcome for metadata-level policy checks.
86enum MetadataPolicyOutcome {
87    /// Skip this file — include the record in output.
88    Skip(Box<FileRecord>),
89    /// Exclude this file entirely — no record in output (include-glob miss).
90    Exclude,
91    /// Continue to content checks.
92    Continue,
93}
94
95#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum FileStatus {
98    AnalyzedExact,
99    AnalyzedBestEffort,
100    SkippedBinary,
101    SkippedDecodeError,
102    SkippedUnsupported,
103    SkippedByPolicy,
104    ErrorInternal,
105}
106
107/// COCOMO I (Basic) project mode — determines the a/b/c/d exponent coefficients.
108#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
109#[serde(rename_all = "snake_case")]
110pub enum CocomoMode {
111    /// Small team, familiar domain. Effort = 2.4 × KSLOC^1.05.
112    #[default]
113    Organic,
114    /// Mixed constraints. Effort = 3.0 × KSLOC^1.12.
115    SemiDetached,
116    /// Tight hardware/OS constraints. Effort = 3.6 × KSLOC^1.20.
117    Embedded,
118}
119
120/// COCOMO I (Basic) cost-estimation result derived from total code SLOC.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CocomoEstimate {
123    pub mode: CocomoMode,
124    /// Input: code lines in thousands (KSLOC).
125    pub ksloc: f64,
126    /// Estimated development effort in person-months.
127    pub effort_person_months: f64,
128    /// Estimated schedule duration in months.
129    pub duration_months: f64,
130    /// Average team size (effort ÷ duration).
131    pub avg_staff: f64,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct EffectiveCounts {
136    pub code_lines: u64,
137    pub comment_lines: u64,
138    pub blank_lines: u64,
139    pub mixed_lines_separate: u64,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ToolMetadata {
144    pub name: String,
145    pub version: String,
146    pub run_id: String,
147    pub timestamp_utc: DateTime<Utc>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct EnvironmentMetadata {
152    pub operating_system: String,
153    pub architecture: String,
154    pub runtime_mode: String,
155    pub initiator_username: String,
156    pub initiator_hostname: String,
157    /// CI system name when the scan runs inside a known CI environment (Jenkins,
158    /// GitHub Actions, GitLab CI, …). `None` for interactive / local runs.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub ci_name: Option<String>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
164pub struct SummaryTotals {
165    pub files_considered: u64,
166    pub files_analyzed: u64,
167    pub files_skipped: u64,
168    pub total_physical_lines: u64,
169    pub code_lines: u64,
170    pub comment_lines: u64,
171    pub blank_lines: u64,
172    pub mixed_lines_separate: u64,
173    #[serde(default)]
174    pub functions: u64,
175    #[serde(default)]
176    pub classes: u64,
177    #[serde(default)]
178    pub variables: u64,
179    /// C/C++ variable-scope breakdown (member/local/global) and object-like macro constants.
180    #[serde(default)]
181    pub variables_member: u64,
182    #[serde(default)]
183    pub variables_local: u64,
184    #[serde(default)]
185    pub variables_global: u64,
186    #[serde(default)]
187    pub macro_definitions: u64,
188    #[serde(default)]
189    pub imports: u64,
190    #[serde(default)]
191    pub test_count: u64,
192    /// Lexically detected test assertion call lines across all analyzed files.
193    #[serde(default)]
194    pub test_assertion_count: u64,
195    /// Lexically detected test suite / fixture / group declaration lines across all analyzed files.
196    #[serde(default)]
197    pub test_suite_count: u64,
198    /// Aggregated from LCOV data when provided.
199    #[serde(default)]
200    pub coverage_lines_found: u64,
201    #[serde(default)]
202    pub coverage_lines_hit: u64,
203    #[serde(default)]
204    pub coverage_functions_found: u64,
205    #[serde(default)]
206    pub coverage_functions_hit: u64,
207    #[serde(default)]
208    pub coverage_branches_found: u64,
209    #[serde(default)]
210    pub coverage_branches_hit: u64,
211    /// Sum of per-file cyclomatic complexity scores across all analyzed files.
212    #[serde(default)]
213    pub cyclomatic_complexity: u64,
214    /// Total logical SLOC across files that support it; `None` if no files produced LSLOC.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub lsloc: Option<u64>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct LanguageSummary {
221    pub language: Language,
222    pub files: u64,
223    pub total_physical_lines: u64,
224    pub code_lines: u64,
225    pub comment_lines: u64,
226    pub blank_lines: u64,
227    pub mixed_lines_separate: u64,
228    #[serde(default)]
229    pub functions: u64,
230    #[serde(default)]
231    pub classes: u64,
232    #[serde(default)]
233    pub variables: u64,
234    /// C/C++ variable-scope breakdown (member/local/global) and object-like macro constants.
235    #[serde(default)]
236    pub variables_member: u64,
237    #[serde(default)]
238    pub variables_local: u64,
239    #[serde(default)]
240    pub variables_global: u64,
241    #[serde(default)]
242    pub macro_definitions: u64,
243    #[serde(default)]
244    pub imports: u64,
245    #[serde(default)]
246    pub test_count: u64,
247    #[serde(default)]
248    pub test_assertion_count: u64,
249    #[serde(default)]
250    pub test_suite_count: u64,
251    #[serde(default)]
252    pub coverage_lines_found: u64,
253    #[serde(default)]
254    pub coverage_lines_hit: u64,
255    #[serde(default)]
256    pub coverage_functions_found: u64,
257    #[serde(default)]
258    pub coverage_functions_hit: u64,
259    #[serde(default)]
260    pub coverage_branches_found: u64,
261    #[serde(default)]
262    pub coverage_branches_hit: u64,
263    #[serde(default)]
264    pub cyclomatic_complexity: u64,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub lsloc: Option<u64>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct FileRecord {
271    pub path: String,
272    pub relative_path: String,
273    pub language: Option<Language>,
274    pub size_bytes: u64,
275    pub detected_encoding: Option<String>,
276    pub raw_line_categories: RawLineCounts,
277    pub effective_counts: EffectiveCounts,
278    pub status: FileStatus,
279    pub warnings: Vec<String>,
280    pub generated: bool,
281    pub minified: bool,
282    pub vendor: bool,
283    pub parse_mode: Option<ParseMode>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub submodule: Option<String>,
286    /// Line/function/branch coverage from an external LCOV file, when provided.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub coverage: Option<FileCoverage>,
289    /// Lexical style-guide adherence analysis; `None` for unsupported languages.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub style_analysis: Option<StyleAnalysis>,
292    /// Cyclomatic complexity approximation for this file (sum of branch decision keywords).
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub cyclomatic_complexity: Option<u32>,
295    /// Logical SLOC estimate; `None` when the language does not support lexical LSLOC.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub lsloc: Option<u32>,
298    /// Git commit-count in the configured activity window that touched this file.
299    /// `None` unless `analysis.activity_window_days` is set and the root is a git repo.
300    /// Powers the hotspots view; distinct from the web layer's scan-to-scan churn rate.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub commit_count: Option<u32>,
303    /// ISO-8601 date of the most recent commit touching this file within the window.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub last_commit_date: Option<String>,
306    /// Per-author line ownership for this file (blame-based), sorted by total lines owned
307    /// descending. `None` unless `analysis.attribution` is enabled and the root is a git repo.
308    /// `author_id` indexes into `AnalysisRun::authors`.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub ownership: Option<Vec<FileOwnership>>,
311    /// SHA-256 (first 8 bytes as u64) of raw file bytes — used for duplicate detection.
312    /// Not serialized; consumed in-process during `assemble_run`.
313    #[serde(skip)]
314    pub content_hash: u64,
315}
316
317impl FileRecord {
318    /// Heuristic: does this file hold unit tests? True when the lexical analyzer detected test
319    /// symbols / assertions / suites, or the path follows a common test-file convention. Shared by
320    /// the report and web layers so the classification stays identical across surfaces.
321    pub fn is_test_file(&self) -> bool {
322        let rc = &self.raw_line_categories;
323        if rc.test_count > 0 || rc.test_assertion_count > 0 || rc.test_suite_count > 0 {
324            return true;
325        }
326        let p = self.relative_path.to_ascii_lowercase().replace('\\', "/");
327        p.contains("/tests/")
328            || p.contains("/test/")
329            || p.contains("/spec/")
330            || p.contains("__tests__")
331            || p.contains(".test.")
332            || p.contains(".spec.")
333            || p.contains("_test.")
334            || p.contains("_spec.")
335            || p.starts_with("test/")
336            || p.starts_with("tests/")
337    }
338}
339
340/// Per-language-family style aggregation within a `StyleSummary`.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct LanguageStyleGroup {
343    /// Display label, e.g. `"C / C++"`, `"Python"`, `"JavaScript"`.
344    pub language_family: String,
345    /// Number of files in this group.
346    pub files_count: u32,
347    /// Name of the guide with the highest average adherence.
348    pub dominant_guide: String,
349    /// Average adherence of the dominant guide (0-100).
350    pub dominant_score_pct: u8,
351    /// Most common indent style across the group.
352    pub common_indent_style: String,
353    /// Average guide adherence scores (guide name, 0-100) sorted descending.
354    pub guide_avg_scores: Vec<(String, u8)>,
355    /// Percentage of files (0-100) where ≤ 5 % of lines exceed the configured column threshold.
356    pub line80_compliant_pct: u8,
357    /// Same as `line80_compliant_pct` but named for the actual configured threshold.
358    pub line_col_compliant_pct: u8,
359}
360
361/// Aggregate multi-language style-guide adherence across all analysed files.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct StyleSummary {
364    /// Total files for which style data was produced.
365    pub files_analyzed: u32,
366    /// Most common indent style across *all* analysed files.
367    pub common_indent_style: String,
368    /// Percentage of all analysed files (0-100) with ≤ 5 % of lines over 80 chars (legacy, always 80).
369    pub line80_compliant_pct: u8,
370    /// Percentage of all analysed files (0-100) with ≤ 5 % of lines over `col_threshold` chars.
371    pub line_col_compliant_pct: u8,
372    /// Column-width threshold used for `line_col_compliant_pct` (from `analysis.style_col_threshold`).
373    pub col_threshold: u16,
374    /// Per-language-family breakdown, sorted by `files_count` descending.
375    pub by_language: Vec<LanguageStyleGroup>,
376}
377
378/// Backward-compatible alias kept so that `sloc-report` and `sloc-web` can migrate
379/// incrementally without a breaking change on the same release.
380pub type CppStyleSummary = StyleSummary;
381
382/// Per-submodule aggregated stats produced when `submodule_breakdown` is enabled.
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct SubmoduleSummary {
385    pub name: String,
386    pub relative_path: String,
387    pub files_analyzed: u64,
388    pub total_physical_lines: u64,
389    pub code_lines: u64,
390    pub comment_lines: u64,
391    pub blank_lines: u64,
392    pub language_summaries: Vec<LanguageSummary>,
393    /// Short commit SHA (7 chars) of the submodule's own HEAD at scan time.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub git_commit_short: Option<String>,
396    /// Full commit SHA of the submodule's own HEAD at scan time.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub git_commit_long: Option<String>,
399    /// Branch name active in the submodule at scan time.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub git_branch: Option<String>,
402    /// Author of the submodule's most recent commit at scan time.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub git_commit_author: Option<String>,
405    /// ISO 8601 author-date of the submodule's most recent commit.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub git_commit_date: Option<String>,
408    /// URL of the submodule's `origin` remote as recorded in its `.git/config`.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub git_remote_url: Option<String>,
411}
412
413/// A raw `(name, email)` pair exactly as recorded in git history (post-`.mailmap`). Multiple
414/// raw identities can be folded into one [`Author`] when they share a normalized email.
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
416pub struct RawIdentity {
417    pub name: String,
418    pub email: String,
419}
420
421/// Line tallies bucketed the same three ways as [`LineCategory`]. `total_lines` is the sum of
422/// the three category counts (i.e. physical lines owned).
423#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
424pub struct AuthorLineCounts {
425    pub code_lines: u64,
426    pub comment_lines: u64,
427    pub blank_lines: u64,
428    pub total_lines: u64,
429}
430
431impl AuthorLineCounts {
432    fn add_category(&mut self, cat: LineCategory) {
433        match cat {
434            LineCategory::Code => self.code_lines += 1,
435            LineCategory::Comment => self.comment_lines += 1,
436            LineCategory::Blank => self.blank_lines += 1,
437        }
438        self.total_lines += 1;
439    }
440
441    fn add(&mut self, other: &AuthorLineCounts) {
442        self.code_lines += other.code_lines;
443        self.comment_lines += other.comment_lines;
444        self.blank_lines += other.blank_lines;
445        self.total_lines += other.total_lines;
446    }
447}
448
449/// One author's ownership of a single file. `author_id` indexes into [`AnalysisRun::authors`].
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct FileOwnership {
452    pub author_id: u32,
453    pub counts: AuthorLineCounts,
454}
455
456/// A resolved contributor: one human, possibly spanning several raw git identities that were
457/// auto-merged because they share a normalized email. Cross-email merges (corporate login vs.
458/// full name vs. last name) are a follow-up interactive step; Phase 1 only auto-merges the
459/// high-confidence same-email case, which is always safe.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct Author {
462    /// Stable index into [`AnalysisRun::authors`]; authors are ordered by code lines owned.
463    pub id: u32,
464    pub canonical_name: String,
465    pub canonical_email: String,
466    /// Every raw `(name, email)` pair folded into this author.
467    pub aliases: Vec<RawIdentity>,
468    /// Repo-wide roll-up across all files.
469    pub counts: AuthorLineCounts,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct AnalysisRun {
474    pub tool: ToolMetadata,
475    pub environment: EnvironmentMetadata,
476    pub effective_configuration: AppConfig,
477    pub input_roots: Vec<String>,
478    pub summary_totals: SummaryTotals,
479    pub totals_by_language: Vec<LanguageSummary>,
480    pub per_file_records: Vec<FileRecord>,
481    pub skipped_file_records: Vec<FileRecord>,
482    pub warnings: Vec<String>,
483    /// Non-empty only when `discovery.submodule_breakdown` is enabled.
484    #[serde(default, skip_serializing_if = "Vec::is_empty")]
485    pub submodule_summaries: Vec<SubmoduleSummary>,
486    /// Short git commit SHA (7 chars) at scan time, if the project is a git repo.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub git_commit_short: Option<String>,
489    /// Full git commit SHA at scan time, if the project is a git repo.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub git_commit_long: Option<String>,
492    /// Git branch active at scan time, if the project is a git repo.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub git_branch: Option<String>,
495    /// Author of the last git commit at scan time.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub git_commit_author: Option<String>,
498    /// Comma-separated git tags pointing at HEAD at scan time.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub git_tags: Option<String>,
501    /// Nearest ancestor release tag (output of `git describe --tags --abbrev=0`).
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub git_nearest_tag: Option<String>,
504    /// ISO 8601 author-date of the last git commit at scan time.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub git_commit_date: Option<String>,
507    /// URL of the `origin` remote as recorded in `.git/config` at scan time.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub git_remote_url: Option<String>,
510    /// Multi-language style-guide adherence; `None` when no supported files were analysed.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub style_summary: Option<StyleSummary>,
513    /// COCOMO I (Basic) effort/schedule estimate derived from total code SLOC.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub cocomo: Option<CocomoEstimate>,
516    /// Unique Lines of Code: count of distinct non-blank code lines across all analyzed files.
517    #[serde(default)]
518    pub uloc: u64,
519    /// `DRYness` percentage: `uloc / total_code_lines × 100`. `None` when code lines = 0.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub dryness_pct: Option<f32>,
522    /// Groups of files with identical content (relative paths). Only non-singleton groups included.
523    #[serde(default, skip_serializing_if = "Vec::is_empty")]
524    pub duplicate_groups: Vec<Vec<String>>,
525    /// Number of duplicate files excluded from SLOC totals (when `exclude_duplicates` is set).
526    #[serde(default)]
527    pub duplicates_excluded: usize,
528    /// Per-author code-ownership roll-up, ordered by code lines owned (descending). Non-empty
529    /// only when `analysis.attribution` is enabled and the scan root is a git repo. Author
530    /// indices are referenced by `FileRecord::ownership`.
531    #[serde(default, skip_serializing_if = "Vec::is_empty")]
532    pub authors: Vec<Author>,
533}
534
535#[derive(Default)]
536struct GitInfo {
537    commit_short: Option<String>,
538    commit_long: Option<String>,
539    branch: Option<String>,
540    author: Option<String>,
541    tags: Option<String>,
542    nearest_tag: Option<String>,
543    commit_date: Option<String>,
544    remote_url: Option<String>,
545}
546
547/// Return `true` if `dir` is itself the top level of a git repository — i.e. it
548/// contains a `.git` directory, or a `.git` *file* (worktree/submodule pointer)
549/// that resolves to a real gitdir. Tests `dir` only; does not walk up.
550fn is_git_root(dir: &Path) -> bool {
551    let candidate = dir.join(".git");
552    if candidate.is_dir() {
553        return true;
554    }
555    candidate.is_file() && resolve_git_file_pointer(&candidate, dir).is_some()
556}
557
558/// Locate the `.git` directory by walking up from `start`.
559/// Handles plain repos, worktrees (`.git` is a file with `gitdir:` pointer), and
560/// submodules. Returns `None` if no git repo is found.
561fn find_git_dir(start: &Path) -> Option<PathBuf> {
562    let mut current = Some(start);
563    while let Some(dir) = current {
564        let candidate = dir.join(".git");
565        if candidate.is_dir() {
566            return Some(candidate);
567        }
568        if candidate.is_file()
569            && let Some(resolved) = resolve_git_file_pointer(&candidate, dir)
570        {
571            return Some(resolved);
572        }
573        current = dir.parent();
574    }
575    None
576}
577
578/// Resolve a `.git` *file* (worktree/submodule pointer) to the absolute path it
579/// points to. Returns `None` if the file is unreadable or lacks a `gitdir:` line,
580/// or if the resolved path is not an existing directory.
581fn resolve_git_file_pointer(file: &Path, base_dir: &Path) -> Option<PathBuf> {
582    let content = fs::read_to_string(file).ok()?;
583    let ptr = content.trim().strip_prefix("gitdir: ")?;
584    // Normalise forward-slash paths to the OS separator so that Path operations
585    // (join, exists, canonicalize) work correctly on Windows.
586    let ptr_native = ptr.replace('/', std::path::MAIN_SEPARATOR_STR);
587    let resolved = if Path::new(&ptr_native).is_absolute() {
588        PathBuf::from(&ptr_native)
589    } else {
590        base_dir.join(&ptr_native)
591    };
592    // canonicalize resolves ".." components and symlinks; fall back to the
593    // un-canonicalized path if it fails (e.g. some Windows configurations
594    // return a UNC "\\?\" prefix that confuses later path operations).
595    let final_path = resolved.canonicalize().unwrap_or(resolved);
596    if final_path.is_dir() {
597        Some(final_path)
598    } else {
599        None
600    }
601}
602
603/// Resolve a git ref name (e.g. `refs/heads/main`) to a full 40-char commit SHA.
604/// Checks loose ref files first, then `packed-refs`.
605fn resolve_ref(git_dir: &Path, refname: &str) -> Option<String> {
606    // Build the OS-native path to the loose ref file by joining each
607    // forward-slash component individually.  This produces the correct
608    // separator on every platform without any manual replacement.
609    let ref_path = refname
610        .split('/')
611        .fold(git_dir.to_path_buf(), |p, c| p.join(c));
612    if ref_path.exists() {
613        let sha = fs::read_to_string(&ref_path)
614            .ok()
615            .map(|s| s.trim().to_string())
616            .filter(|s| s.len() >= 40 && s.chars().all(|c| c.is_ascii_hexdigit()));
617        if sha.is_some() {
618            return sha;
619        }
620    }
621    // Packed refs: each line is "<sha> <refname>" (lines starting with '#' are
622    // comments; lines starting with '^' are peeled tag objects to skip).
623    // str::lines() handles both \n and \r\n, so Windows line endings are fine.
624    let packed = fs::read_to_string(git_dir.join("packed-refs")).ok()?;
625    for line in packed.lines() {
626        if line.starts_with('#') || line.starts_with('^') {
627            continue;
628        }
629        let mut cols = line.splitn(2, ' ');
630        let sha = cols.next()?;
631        let name = cols.next()?.trim();
632        if name == refname {
633            return Some(sha.to_string());
634        }
635    }
636    None
637}
638
639/// Extract the URL value from a `url = <value>` git-config line, returning `None` if absent or empty.
640fn parse_url_line(line: &str) -> Option<&str> {
641    let rest = line.strip_prefix("url")?;
642    let rest = rest.trim_start_matches([' ', '\t']);
643    let url = rest.strip_prefix('=')?.trim();
644    if url.is_empty() { None } else { Some(url) }
645}
646
647/// Parse `.git/config` and return the URL of the `origin` remote, if present.
648fn read_git_remote_url(git_dir: &Path) -> Option<String> {
649    let config = fs::read_to_string(git_dir.join("config")).ok()?;
650    let mut in_origin = false;
651    for line in config.lines() {
652        let trimmed = line.trim();
653        if trimmed.starts_with('[') {
654            in_origin = trimmed == r#"[remote "origin"]"#;
655        } else if in_origin && let Some(url) = parse_url_line(trimmed) {
656            return Some(url.to_owned());
657        }
658    }
659    None
660}
661
662/// Detect git metadata by reading `.git/` files directly — no `git` executable
663/// needed. Falls back gracefully for detached HEADs, shallow clones, and missing
664/// reflogs.
665fn detect_git_for_run(project_path: &Path) -> GitInfo {
666    // Resolve the CI branch early so it can fill in any gap in git metadata.
667    let ci_branch = ci_branch_from_env();
668
669    let Some(git_dir) = find_git_dir(project_path) else {
670        // No .git directory (e.g. scanning a non-repo path in CI). Use whatever
671        // the CI system tells us about the branch.
672        return GitInfo {
673            branch: ci_branch,
674            ..GitInfo::default()
675        };
676    };
677
678    let head_raw = match fs::read_to_string(git_dir.join("HEAD")) {
679        Ok(s) => s.trim().to_string(),
680        Err(_) => {
681            return GitInfo {
682                branch: ci_branch,
683                ..GitInfo::default()
684            };
685        }
686    };
687
688    let (branch_from_head, commit_long) = head_raw.strip_prefix("ref: ").map_or_else(
689        || {
690            if head_raw.len() >= 40 && head_raw.chars().all(|c| c.is_ascii_hexdigit()) {
691                // Detached HEAD — HEAD file is the commit SHA (common in CI checkouts).
692                (None, Some(head_raw[..40].to_string()))
693            } else {
694                (None, None)
695            }
696        },
697        |refname| {
698            let branch = refname
699                .strip_prefix("refs/heads/")
700                .map(|b| b.trim().to_string());
701            let sha = resolve_ref(&git_dir, refname.trim());
702            (branch, sha)
703        },
704    );
705    // Prefer the branch name derived from the HEAD ref; fall back to the CI
706    // env var (covers detached-HEAD checkouts done by Jenkins, GitHub Actions, etc.).
707    let branch = branch_from_head.or(ci_branch);
708
709    let commit_short = commit_long
710        .as_deref()
711        .map(|s| s.chars().take(7).collect::<String>());
712
713    let author = run_git_cmd(project_path, &["log", "-1", "--format=%an", "HEAD"]);
714    let commit_date = run_git_cmd(project_path, &["log", "-1", "--format=%aI", "HEAD"]);
715    let remote_url = read_git_remote_url(&git_dir);
716
717    // Tags and nearest-tag still require git CLI — try it as a best-effort bonus
718    // but don't block on it. If git isn't available these will simply be None.
719    let tags = run_git_cmd(project_path, &["tag", "--points-at", "HEAD"]).map(|t| {
720        t.lines()
721            .filter(|l| !l.is_empty())
722            .collect::<Vec<_>>()
723            .join(", ")
724    });
725    let nearest_tag = run_git_cmd(project_path, &["describe", "--tags", "--abbrev=0", "HEAD"]);
726
727    GitInfo {
728        commit_short,
729        commit_long,
730        branch,
731        author,
732        tags,
733        nearest_tag,
734        commit_date,
735        remote_url,
736    }
737}
738
739/// Run a git command as a best-effort supplemental source.
740fn run_git_cmd(dir: &Path, args: &[&str]) -> Option<String> {
741    // Try the bare name first (works when git is on PATH), then fall back to
742    // absolute paths for service accounts that run with a stripped PATH.
743    // Unix paths silently fail on Windows and vice-versa.
744    let candidates: &[&str] = &[
745        // Works on all platforms when git is on PATH
746        "git",
747        // Common Linux / macOS install locations
748        "/usr/bin/git",
749        "/usr/local/bin/git",
750        "/opt/homebrew/bin/git",
751        // Git for Windows default installation paths
752        r"C:\Program Files\Git\cmd\git.exe",
753        r"C:\Program Files\Git\bin\git.exe",
754        r"C:\Program Files (x86)\Git\cmd\git.exe",
755    ];
756    for &exe in candidates {
757        let result = std::process::Command::new(exe)
758            .args(["-c", "safe.directory=*"])
759            .args(args)
760            .current_dir(dir)
761            .output()
762            .ok()
763            .filter(|o| o.status.success())
764            .and_then(|o| String::from_utf8(o.stdout).ok())
765            .map(|s| s.trim().to_string())
766            .filter(|s| !s.is_empty());
767        if result.is_some() {
768            return result;
769        }
770    }
771    None
772}
773
774/// Per-file git activity (commit-count + last-change date) over `window_days`, computed
775/// with a single `git log --name-status` pass. Keys are paths relative to `project_path`
776/// (via `--relative`), matching `FileRecord::relative_path`. Best-effort: returns an empty
777/// map when git is unavailable or the path is not a repository — a scan never fails on this.
778fn detect_file_activity(
779    project_path: &Path,
780    window_days: u32,
781) -> HashMap<String, (u32, Option<String>)> {
782    let since = format!("--since={window_days} days ago");
783    // `--relative` limits output to (and reports paths relative to) the scan directory, so
784    // the keys line up with FileRecord::relative_path even when scanning a repo subdirectory.
785    // %x00 prefixes each commit header with a NUL, distinguishing it from name-status lines.
786    let out = run_git_cmd(
787        project_path,
788        &[
789            "-c",
790            "core.quotepath=false",
791            "log",
792            since.as_str(),
793            "--no-merges",
794            "--name-status",
795            "--relative",
796            "--pretty=format:%x00%aI",
797        ],
798    );
799    out.map(|s| parse_activity_log(&s)).unwrap_or_default()
800}
801
802/// Parse `git log --name-status` output (NUL-prefixed commit headers) into a
803/// path → (`commit_count`, `last_commit_date`) map. `git log` emits newest-first, so the
804/// first time a path appears is its most recent change. Renames are attributed to the new path.
805fn parse_activity_log(out: &str) -> HashMap<String, (u32, Option<String>)> {
806    let mut map: HashMap<String, (u32, Option<String>)> = HashMap::new();
807    let mut current_date: Option<String> = None;
808    for line in out.lines() {
809        if let Some(date) = line.strip_prefix('\u{0}') {
810            let d = date.trim();
811            current_date = (!d.is_empty()).then(|| d.to_owned());
812            continue;
813        }
814        if line.trim().is_empty() {
815            continue;
816        }
817        // name-status line: "STATUS\tpath" or "Rxxx\told\tnew" / "Cxxx\told\tnew".
818        let mut fields = line.split('\t');
819        let status = fields.next().unwrap_or("");
820        let path = if status.starts_with('R') || status.starts_with('C') {
821            fields.next_back()
822        } else {
823            fields.next()
824        };
825        let Some(path) = path.map(str::trim).filter(|p| !p.is_empty()) else {
826            continue;
827        };
828        let entry = map.entry(path.to_owned()).or_insert((0, None));
829        entry.0 += 1;
830        if entry.1.is_none() {
831            entry.1.clone_from(&current_date);
832        }
833    }
834    map
835}
836
837/// Update the shared phase label (if the caller supplied one) so a polling UI can show which
838/// stage the scan is in. No-op when `progress` or its phase handle is absent.
839fn set_progress_phase(progress: Option<&ProgressCounters>, label: &str) {
840    if let Some(phase) = progress.and_then(|p| p.phase.as_ref())
841        && let Ok(mut current) = phase.lock()
842    {
843        *current = label.to_string();
844    }
845}
846
847/// One physical line's classification paired with the git identity that last touched it.
848type BlamePairs = Vec<(LineCategory, RawIdentity)>;
849
850/// Blame every analyzed file under `root`, attribute each physical line to its last author,
851/// bucket it as code / comment / blank, and roll the tallies up per resolved contributor.
852///
853/// Mutates each `FileRecord` in place (populating `ownership`) and returns the repo-wide author
854/// list ordered by code lines owned (descending). Best-effort: files that fail to blame or read
855/// are silently skipped, so a partial result is always safe.
856///
857/// The `git blame` subprocess per file dominates the whole scan on large repositories, so the
858/// blame calls are fanned out across the analysis thread pool. Only the folding into the shared
859/// `AuthorResolver` runs sequentially (in stable index order) to keep author ids deterministic.
860/// Progress is reported via `progress.attrib_done`/`attrib_total`, and `cancel` is honoured
861/// between files so an aborted scan stops promptly. The whole pass is opt-in behind
862/// `analysis.attribution`.
863fn attribute_ownership(
864    root: &Path,
865    records: &mut [FileRecord],
866    progress: Option<&ProgressCounters>,
867    cancel: Option<&AtomicBool>,
868) -> Vec<Author> {
869    // Surface the phase to any polling UI: after file counting ends the blame pass is by far the
870    // longest stage, and without this the scan looks finished-but-stalled with no updates.
871    set_progress_phase(progress, "Attributing authorship");
872
873    // Only files with a detected language are blameable. Collect their indices up front so the
874    // attribution total is exact and the parallel pass has a stable work-list.
875    let indices: Vec<usize> = records
876        .iter()
877        .enumerate()
878        .filter(|(_, rec)| rec.language.is_some())
879        .map(|(i, _)| i)
880        .collect();
881    if let Some(p) = progress {
882        p.attrib_total.store(indices.len(), Ordering::Relaxed);
883        p.attrib_done.store(0, Ordering::Relaxed);
884    }
885
886    let attrib_done = progress.map(|p| p.attrib_done.as_ref());
887    let blamed = parallel_blame(root, records, &indices, cancel, attrib_done);
888
889    // Fold per-file results into the resolver sequentially, in index order, so author ids stay
890    // deterministic regardless of which thread finished which file first.
891    let mut resolver = AuthorResolver::default();
892    for (pos, &idx) in indices.iter().enumerate() {
893        let Some(pairs) = blamed.get(pos).and_then(Option::as_ref) else {
894            continue;
895        };
896        let mut per_file: HashMap<u32, AuthorLineCounts> = HashMap::new();
897        for (category, ident) in pairs {
898            let id = resolver.resolve(ident);
899            per_file.entry(id).or_default().add_category(*category);
900        }
901
902        let mut ownership: Vec<FileOwnership> = per_file
903            .into_iter()
904            .map(|(author_id, counts)| {
905                resolver.authors[author_id as usize].counts.add(&counts);
906                FileOwnership { author_id, counts }
907            })
908            .collect();
909        ownership.sort_by_key(|entry| std::cmp::Reverse(entry.counts.total_lines));
910        records[idx].ownership = Some(ownership);
911    }
912
913    resolver.finish(records)
914}
915
916/// Blame a single record: read the working-tree bytes, classify each physical line, and pair it
917/// with the git identity that last touched it. `None` when the file has no language, can't be
918/// read, or `git blame` produced nothing (non-git path, shallow clone). The category/identity
919/// lists are zipped by the shorter of the two — trailing-newline / CRLF quirks can differ by one.
920fn blame_one(root: &Path, rec: &FileRecord) -> Option<BlamePairs> {
921    let language = rec.language?;
922    let bytes = std::fs::read(&rec.path).ok()?;
923    let text = String::from_utf8_lossy(&bytes);
924    let categories = classify_physical_lines(language, &text);
925    let blame = blame_line_identities(root, &rec.relative_path);
926    if blame.is_empty() {
927        return None;
928    }
929    Some(categories.into_iter().zip(blame).collect())
930}
931
932/// Run `blame_one` over `indices` (into `records`) across a work-stealing thread pool, returning
933/// results index-aligned with `indices`. Each worker atomically claims the next file, so a slow
934/// blame on one huge file never stalls the others. `attrib_done` is bumped per file for live
935/// progress; `cancel` is polled between files so an aborted scan stops without finishing the pool.
936fn parallel_blame(
937    root: &Path,
938    records: &[FileRecord],
939    indices: &[usize],
940    cancel: Option<&AtomicBool>,
941    attrib_done: Option<&AtomicUsize>,
942) -> Vec<Option<BlamePairs>> {
943    let n = indices.len();
944    if n == 0 {
945        return Vec::new();
946    }
947    let thread_count = std::thread::available_parallelism().map_or(DEFAULT_ANALYSIS_THREADS, |t| {
948        t.get().min(MAX_ANALYSIS_THREADS)
949    });
950    let next_index = AtomicUsize::new(0);
951
952    let chunks: Vec<Vec<(usize, Option<BlamePairs>)>> = std::thread::scope(|s| {
953        let mut handles = Vec::with_capacity(thread_count);
954        for _ in 0..thread_count {
955            handles.push(s.spawn(|| {
956                let mut local: Vec<(usize, Option<BlamePairs>)> = Vec::new();
957                loop {
958                    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
959                        break;
960                    }
961                    let pos = next_index.fetch_add(1, Ordering::Relaxed);
962                    if pos >= n {
963                        break;
964                    }
965                    let payload = blame_one(root, &records[indices[pos]]);
966                    if let Some(done) = attrib_done {
967                        done.fetch_add(1, Ordering::Relaxed);
968                    }
969                    local.push((pos, payload));
970                }
971                local
972            }));
973        }
974        handles
975            .into_iter()
976            .map(|h| h.join().unwrap_or_default())
977            .collect()
978    });
979
980    let mut out: Vec<Option<BlamePairs>> = (0..n).map(|_| None).collect();
981    for chunk in chunks {
982        for (pos, payload) in chunk {
983            out[pos] = payload;
984        }
985    }
986    out
987}
988
989/// How costly the per-author attribution (git blame) pass is expected to be for a repository,
990/// used to warn the user and to auto-default attribution off on pathologically large trees.
991#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
992#[serde(rename_all = "snake_case")]
993pub enum AttributionSeverity {
994    /// Small repo — attribution finishes in seconds. Recommended on.
995    Light,
996    /// Mid-size repo — attribution adds up to a couple of minutes. On, but flagged.
997    Moderate,
998    /// Very large repo (e.g. a monorepo with many submodules) — attribution can take many
999    /// minutes. Recommended off by default; the user can still opt in.
1000    Heavy,
1001}
1002
1003/// A cheap up-front estimate of the attribution (git blame) cost for a repository, computed from
1004/// git metadata alone (no filesystem walk, no file reads) so it can run while the user is still
1005/// configuring the scan. [`Self::blameable_files`] counts tracked files — including submodule
1006/// working trees via `--recurse-submodules` — whose name maps to a supported language, which is
1007/// exactly the set the blame pass would process.
1008#[derive(Debug, Clone, Serialize, Deserialize)]
1009pub struct AttributionEstimate {
1010    /// Whether the path is a git repository at all (attribution is a no-op otherwise).
1011    pub is_git: bool,
1012    /// Number of tracked, language-detected files that would be blamed (submodules included).
1013    pub blameable_files: u64,
1014    /// Total commit depth of `HEAD` — a rough proxy for how much history each blame walks.
1015    pub commit_count: u64,
1016    /// Bucketed severity used to drive the UI warning and the auto-default.
1017    pub severity: AttributionSeverity,
1018    /// Whether attribution should default to on for this repo (false only for [`Heavy`]).
1019    ///
1020    /// [`Heavy`]: AttributionSeverity::Heavy
1021    pub recommend_attribution: bool,
1022    /// Rough wall-clock estimate for the blame pass, in seconds (order-of-magnitude only).
1023    pub estimated_seconds: u64,
1024    /// Number of git submodules declared in the super-repo's `.gitmodules` (0 when none).
1025    pub submodule_count: u64,
1026    /// Total commit depth across the super-repo **and** every submodule combined — i.e.
1027    /// [`Self::commit_count`] plus the sum of each submodule's own `HEAD` depth.
1028    pub combined_commit_count: u64,
1029}
1030
1031/// Files-per-second throughput assumed for the blame pass when estimating its duration. Derived
1032/// from real runs of the parallel blame path; deliberately conservative so the estimate errs high
1033/// rather than surprising the user. Only ever used for a human-facing "~N minutes" hint.
1034const BLAME_FILES_PER_SEC: u64 = 50;
1035/// At or above this many blameable files, attribution is treated as [`AttributionSeverity::Heavy`]
1036/// and defaulted off. Below [`ATTRIB_MODERATE_FILES`] it is [`Light`]; in between, [`Moderate`].
1037///
1038/// [`Light`]: AttributionSeverity::Light
1039/// [`Moderate`]: AttributionSeverity::Moderate
1040const ATTRIB_HEAVY_FILES: u64 = 10_000;
1041/// Lower bound of the [`AttributionSeverity::Moderate`] band (files).
1042const ATTRIB_MODERATE_FILES: u64 = 2_000;
1043/// A deep history makes each blame walk more commits; combined with a merely-moderate file count
1044/// it still adds up, so a repo past this commit depth is promoted one severity band.
1045const ATTRIB_DEEP_HISTORY_COMMITS: u64 = 50_000;
1046
1047/// Estimate the attribution cost for `root` from git metadata alone. Best-effort and fast (two
1048/// `git` calls, no walk): a non-git path or any git failure yields a zero-cost, attribution-on
1049/// estimate so nothing is ever blocked. See [`AttributionEstimate`].
1050#[must_use]
1051pub fn estimate_attribution_cost(root: &Path) -> AttributionEstimate {
1052    if find_git_dir(root).is_none() {
1053        return AttributionEstimate {
1054            is_git: false,
1055            blameable_files: 0,
1056            commit_count: 0,
1057            severity: AttributionSeverity::Light,
1058            recommend_attribution: true,
1059            estimated_seconds: 0,
1060            submodule_count: 0,
1061            combined_commit_count: 0,
1062        };
1063    }
1064
1065    // One fast git call lists every tracked file, descending into submodule working trees. Count
1066    // only those whose name maps to a supported language — the exact set the blame pass touches.
1067    let overrides = std::collections::BTreeMap::new();
1068    let blameable_files = run_git_cmd(root, &["ls-files", "--recurse-submodules"])
1069        .map(|out| {
1070            out.lines()
1071                .filter(|line| {
1072                    !line.is_empty()
1073                        && detect_language(Path::new(line), None, &overrides, false).is_some()
1074                })
1075                .count() as u64
1076        })
1077        .unwrap_or(0);
1078
1079    let commit_count = count_head_commits(root);
1080
1081    // Sum each submodule's own commit depth so the UI can show "super-repo" vs. "everything
1082    // combined" commit totals. Best-effort and parallel — a submodule that isn't checked out
1083    // simply contributes 0.
1084    let submodules = detect_submodules(root);
1085    let submodule_commits = count_submodule_commits(root, &submodules);
1086    let combined_commit_count = commit_count.saturating_add(submodule_commits);
1087
1088    let severity = classify_attribution_severity(blameable_files, commit_count);
1089    AttributionEstimate {
1090        is_git: true,
1091        blameable_files,
1092        commit_count,
1093        severity,
1094        recommend_attribution: severity != AttributionSeverity::Heavy,
1095        estimated_seconds: blameable_files.div_ceil(BLAME_FILES_PER_SEC),
1096        submodule_count: submodules.len() as u64,
1097        combined_commit_count,
1098    }
1099}
1100
1101/// `git rev-list --count HEAD` in `dir`, or 0 on any failure (empty/shallow/non-git).
1102fn count_head_commits(dir: &Path) -> u64 {
1103    run_git_cmd(dir, &["rev-list", "--count", "HEAD"])
1104        .and_then(|s| s.trim().parse::<u64>().ok())
1105        .unwrap_or(0)
1106}
1107
1108/// Sum of `HEAD` commit depth across every submodule working tree, counted in parallel. Returns 0
1109/// when there are no submodules. Best-effort: an unchecked-out or unreadable submodule adds 0.
1110fn count_submodule_commits(root: &Path, submodules: &[(String, PathBuf)]) -> u64 {
1111    let n = submodules.len();
1112    if n == 0 {
1113        return 0;
1114    }
1115    let thread_count = std::thread::available_parallelism()
1116        .map_or(DEFAULT_ANALYSIS_THREADS, |t| {
1117            t.get().min(MAX_ANALYSIS_THREADS)
1118        })
1119        .min(n);
1120    let next_index = AtomicUsize::new(0);
1121
1122    let partials: Vec<u64> = std::thread::scope(|s| {
1123        let mut handles = Vec::with_capacity(thread_count);
1124        for _ in 0..thread_count {
1125            handles.push(s.spawn(|| {
1126                let mut sum = 0u64;
1127                loop {
1128                    let i = next_index.fetch_add(1, Ordering::Relaxed);
1129                    if i >= n {
1130                        break;
1131                    }
1132                    sum = sum.saturating_add(count_head_commits(&root.join(&submodules[i].1)));
1133                }
1134                sum
1135            }));
1136        }
1137        handles.into_iter().map(|h| h.join().unwrap_or(0)).collect()
1138    });
1139    partials.iter().sum()
1140}
1141
1142/// Bucket a repo into an [`AttributionSeverity`] from its blameable-file count and commit depth.
1143/// Severity is driven primarily by file count (one blame subprocess per file dominates the cost),
1144/// then promoted one band when the history is very deep — a merely-moderate file count over a huge
1145/// history still adds up. Pure, so it is unit-tested directly without needing a git fixture.
1146#[must_use]
1147fn classify_attribution_severity(blameable_files: u64, commit_count: u64) -> AttributionSeverity {
1148    let base = if blameable_files >= ATTRIB_HEAVY_FILES {
1149        AttributionSeverity::Heavy
1150    } else if blameable_files >= ATTRIB_MODERATE_FILES {
1151        AttributionSeverity::Moderate
1152    } else {
1153        AttributionSeverity::Light
1154    };
1155    if commit_count < ATTRIB_DEEP_HISTORY_COMMITS {
1156        return base;
1157    }
1158    match base {
1159        AttributionSeverity::Light if blameable_files >= ATTRIB_MODERATE_FILES / 2 => {
1160            AttributionSeverity::Moderate
1161        }
1162        AttributionSeverity::Moderate => AttributionSeverity::Heavy,
1163        other => other,
1164    }
1165}
1166
1167/// Accumulates raw git identities into deduplicated [`Author`]s keyed by normalized email.
1168#[derive(Default)]
1169struct AuthorResolver {
1170    authors: Vec<Author>,
1171    /// Normalized-email key → author index.
1172    key_to_id: HashMap<String, u32>,
1173    /// Parallel to `authors`: the set of raw identities already recorded, to avoid dup aliases.
1174    seen_aliases: Vec<HashSet<RawIdentity>>,
1175}
1176
1177impl AuthorResolver {
1178    fn resolve(&mut self, ident: &RawIdentity) -> u32 {
1179        let key = normalize_email_key(ident);
1180        if let Some(&id) = self.key_to_id.get(&key) {
1181            if self.seen_aliases[id as usize].insert(ident.clone()) {
1182                self.authors[id as usize].aliases.push(ident.clone());
1183            }
1184            return id;
1185        }
1186        let id = self.authors.len() as u32;
1187        let canonical_name = if ident.name.trim().is_empty() {
1188            ident.email.clone()
1189        } else {
1190            ident.name.clone()
1191        };
1192        self.authors.push(Author {
1193            id,
1194            canonical_name,
1195            canonical_email: ident.email.clone(),
1196            aliases: vec![ident.clone()],
1197            counts: AuthorLineCounts::default(),
1198        });
1199        self.seen_aliases.push(HashSet::from([ident.clone()]));
1200        self.key_to_id.insert(key, id);
1201        id
1202    }
1203
1204    /// Sort authors by code lines owned (descending), reassign stable ids, and remap the
1205    /// `author_id`s already stored on each `FileRecord::ownership` to match.
1206    fn finish(self, records: &mut [FileRecord]) -> Vec<Author> {
1207        let mut order: Vec<usize> = (0..self.authors.len()).collect();
1208        order.sort_by(|&a, &b| {
1209            self.authors[b]
1210                .counts
1211                .code_lines
1212                .cmp(&self.authors[a].counts.code_lines)
1213                .then_with(|| {
1214                    self.authors[a]
1215                        .canonical_name
1216                        .cmp(&self.authors[b].canonical_name)
1217                })
1218        });
1219        let mut remap = vec![0u32; self.authors.len()];
1220        for (new_id, &old) in order.iter().enumerate() {
1221            remap[old] = new_id as u32;
1222        }
1223        for rec in records.iter_mut() {
1224            if let Some(ownership) = rec.ownership.as_mut() {
1225                for entry in ownership.iter_mut() {
1226                    entry.author_id = remap[entry.author_id as usize];
1227                }
1228            }
1229        }
1230        let mut sorted: Vec<Author> = order.iter().map(|&old| self.authors[old].clone()).collect();
1231        for (new_id, author) in sorted.iter_mut().enumerate() {
1232            author.id = new_id as u32;
1233        }
1234        sorted
1235    }
1236}
1237
1238/// The stable identity key used for the safe, high-confidence auto-merge: normalized email.
1239/// Same-email identities (differing only in display-name spelling/capitalization) collapse into
1240/// one author. Cross-email merges are deferred to the Phase-2 interactive step. When no usable
1241/// email is present the display name is used so unrelated anonymous commits don't all merge.
1242fn normalize_email_key(ident: &RawIdentity) -> String {
1243    let email = ident.email.trim().to_lowercase();
1244    if email.is_empty() || email == "not.committed.yet" || !email.contains('@') {
1245        return format!("name:{}", ident.name.trim().to_lowercase());
1246    }
1247    // Strip a `+tag` suffix from the local part (e.g. `user+work@host` → `user@host`).
1248    match email.split_once('@') {
1249        Some((local, domain)) => {
1250            let core = local.split('+').next().unwrap_or(local);
1251            format!("{core}@{domain}")
1252        }
1253        None => email,
1254    }
1255}
1256
1257/// Run `git blame` on `rel` (relative to `root`) and return one raw identity per physical line,
1258/// in file order. `-w` ignores whitespace-only reblame and `-M` follows moves within the file so a
1259/// refactor doesn't misattribute ownership; the repo `.mailmap` is honoured by git. Returns an
1260/// empty vec on any failure (non-git path, shallow clone, unreadable file).
1261///
1262/// `-C` (cross-file copy detection) is deliberately *not* passed: it forces git to re-scan every
1263/// other file touched in each commit and is by far the most expensive blame flag — on a large repo
1264/// with submodules it turns a minutes-long pass into a tens-of-minutes one, while rarely changing
1265/// the dominant author. `-M` alone keeps intra-file move tracking, which is what matters for
1266/// "who last owns this line".
1267fn blame_line_identities(root: &Path, rel: &str) -> Vec<RawIdentity> {
1268    run_git_cmd(root, &["blame", "--line-porcelain", "-w", "-M", "--", rel])
1269        .map(|out| parse_blame_porcelain(&out))
1270        .unwrap_or_default()
1271}
1272
1273/// Parse `git blame --line-porcelain` output into one [`RawIdentity`] per source line. In
1274/// line-porcelain mode the `author` / `author-mail` headers repeat for every line and each
1275/// block terminates with a TAB-prefixed content line.
1276fn parse_blame_porcelain(out: &str) -> Vec<RawIdentity> {
1277    let mut identities = Vec::new();
1278    let mut name = String::new();
1279    let mut email = String::new();
1280    for line in out.lines() {
1281        if let Some(rest) = line.strip_prefix("author ") {
1282            name = rest.trim().to_owned();
1283        } else if let Some(rest) = line.strip_prefix("author-mail ") {
1284            email = rest
1285                .trim()
1286                .trim_start_matches('<')
1287                .trim_end_matches('>')
1288                .to_owned();
1289        } else if line.starts_with('\t') {
1290            identities.push(RawIdentity {
1291                name: std::mem::take(&mut name),
1292                email: std::mem::take(&mut email),
1293            });
1294        }
1295    }
1296    identities
1297}
1298
1299// ── Post-hoc author identity merging ─────────────────────────────────────────
1300//
1301// The attribution pass auto-merges only the high-confidence same-email case. Real repos also
1302// accumulate the *same person under different emails* (corporate login, personal address, a
1303// bare "First Last <first@laptop>"). `IdentityMap` lets an operator combine those identities
1304// after a scan — without re-running blame — by grouping their emails under one canonical name.
1305
1306/// One operator-defined merge: several email identities that are actually one person.
1307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1308pub struct AuthorMergeGroup {
1309    /// Display name for the merged contributor.
1310    pub canonical_name: String,
1311    /// Primary email for the merged contributor (also used as the group's stable key).
1312    pub canonical_email: String,
1313    /// Every email folded into this identity (lower-cased), including `canonical_email`.
1314    pub members: Vec<String>,
1315}
1316
1317/// A persisted collection of [`AuthorMergeGroup`]s. Applied to a run's authors at display time.
1318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1319pub struct IdentityMap {
1320    #[serde(default)]
1321    pub groups: Vec<AuthorMergeGroup>,
1322}
1323
1324impl IdentityMap {
1325    /// Load from `path`, returning an empty map if the file is missing or unreadable (so a
1326    /// fresh install just has no merges).
1327    #[must_use]
1328    pub fn load(path: &Path) -> Self {
1329        std::fs::read_to_string(path)
1330            .ok()
1331            .and_then(|s| serde_json::from_str(&s).ok())
1332            .unwrap_or_default()
1333    }
1334
1335    /// Persist to `path` as pretty JSON.
1336    ///
1337    /// # Errors
1338    /// Returns an error if the file cannot be written or serialization fails.
1339    pub fn save(&self, path: &Path) -> Result<()> {
1340        let json = serde_json::to_string_pretty(self)?;
1341        std::fs::write(path, json)
1342            .with_context(|| format!("failed to write identity map to {}", path.display()))
1343    }
1344
1345    /// The group containing `email` (case-insensitive), if any.
1346    #[must_use]
1347    pub fn group_for(&self, email: &str) -> Option<&AuthorMergeGroup> {
1348        let key = email.trim().to_lowercase();
1349        self.groups.iter().find(|g| g.members.contains(&key))
1350    }
1351
1352    /// Merge the given emails into a single identity. Any existing groups that overlap the
1353    /// selection are absorbed (so merging already-merged contributors just extends the group).
1354    /// `name` sets the canonical display name; when `None`, the first email is used. A merge of
1355    /// fewer than two distinct emails is a no-op.
1356    pub fn merge(&mut self, emails: &[String], name: Option<&str>) {
1357        let mut members: Vec<String> = emails
1358            .iter()
1359            .map(|e| e.trim().to_lowercase())
1360            .filter(|e| !e.is_empty())
1361            .collect();
1362        members.sort();
1363        members.dedup();
1364        if members.len() < 2 {
1365            return;
1366        }
1367        // Absorb any existing groups that overlap the selection.
1368        let mut absorbed: Vec<String> = Vec::new();
1369        self.groups.retain(|g| {
1370            if g.members.iter().any(|m| members.contains(m)) {
1371                absorbed.extend(g.members.iter().cloned());
1372                false
1373            } else {
1374                true
1375            }
1376        });
1377        members.extend(absorbed);
1378        members.sort();
1379        members.dedup();
1380        let canonical_email = members[0].clone();
1381        let canonical_name = name
1382            .map(str::trim)
1383            .filter(|s| !s.is_empty())
1384            .map_or_else(|| canonical_email.clone(), ToString::to_string);
1385        self.groups.push(AuthorMergeGroup {
1386            canonical_name,
1387            canonical_email,
1388            members,
1389        });
1390    }
1391
1392    /// Remove the group whose `canonical_email` matches (splitting it back into its originals).
1393    pub fn unmerge(&mut self, canonical_email: &str) {
1394        let key = canonical_email.trim().to_lowercase();
1395        self.groups
1396            .retain(|g| g.canonical_email.to_lowercase() != key);
1397    }
1398
1399    /// Render the map as a git `.mailmap` file so the merges round-trip into git itself
1400    /// (`git shortlog`, `git blame --mailmap`, and future oxide-sloc scans all honour it).
1401    #[must_use]
1402    pub fn to_mailmap(&self) -> String {
1403        let mut out = String::from(
1404            "# Generated by oxide-sloc — maps alternate author emails to a canonical identity.\n",
1405        );
1406        for g in &self.groups {
1407            for member in &g.members {
1408                if *member == g.canonical_email.to_lowercase() {
1409                    continue;
1410                }
1411                out.push_str(&format!(
1412                    "{} <{}> <{}>\n",
1413                    g.canonical_name, g.canonical_email, member
1414                ));
1415            }
1416        }
1417        out
1418    }
1419}
1420
1421/// Fold a run's authors according to `map`, in place: contributors whose email belongs to the
1422/// same merge group are combined (counts summed, aliases merged, per-file ownership remapped and
1423/// re-aggregated). Authors are re-sorted by code lines owned and re-indexed. A no-op when the map
1424/// is empty or the run has no authors.
1425pub fn apply_identity_map(run: &mut AnalysisRun, map: &IdentityMap) {
1426    if map.groups.is_empty() || run.authors.is_empty() {
1427        return;
1428    }
1429    let resolved = resolve_merge_keys(&run.authors, map);
1430    let (merged, old_to_new) = merge_authors(&run.authors, &resolved);
1431    fold_file_ownership(&mut run.per_file_records, &old_to_new);
1432    sort_and_reindex_authors(run, merged);
1433}
1434
1435/// Resolve each existing author to a `(merge_key, canonical_name, canonical_email)` triple.
1436/// The key is the group's canonical email (lower-cased) when the author belongs to a merge group,
1437/// otherwise the author's own lower-cased email — so authors sharing a key collapse together.
1438fn resolve_merge_keys(authors: &[Author], map: &IdentityMap) -> Vec<(String, String, String)> {
1439    authors
1440        .iter()
1441        .map(|a| {
1442            map.group_for(&a.canonical_email).map_or_else(
1443                || {
1444                    (
1445                        a.canonical_email.to_lowercase(),
1446                        a.canonical_name.clone(),
1447                        a.canonical_email.clone(),
1448                    )
1449                },
1450                |g| {
1451                    (
1452                        g.canonical_email.to_lowercase(),
1453                        g.canonical_name.clone(),
1454                        g.canonical_email.clone(),
1455                    )
1456                },
1457            )
1458        })
1459        .collect()
1460}
1461
1462/// Group old author indices by merge key (first-seen order), summing line counts and unioning
1463/// aliases into one `Author` per key. Returns the merged authors plus an `old_index -> new_id`
1464/// map used to remap per-file ownership.
1465fn merge_authors(
1466    authors: &[Author],
1467    resolved: &[(String, String, String)],
1468) -> (Vec<Author>, Vec<u32>) {
1469    let mut key_to_new: HashMap<String, u32> = HashMap::new();
1470    let mut merged: Vec<Author> = Vec::new();
1471    let mut old_to_new: Vec<u32> = vec![0; authors.len()];
1472    for (old_idx, (key, name, email)) in resolved.iter().enumerate() {
1473        let new_id = *key_to_new.entry(key.clone()).or_insert_with(|| {
1474            let id = merged.len() as u32;
1475            merged.push(Author {
1476                id,
1477                canonical_name: name.clone(),
1478                canonical_email: email.clone(),
1479                aliases: Vec::new(),
1480                counts: AuthorLineCounts::default(),
1481            });
1482            id
1483        });
1484        old_to_new[old_idx] = new_id;
1485        let src = &authors[old_idx];
1486        let dst = &mut merged[new_id as usize];
1487        dst.counts.add(&src.counts);
1488        for alias in &src.aliases {
1489            if !dst.aliases.contains(alias) {
1490                dst.aliases.push(alias.clone());
1491            }
1492        }
1493    }
1494    (merged, old_to_new)
1495}
1496
1497/// Remap and re-aggregate each file's ownership entries onto the merged author ids, in place.
1498fn fold_file_ownership(records: &mut [FileRecord], old_to_new: &[u32]) {
1499    for rec in records {
1500        if let Some(ownership) = rec.ownership.as_mut() {
1501            let mut by_new: HashMap<u32, AuthorLineCounts> = HashMap::new();
1502            for entry in ownership.iter() {
1503                let new_id = old_to_new[entry.author_id as usize];
1504                by_new.entry(new_id).or_default().add(&entry.counts);
1505            }
1506            let mut folded: Vec<FileOwnership> = by_new
1507                .into_iter()
1508                .map(|(author_id, counts)| FileOwnership { author_id, counts })
1509                .collect();
1510            folded.sort_by_key(|e| std::cmp::Reverse(e.counts.total_lines));
1511            *ownership = folded;
1512        }
1513    }
1514}
1515
1516/// Sort merged authors by code lines owned (name as tiebreak), re-index them from 0, remap the
1517/// per-file ownership ids to match, and store the ordered list on `run.authors`.
1518fn sort_and_reindex_authors(run: &mut AnalysisRun, merged: Vec<Author>) {
1519    let mut order: Vec<usize> = (0..merged.len()).collect();
1520    order.sort_by(|&a, &b| {
1521        merged[b]
1522            .counts
1523            .code_lines
1524            .cmp(&merged[a].counts.code_lines)
1525            .then_with(|| merged[a].canonical_name.cmp(&merged[b].canonical_name))
1526    });
1527    let mut remap = vec![0u32; merged.len()];
1528    for (new_id, &old) in order.iter().enumerate() {
1529        remap[old] = new_id as u32;
1530    }
1531    for rec in &mut run.per_file_records {
1532        if let Some(ownership) = rec.ownership.as_mut() {
1533            for entry in ownership.iter_mut() {
1534                entry.author_id = remap[entry.author_id as usize];
1535            }
1536        }
1537    }
1538    let mut sorted: Vec<Author> = order.iter().map(|&old| merged[old].clone()).collect();
1539    for (new_id, author) in sorted.iter_mut().enumerate() {
1540        author.id = new_id as u32;
1541    }
1542    run.authors = sorted;
1543}
1544
1545/// True for a GitHub per-user no-reply commit address
1546/// (`login@users.noreply.github.com` or `ID+login@users.noreply.github.com`).
1547fn is_github_noreply(email: &str) -> bool {
1548    email
1549        .trim()
1550        .to_lowercase()
1551        .ends_with("users.noreply.github.com")
1552}
1553
1554/// Fold each GitHub `users.noreply.github.com` identity into a real-email identity of the same
1555/// person, matched by display name, keeping the real email as canonical. Repos routinely carry
1556/// both a contributor's private-email GitHub address and their real email, splitting one person
1557/// across two rows; this collapses them so the ownership view shows a single identity. Matching is
1558/// by normalized display name, so distinct bots/people (e.g. `copilot-swe-agent[bot]`) never
1559/// collapse into an unrelated author. Runs automatically at scan time and is a no-op when nothing
1560/// matches — the operator-driven [`apply_identity_map`] still handles arbitrary cross-account
1561/// merges on top.
1562///
1563/// Public so surfaces that load a previously-serialized run (scanned before this pass existed) can
1564/// apply it at render time without a re-scan; it is idempotent, so re-running on an already-merged
1565/// run is a no-op.
1566pub fn auto_merge_noreply_identities(run: &mut AnalysisRun) {
1567    if run.authors.len() < 2 {
1568        return;
1569    }
1570    // Real-email identity per normalized name. Authors arrive sorted by code lines owned, so the
1571    // first match is the dominant identity — the right merge target and canonical email/name.
1572    let mut real_by_name: HashMap<String, (String, String)> = HashMap::new();
1573    for a in &run.authors {
1574        if !is_github_noreply(&a.canonical_email) {
1575            real_by_name
1576                .entry(a.canonical_name.trim().to_lowercase())
1577                .or_insert_with(|| (a.canonical_email.clone(), a.canonical_name.clone()));
1578        }
1579    }
1580    if real_by_name.is_empty() {
1581        return;
1582    }
1583    let resolved: Vec<(String, String, String)> = run
1584        .authors
1585        .iter()
1586        .map(|a| {
1587            if is_github_noreply(&a.canonical_email)
1588                && let Some((email, name)) =
1589                    real_by_name.get(&a.canonical_name.trim().to_lowercase())
1590            {
1591                (email.to_lowercase(), name.clone(), email.clone())
1592            } else {
1593                (
1594                    a.canonical_email.to_lowercase(),
1595                    a.canonical_name.clone(),
1596                    a.canonical_email.clone(),
1597                )
1598            }
1599        })
1600        .collect();
1601    let (merged, old_to_new) = merge_authors(&run.authors, &resolved);
1602    if merged.len() == run.authors.len() {
1603        return; // nothing folded
1604    }
1605    fold_file_ownership(&mut run.per_file_records, &old_to_new);
1606    sort_and_reindex_authors(run, merged);
1607}
1608
1609/// Return the name of the CI system if the process is running inside one.
1610fn detect_ci_system() -> Option<&'static str> {
1611    let ev = |k: &str| std::env::var(k).is_ok();
1612    let ev_true = |k: &str| std::env::var(k).as_deref() == Ok("true");
1613    if ev("JENKINS_URL") || ev("JENKINS_HOME") || ev("BUILD_URL") {
1614        return Some("Jenkins");
1615    }
1616    if ev_true("GITHUB_ACTIONS") {
1617        return Some("GitHub Actions");
1618    }
1619    if ev_true("GITLAB_CI") {
1620        return Some("GitLab CI");
1621    }
1622    if ev_true("CIRCLECI") {
1623        return Some("CircleCI");
1624    }
1625    if ev_true("TRAVIS") {
1626        return Some("Travis CI");
1627    }
1628    if ev_true("TF_BUILD") {
1629        return Some("Azure DevOps");
1630    }
1631    if ev("TEAMCITY_VERSION") {
1632        return Some("TeamCity");
1633    }
1634    None
1635}
1636
1637/// Read the current branch name from well-known CI environment variables.
1638/// Called as a fallback when the git HEAD is detached (common in CI checkouts).
1639fn ci_branch_from_env() -> Option<String> {
1640    const VARS: &[&str] = &[
1641        "BRANCH_NAME",        // Jenkins Pipeline
1642        "GIT_BRANCH",         // Jenkins Freestyle (may carry "origin/<branch>")
1643        "GITHUB_REF_NAME",    // GitHub Actions
1644        "CI_COMMIT_BRANCH",   // GitLab CI
1645        "CIRCLE_BRANCH",      // CircleCI
1646        "TRAVIS_BRANCH",      // Travis CI
1647        "BUILD_SOURCEBRANCH", // Azure DevOps (may carry "refs/heads/<branch>")
1648    ];
1649    for &var in VARS {
1650        if let Ok(val) = std::env::var(var) {
1651            let val = val.trim();
1652            let val = val
1653                .strip_prefix("refs/heads/")
1654                .or_else(|| val.strip_prefix("origin/"))
1655                .unwrap_or(val);
1656            if !val.is_empty() && val != "HEAD" {
1657                return Some(val.to_string());
1658            }
1659        }
1660    }
1661    None
1662}
1663
1664fn get_current_username() -> String {
1665    std::env::var("USERNAME")
1666        .or_else(|_| std::env::var("USER"))
1667        .unwrap_or_else(|_| "unknown".to_string())
1668}
1669
1670fn non_empty_env(var: &str) -> Option<String> {
1671    let v = std::env::var(var).ok()?;
1672    if v.is_empty() { None } else { Some(v) }
1673}
1674
1675fn is_jenkins_env() -> bool {
1676    std::env::var("JENKINS_URL").is_ok()
1677        || std::env::var("JENKINS_HOME").is_ok()
1678        || std::env::var("BUILD_URL").is_ok()
1679}
1680
1681fn get_hostname() -> String {
1682    // In CI environments prefer a human-readable agent/runner identifier over
1683    // whatever hostname the container was assigned.
1684    if is_jenkins_env()
1685        && let Some(n) = non_empty_env("NODE_NAME")
1686    {
1687        return n;
1688    }
1689    if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true")
1690        && let Some(r) = non_empty_env("RUNNER_NAME")
1691    {
1692        return r;
1693    }
1694    if std::env::var("GITLAB_CI").as_deref() == Ok("true")
1695        && let Some(r) = non_empty_env("CI_RUNNER_DESCRIPTION")
1696    {
1697        return r;
1698    }
1699    std::env::var("COMPUTERNAME")
1700        .or_else(|_| std::env::var("HOSTNAME"))
1701        .or_else(|_| std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()))
1702        .unwrap_or_else(|_| "unknown".to_string())
1703}
1704
1705/// Walk a single directory root and collect file records into the output vectors.
1706#[allow(clippy::too_many_arguments)]
1707fn walk_root(
1708    root: &Path,
1709    config: &AppConfig,
1710    include_globs: Option<&GlobSet>,
1711    exclude_globs: Option<&GlobSet>,
1712    enabled_languages: Option<&BTreeSet<Language>>,
1713    seen_paths: &mut HashSet<PathBuf>,
1714    analyzed: &mut Vec<FileRecord>,
1715    skipped: &mut Vec<FileRecord>,
1716    warnings: &mut Vec<String>,
1717    cancel: Option<&AtomicBool>,
1718    progress: Option<&ProgressCounters>,
1719) -> Result<()> {
1720    let mut builder = WalkBuilder::new(root);
1721    builder
1722        .follow_links(config.discovery.follow_symlinks)
1723        .hidden(config.discovery.ignore_hidden_files)
1724        .ignore(config.discovery.honor_ignore_files)
1725        .parents(config.discovery.honor_ignore_files)
1726        .git_ignore(config.discovery.honor_ignore_files)
1727        .git_global(config.discovery.honor_ignore_files)
1728        .git_exclude(config.discovery.honor_ignore_files);
1729
1730    let paths = collect_walk_paths(&builder, seen_paths, warnings);
1731    if paths.is_empty() {
1732        return Ok(());
1733    }
1734
1735    if let Some(p) = progress {
1736        p.files_total.fetch_add(paths.len(), Ordering::Relaxed);
1737    }
1738
1739    let chunk_results = run_parallel_analysis(
1740        &paths,
1741        root,
1742        config,
1743        include_globs,
1744        exclude_globs,
1745        enabled_languages,
1746        cancel,
1747        progress,
1748    )?;
1749    merge_chunk_results(chunk_results, analyzed, skipped, warnings)
1750}
1751
1752fn collect_walk_paths(
1753    builder: &WalkBuilder,
1754    seen_paths: &mut HashSet<PathBuf>,
1755    warnings: &mut Vec<String>,
1756) -> Vec<PathBuf> {
1757    // build_parallel() walks the directory tree across multiple threads (work-stealing
1758    // internally), which is meaningfully faster for deeply nested repos with many directories.
1759    // We collect results via an MPSC channel so each walker thread sends without contention.
1760    let (tx, rx) = std::sync::mpsc::channel::<std::result::Result<PathBuf, String>>();
1761
1762    builder.build_parallel().run(|| {
1763        let tx = tx.clone();
1764        Box::new(move |entry| {
1765            match entry {
1766                Err(e) => {
1767                    let _ = tx.send(Err(format!("discovery warning: {e}")));
1768                }
1769                Ok(e) => {
1770                    let path = e.into_path();
1771                    if !path.is_dir() {
1772                        let _ = tx.send(Ok(path));
1773                    }
1774                }
1775            }
1776            ignore::WalkState::Continue
1777        })
1778    });
1779
1780    // Drop the sender that the outer scope holds; the per-thread clones were dropped when
1781    // run() returned (all threads finished). Dropping this last sender closes the channel.
1782    drop(tx);
1783
1784    rx.into_iter()
1785        .filter_map(|msg| match msg {
1786            Ok(path) => {
1787                if seen_paths.insert(path.clone()) {
1788                    Some(path)
1789                } else {
1790                    None
1791                }
1792            }
1793            Err(warn) => {
1794                warnings.push(warn);
1795                None
1796            }
1797        })
1798        .collect()
1799}
1800
1801/// Inner work loop executed by each analysis thread.
1802#[allow(clippy::too_many_arguments)]
1803fn worker_loop(
1804    paths: &[PathBuf],
1805    root: &Path,
1806    config: &AppConfig,
1807    include_globs: Option<&GlobSet>,
1808    exclude_globs: Option<&GlobSet>,
1809    enabled_languages: Option<&BTreeSet<Language>>,
1810    cancel: Option<&AtomicBool>,
1811    next_index: &AtomicUsize,
1812    files_done: Option<&AtomicUsize>,
1813) -> Vec<Result<Option<FileRecord>>> {
1814    let mut results = Vec::new();
1815    loop {
1816        if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1817            results.push(Err(anyhow::anyhow!("analysis cancelled")));
1818            break;
1819        }
1820        let i = next_index.fetch_add(1, Ordering::Relaxed);
1821        if i >= paths.len() {
1822            break;
1823        }
1824        results.push(analyze_candidate_file(
1825            &paths[i],
1826            root,
1827            config,
1828            include_globs,
1829            exclude_globs,
1830            enabled_languages,
1831        ));
1832        if let Some(fd) = files_done {
1833            fd.fetch_add(1, Ordering::Relaxed);
1834        }
1835    }
1836    results
1837}
1838
1839#[allow(clippy::too_many_arguments)]
1840fn run_parallel_analysis(
1841    paths: &[PathBuf],
1842    root: &Path,
1843    config: &AppConfig,
1844    include_globs: Option<&GlobSet>,
1845    exclude_globs: Option<&GlobSet>,
1846    enabled_languages: Option<&BTreeSet<Language>>,
1847    cancel: Option<&AtomicBool>,
1848    progress: Option<&ProgressCounters>,
1849) -> Result<Vec<Vec<Result<Option<FileRecord>>>>> {
1850    let thread_count = std::thread::available_parallelism().map_or(DEFAULT_ANALYSIS_THREADS, |n| {
1851        n.get().min(MAX_ANALYSIS_THREADS)
1852    });
1853    // Shared work-queue index: each thread atomically claims the next path to process.
1854    // This eliminates static-chunk load imbalance — threads that finish early immediately
1855    // pick up more work instead of sitting idle while one overloaded chunk finishes.
1856    let next_index = AtomicUsize::new(0);
1857    let files_done: Option<&AtomicUsize> = progress.map(|p| p.files_done.as_ref());
1858
1859    std::thread::scope(|s| -> Result<Vec<Vec<Result<Option<FileRecord>>>>> {
1860        // IMPORTANT: collect ALL handles before joining any of them.
1861        // A lazy spawn-then-join chain would serialize threads one at a time.
1862        let mut handles = Vec::with_capacity(thread_count);
1863        for _ in 0..thread_count {
1864            handles.push(s.spawn(|| {
1865                worker_loop(
1866                    paths,
1867                    root,
1868                    config,
1869                    include_globs,
1870                    exclude_globs,
1871                    enabled_languages,
1872                    cancel,
1873                    &next_index,
1874                    files_done,
1875                )
1876            }));
1877        }
1878        handles
1879            .into_iter()
1880            .map(|h| {
1881                h.join()
1882                    .map_err(|_| anyhow::anyhow!("analysis thread panicked"))
1883            })
1884            .collect()
1885    })
1886}
1887
1888fn merge_chunk_results(
1889    chunk_results: Vec<Vec<Result<Option<FileRecord>>>>,
1890    analyzed: &mut Vec<FileRecord>,
1891    skipped: &mut Vec<FileRecord>,
1892    warnings: &mut Vec<String>,
1893) -> Result<()> {
1894    for chunk in chunk_results {
1895        for result in chunk {
1896            if let Some(record) = result? {
1897                push_record(record, analyzed, skipped, warnings);
1898            }
1899        }
1900    }
1901    Ok(())
1902}
1903
1904/// Label each analyzed file with its submodule and build per-submodule summaries.
1905fn process_submodules(config: &AppConfig, analyzed: &mut [FileRecord]) -> Vec<SubmoduleSummary> {
1906    let root = config.discovery.root_paths[0]
1907        .canonicalize()
1908        .unwrap_or_else(|_| config.discovery.root_paths[0].clone());
1909    let submodules = detect_submodules(&root);
1910    if submodules.is_empty() {
1911        return Vec::new();
1912    }
1913
1914    for file in analyzed.iter_mut() {
1915        for (name, sub_path) in &submodules {
1916            let prefix = sub_path.to_string_lossy().replace('\\', "/");
1917            let rel = &file.relative_path;
1918            if rel == &prefix || rel.starts_with(&format!("{prefix}/")) {
1919                file.submodule = Some(name.clone());
1920                break;
1921            }
1922        }
1923    }
1924
1925    build_submodule_summaries(analyzed, &submodules, &root)
1926}
1927
1928/// Compute Basic COCOMO I cost estimate from total code SLOC.
1929#[allow(clippy::cast_precision_loss)] // COCOMO formula: line counts at f64 precision are sufficient
1930fn compute_cocomo(code_lines: u64, mode: CocomoMode) -> CocomoEstimate {
1931    let ksloc = code_lines as f64 / 1_000.0;
1932    let (a, b, c, d): (f64, f64, f64, f64) = match mode {
1933        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
1934        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
1935        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
1936    };
1937    let effort = a * ksloc.powf(b);
1938    let duration = c * effort.powf(d);
1939    let avg_staff = if duration > 0.0 {
1940        effort / duration
1941    } else {
1942        0.0
1943    };
1944    // Round to 2 decimal places for readability.
1945    CocomoEstimate {
1946        mode,
1947        ksloc: (ksloc * 100.0).round() / 100.0,
1948        effort_person_months: (effort * 100.0).round() / 100.0,
1949        duration_months: (duration * 100.0).round() / 100.0,
1950        avg_staff: (avg_staff * 100.0).round() / 100.0,
1951    }
1952}
1953
1954/// Collect ULOC hashes across all analyzed files, compute ULOC and `DRYness`.
1955#[allow(clippy::cast_precision_loss)] // DRYness is a display percentage; f32 precision is adequate
1956fn compute_uloc(analyzed: &[FileRecord]) -> (u64, Option<f32>) {
1957    use std::collections::HashSet as StdHashSet;
1958    let mut unique: StdHashSet<u64> = StdHashSet::new();
1959    let mut total_code: u64 = 0;
1960    for record in analyzed {
1961        total_code += record.effective_counts.code_lines;
1962        for &hash in &record.raw_line_categories.code_line_hashes {
1963            unique.insert(hash);
1964        }
1965    }
1966    let uloc = unique.len() as u64;
1967    let dryness = if total_code > 0 {
1968        Some((uloc as f32 / total_code as f32) * 100.0)
1969    } else {
1970        None
1971    };
1972    (uloc, dryness)
1973}
1974
1975/// Group files by content hash and return groups of duplicates (relative paths).
1976/// Only groups with ≥ 2 files are returned.
1977fn find_duplicate_groups(analyzed: &[FileRecord]) -> Vec<Vec<String>> {
1978    let mut by_hash: std::collections::HashMap<u64, Vec<&str>> = std::collections::HashMap::new();
1979    for record in analyzed {
1980        if record.content_hash != 0 {
1981            by_hash
1982                .entry(record.content_hash)
1983                .or_default()
1984                .push(&record.relative_path);
1985        }
1986    }
1987    let mut groups: Vec<Vec<String>> = by_hash
1988        .into_values()
1989        .filter(|v| v.len() >= 2)
1990        .map(|v| {
1991            let mut paths: Vec<String> = v.into_iter().map(str::to_owned).collect();
1992            paths.sort();
1993            paths
1994        })
1995        .collect();
1996    groups.sort_by(|a, b| a[0].cmp(&b[0]));
1997    groups
1998}
1999
2000/// Assemble the final `AnalysisRun` from collected records and metadata.
2001// Progress + cancel are threaded in so the long attribution pass can report live progress and
2002// abort promptly; folding them into a struct would add indirection without real clarity.
2003#[allow(clippy::too_many_arguments)]
2004fn assemble_run(
2005    config: &AppConfig,
2006    runtime_mode: &str,
2007    mut analyzed: Vec<FileRecord>,
2008    skipped: Vec<FileRecord>,
2009    warnings: Vec<String>,
2010    submodule_summaries: Vec<SubmoduleSummary>,
2011    progress: Option<&ProgressCounters>,
2012    cancel: Option<&AtomicBool>,
2013) -> AnalysisRun {
2014    set_progress_phase(progress, "Computing metrics");
2015    let summary = build_summary(&analyzed, &skipped);
2016    let language_summaries = build_language_summaries(&analyzed);
2017    let col_threshold = config.analysis.style_col_threshold;
2018    let style_summary = build_style_summary(&analyzed, col_threshold);
2019
2020    // Compute ULOC, DRYness, duplicates, and COCOMO from the aggregated records.
2021    let (uloc, dryness_pct) = compute_uloc(&analyzed);
2022    let duplicate_groups = find_duplicate_groups(&analyzed);
2023    let cocomo = if summary.code_lines > 0 {
2024        Some(compute_cocomo(summary.code_lines, CocomoMode::Organic))
2025    } else {
2026        None
2027    };
2028
2029    let first_root = config
2030        .discovery
2031        .root_paths
2032        .first()
2033        .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()));
2034    let git = first_root
2035        .as_deref()
2036        .map(detect_git_for_run)
2037        .unwrap_or_default();
2038
2039    // Per-file git activity for the hotspots view (on by default, single `git log` pass,
2040    // best-effort). A window of 0 (or None) disables it; a non-git path yields an empty result.
2041    let activity_window = config.analysis.activity_window_days.unwrap_or(0);
2042    if let (true, Some(root)) = (activity_window > 0, first_root.as_deref()) {
2043        set_progress_phase(progress, "Reading git history");
2044        apply_file_activity(root, activity_window, &mut analyzed);
2045    }
2046
2047    // Per-author code-ownership attribution (opt-in: one `git blame` per file). Best-effort —
2048    // a non-git path or blame failure yields an empty author list and leaves records untouched.
2049    let authors = if config.analysis.attribution {
2050        first_root
2051            .as_deref()
2052            .map(|root| attribute_ownership(root, &mut analyzed, progress, cancel))
2053            .unwrap_or_default()
2054    } else {
2055        Vec::new()
2056    };
2057
2058    let now = Utc::now();
2059    let run_id = {
2060        let uuid_suffix = Uuid::new_v4().simple().to_string();
2061        format!("{}-{}", now.format("%Y%m%d-%H%M"), uuid_suffix)
2062    };
2063
2064    let mut run = AnalysisRun {
2065        tool: ToolMetadata {
2066            name: "sloc".into(),
2067            version: env!("CARGO_PKG_VERSION").into(),
2068            run_id,
2069            timestamp_utc: now,
2070        },
2071        environment: EnvironmentMetadata {
2072            operating_system: std::env::consts::OS.into(),
2073            architecture: std::env::consts::ARCH.into(),
2074            runtime_mode: runtime_mode.into(),
2075            initiator_username: get_current_username(),
2076            initiator_hostname: get_hostname(),
2077            ci_name: if is_jenkins_env() {
2078                Some(format!("Jenkins\t{}", get_hostname()))
2079            } else {
2080                detect_ci_system().map(str::to_string)
2081            },
2082        },
2083        effective_configuration: config.clone(),
2084        input_roots: config
2085            .discovery
2086            .root_paths
2087            .iter()
2088            .map(|p| path_to_string(p))
2089            .collect(),
2090        summary_totals: summary,
2091        totals_by_language: language_summaries,
2092        per_file_records: analyzed,
2093        skipped_file_records: skipped,
2094        warnings,
2095        submodule_summaries,
2096        git_commit_short: git.commit_short,
2097        git_commit_long: git.commit_long,
2098        git_branch: git.branch,
2099        git_commit_author: git.author,
2100        git_tags: git.tags,
2101        git_nearest_tag: git.nearest_tag,
2102        git_commit_date: git.commit_date,
2103        git_remote_url: git.remote_url,
2104        style_summary,
2105        cocomo,
2106        uloc,
2107        dryness_pct,
2108        duplicate_groups,
2109        duplicates_excluded: 0,
2110        authors,
2111    };
2112    // Collapse GitHub no-reply aliases into the same person's real-email identity before the run
2113    // is serialized, so every downstream surface (HTML report, web, JSON, MCP) sees one identity.
2114    auto_merge_noreply_identities(&mut run);
2115    run
2116}
2117
2118/// Attach per-file git activity (commit count + last-change date) for the hotspots view, in place.
2119/// A single `git log` pass over `root`; best-effort — an empty result (non-git path, or no history
2120/// within `window_days`) leaves every record untouched.
2121fn apply_file_activity(root: &Path, window_days: u32, analyzed: &mut [FileRecord]) {
2122    let activity = detect_file_activity(root, window_days);
2123    if activity.is_empty() {
2124        return;
2125    }
2126    for rec in analyzed {
2127        if let Some((count, date)) = activity.get(&rec.relative_path) {
2128            rec.commit_count = Some(*count);
2129            rec.last_commit_date.clone_from(date);
2130        }
2131    }
2132}
2133
2134/// # Errors
2135///
2136/// Returns an error if the config is invalid, root paths cannot be walked, or any file
2137/// analysis step fails in a way that cannot be recovered from.
2138#[allow(clippy::too_many_lines)]
2139pub fn analyze(
2140    config: &AppConfig,
2141    runtime_mode: &str,
2142    cancel: Option<&AtomicBool>,
2143    progress: Option<&ProgressCounters>,
2144) -> Result<AnalysisRun> {
2145    config.validate()?;
2146
2147    if config.discovery.root_paths.is_empty() {
2148        anyhow::bail!("no input paths were provided");
2149    }
2150
2151    let include_globs = compile_globset(&config.discovery.include_globs)?;
2152    let exclude_globs = compile_globset(&config.discovery.exclude_globs)?;
2153    let enabled_languages = parse_enabled_languages(&config.analysis.enabled_languages)?;
2154
2155    let mut analyzed = Vec::new();
2156    let mut skipped = Vec::new();
2157    let mut warnings = Vec::new();
2158    let mut seen_paths = HashSet::new();
2159
2160    for root in &config.discovery.root_paths {
2161        if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
2162            anyhow::bail!("analysis cancelled");
2163        }
2164
2165        let root = root.canonicalize().unwrap_or_else(|_| root.clone());
2166
2167        if root.is_file() {
2168            if let Some(record) = analyze_candidate_file(
2169                &root,
2170                root.parent().unwrap_or_else(|| Path::new(".")),
2171                config,
2172                include_globs.as_ref(),
2173                exclude_globs.as_ref(),
2174                enabled_languages.as_ref(),
2175            )? {
2176                push_record(record, &mut analyzed, &mut skipped, &mut warnings);
2177            }
2178            continue;
2179        }
2180
2181        let layout = detect_repository_layout(&root);
2182        if layout.has_multiple_repos() {
2183            warnings.push(format_multi_repo_warning(&layout));
2184        }
2185
2186        walk_root(
2187            &root,
2188            config,
2189            include_globs.as_ref(),
2190            exclude_globs.as_ref(),
2191            enabled_languages.as_ref(),
2192            &mut seen_paths,
2193            &mut analyzed,
2194            &mut skipped,
2195            &mut warnings,
2196            cancel,
2197            progress,
2198        )?;
2199    }
2200
2201    analyzed.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
2202    skipped.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
2203
2204    // Submodule detection: label each file with its submodule and build per-submodule summaries.
2205    let submodule_summaries = if config.discovery.submodule_breakdown {
2206        set_progress_phase(progress, "Summarizing submodules");
2207        process_submodules(config, &mut analyzed)
2208    } else {
2209        Vec::new()
2210    };
2211
2212    attach_coverage(config, &mut analyzed, &mut warnings);
2213
2214    Ok(assemble_run(
2215        config,
2216        runtime_mode,
2217        analyzed,
2218        skipped,
2219        warnings,
2220        submodule_summaries,
2221        progress,
2222        cancel,
2223    ))
2224}
2225
2226fn attach_coverage(config: &AppConfig, analyzed: &mut [FileRecord], warnings: &mut Vec<String>) {
2227    let Some(cov_path) = coverage::resolve_coverage_file(config.analysis.coverage_file.as_deref())
2228    else {
2229        return;
2230    };
2231    tracing::debug!(path = %cov_path.display(), "loading coverage file");
2232    match fs::read_to_string(&cov_path) {
2233        Ok(content) => {
2234            let cov_map = coverage::parse_coverage_auto(&cov_path, &content);
2235            let mut matched: u32 = 0;
2236            let mut unmatched: u32 = 0;
2237            for record in analyzed.iter_mut() {
2238                record.coverage =
2239                    coverage::lookup_coverage(&cov_map, &record.relative_path).cloned();
2240                if record.coverage.is_some() {
2241                    matched += 1;
2242                } else {
2243                    unmatched += 1;
2244                }
2245            }
2246            tracing::debug!(
2247                path = %cov_path.display(),
2248                coverage_entries = cov_map.len(),
2249                files_matched = matched,
2250                files_unmatched = unmatched,
2251                "coverage attached"
2252            );
2253            if unmatched > 0 && matched == 0 {
2254                tracing::warn!(
2255                    path = %cov_path.display(),
2256                    "coverage file loaded but no source files could be matched — check that paths in the coverage report match the scanned directory"
2257                );
2258            }
2259        }
2260        Err(e) => {
2261            tracing::warn!(path = %cov_path.display(), error = %e, "coverage file could not be read");
2262            warnings.push(format!(
2263                "coverage file '{}' could not be read: {e}",
2264                cov_path.display()
2265            ));
2266        }
2267    }
2268}
2269
2270fn push_record(
2271    record: FileRecord,
2272    analyzed: &mut Vec<FileRecord>,
2273    skipped: &mut Vec<FileRecord>,
2274    warnings: &mut Vec<String>,
2275) {
2276    warnings.extend(
2277        record
2278            .warnings
2279            .iter()
2280            .map(|warning| format!("{}: {warning}", record.relative_path)),
2281    );
2282
2283    match record.status {
2284        FileStatus::AnalyzedExact | FileStatus::AnalyzedBestEffort => analyzed.push(record),
2285        _ => skipped.push(record),
2286    }
2287}
2288
2289/// Convenience wrapper: build a boxed `Skip` outcome with a single-item warning message.
2290#[inline]
2291fn skip_with_reason(
2292    path: &Path,
2293    root: &Path,
2294    size: u64,
2295    reason: impl Into<String>,
2296) -> MetadataPolicyOutcome {
2297    MetadataPolicyOutcome::Skip(Box::new(skipped_record(
2298        path,
2299        root,
2300        size,
2301        FileStatus::SkippedByPolicy,
2302        vec![reason.into()],
2303    )))
2304}
2305
2306/// Apply metadata-level policy checks (symlink, name, dir exclusion, size, globs, lockfile).
2307/// Returns `Skip(record)` to skip, `Exclude` to omit from output entirely (include-glob miss),
2308/// or `Continue` to proceed to content checks.
2309#[allow(clippy::too_many_arguments)]
2310fn check_metadata_policy(
2311    path: &Path,
2312    root: &Path,
2313    relative_path: &str,
2314    metadata: &fs::Metadata,
2315    config: &AppConfig,
2316    include_globs: Option<&GlobSet>,
2317    exclude_globs: Option<&GlobSet>,
2318) -> MetadataPolicyOutcome {
2319    let size = metadata.len();
2320
2321    if metadata.file_type().is_symlink() && !config.discovery.follow_symlinks {
2322        return skip_with_reason(path, root, size, "symlink skipped by policy");
2323    }
2324    if file_name_eq(path, ".gitignore") {
2325        return skip_with_reason(path, root, size, ".gitignore is always excluded");
2326    }
2327    if is_excluded_dir_path(path, &config.discovery.excluded_directories) {
2328        return skip_with_reason(path, root, size, "path matched excluded directory setting");
2329    }
2330    if size > config.discovery.max_file_size_bytes {
2331        return skip_with_reason(
2332            path,
2333            root,
2334            size,
2335            format!(
2336                "file exceeded max_file_size_bytes ({})",
2337                config.discovery.max_file_size_bytes
2338            ),
2339        );
2340    }
2341    if let Some(globs) = include_globs
2342        && !globs.is_match(Path::new(relative_path))
2343        && !globs.is_match(path)
2344    {
2345        return MetadataPolicyOutcome::Exclude;
2346    }
2347    if let Some(globs) = exclude_globs
2348        && (globs.is_match(Path::new(relative_path)) || globs.is_match(path))
2349    {
2350        return skip_with_reason(path, root, size, "path matched exclude glob");
2351    }
2352    if is_known_lockfile(path) && !config.analysis.include_lockfiles {
2353        return skip_with_reason(path, root, size, "lockfile skipped by default policy");
2354    }
2355
2356    MetadataPolicyOutcome::Continue
2357}
2358
2359struct ContentPolicyResult {
2360    vendor: bool,
2361    generated: bool,
2362    minified: bool,
2363    skip_record: Option<FileRecord>,
2364}
2365
2366/// Apply content-level policy checks (vendor, generated, minified).
2367/// `skip_record` is `Some` when the file should be skipped.
2368fn check_content_policy(
2369    path: &Path,
2370    root: &Path,
2371    size_bytes: u64,
2372    bytes: &[u8],
2373    config: &AppConfig,
2374) -> ContentPolicyResult {
2375    let vendor = is_vendor_path(path);
2376    if vendor && config.analysis.vendor_directory_detection {
2377        return ContentPolicyResult {
2378            vendor,
2379            generated: false,
2380            minified: false,
2381            skip_record: Some(skipped_record(
2382                path,
2383                root,
2384                size_bytes,
2385                FileStatus::SkippedByPolicy,
2386                vec!["vendor file skipped by policy".into()],
2387            )),
2388        };
2389    }
2390
2391    let generated = config.analysis.generated_file_detection && looks_generated(path, bytes);
2392    if generated {
2393        return ContentPolicyResult {
2394            vendor,
2395            generated,
2396            minified: false,
2397            skip_record: Some(skipped_record(
2398                path,
2399                root,
2400                size_bytes,
2401                FileStatus::SkippedByPolicy,
2402                vec!["generated file skipped by policy".into()],
2403            )),
2404        };
2405    }
2406
2407    let minified = config.analysis.minified_file_detection && looks_minified(path, bytes);
2408    if minified {
2409        return ContentPolicyResult {
2410            vendor,
2411            generated,
2412            minified,
2413            skip_record: Some(skipped_record(
2414                path,
2415                root,
2416                size_bytes,
2417                FileStatus::SkippedByPolicy,
2418                vec!["minified file skipped by policy".into()],
2419            )),
2420        };
2421    }
2422
2423    ContentPolicyResult {
2424        vendor,
2425        generated,
2426        minified,
2427        skip_record: None,
2428    }
2429}
2430
2431/// Decode file bytes to a UTF-8 string, handling binary detection and decode failures.
2432fn decode_file_contents(
2433    path: &Path,
2434    root: &Path,
2435    size_bytes: u64,
2436    bytes: &[u8],
2437    config: &AppConfig,
2438) -> Result<Option<(String, String, Vec<String>)>> {
2439    if is_binary(bytes) {
2440        return match config.analysis.binary_file_behavior {
2441            BinaryFileBehavior::Skip => Ok(None),
2442            BinaryFileBehavior::Fail => {
2443                anyhow::bail!("binary file encountered: {}", path.display())
2444            }
2445        };
2446    }
2447
2448    match decode_bytes(bytes) {
2449        Ok(result) => Ok(Some(result)),
2450        Err(err) => match config.analysis.decode_failure_behavior {
2451            FailureBehavior::WarnSkip => {
2452                // Caller will handle the None as a SkippedDecodeError record.
2453                // We use a sentinel: return Ok(None) but encode the error into a field.
2454                // Instead, propagate as a skipped record via the caller.
2455                let _ = (path, root, size_bytes); // suppress unused warnings
2456                Err(anyhow::anyhow!("__decode_warn__: {err}"))
2457            }
2458            FailureBehavior::Fail => {
2459                anyhow::bail!("decode failure for {}: {err}", path.display())
2460            }
2461        },
2462    }
2463}
2464
2465/// Result of resolving a candidate file's language: either a concrete language or a pre-built
2466/// skipped `FileRecord` (unsupported/undetected, or disabled by the enabled-languages policy).
2467enum LanguageOutcome {
2468    Resolved(Language),
2469    Skip(Box<FileRecord>),
2470}
2471
2472/// Detect the file's language, apply the `.h` C→C++ reclassification, and enforce the
2473/// enabled-languages policy. Returns `LanguageOutcome::Skip` (with the ready-to-return record)
2474/// when the language is undetected/unsupported or disabled by configuration.
2475fn resolve_language(
2476    path: &Path,
2477    root: &Path,
2478    size_bytes: u64,
2479    text: &str,
2480    config: &AppConfig,
2481    enabled_languages: Option<&BTreeSet<Language>>,
2482) -> LanguageOutcome {
2483    let first_line = text.lines().next();
2484    let language = detect_language(
2485        path,
2486        first_line,
2487        &config.analysis.extension_overrides,
2488        config.analysis.shebang_detection,
2489    );
2490
2491    let Some(mut language) = language else {
2492        return LanguageOutcome::Skip(Box::new(skipped_record(
2493            path,
2494            root,
2495            size_bytes,
2496            FileStatus::SkippedUnsupported,
2497            vec!["unsupported or undetected language".into()],
2498        )));
2499    };
2500
2501    // The `.h` extension is ambiguous between C and C++; `detect_language` defaults it to C.
2502    // Reclassify as C++ when the file uses C++-only constructs (namespaces, classes, templates,
2503    // `std::`, …) so class/namespace and class-typed function signatures are counted correctly.
2504    if language == Language::C
2505        && path.extension().and_then(|e| e.to_str()) == Some("h")
2506        && sloc_languages::looks_like_cpp(text)
2507    {
2508        language = Language::Cpp;
2509    }
2510
2511    if let Some(enabled) = enabled_languages
2512        && !enabled.contains(&language)
2513    {
2514        return LanguageOutcome::Skip(Box::new(skipped_record(
2515            path,
2516            root,
2517            size_bytes,
2518            FileStatus::SkippedByPolicy,
2519            vec![format!(
2520                "language {} disabled by configuration",
2521                language.display_name()
2522            )],
2523        )));
2524    }
2525
2526    LanguageOutcome::Resolved(language)
2527}
2528
2529#[allow(clippy::too_many_lines)]
2530fn analyze_candidate_file(
2531    path: &Path,
2532    root: &Path,
2533    config: &AppConfig,
2534    include_globs: Option<&GlobSet>,
2535    exclude_globs: Option<&GlobSet>,
2536    enabled_languages: Option<&BTreeSet<Language>>,
2537) -> Result<Option<FileRecord>> {
2538    let metadata = match fs::symlink_metadata(path) {
2539        Ok(metadata) => metadata,
2540        Err(err) => {
2541            return Ok(Some(skipped_record(
2542                path,
2543                root,
2544                0,
2545                FileStatus::ErrorInternal,
2546                vec![format!("failed to read metadata: {err}")],
2547            )));
2548        }
2549    };
2550
2551    let relative_path = relative_path_string(path, root);
2552
2553    // Metadata-level policy checks.
2554    match check_metadata_policy(
2555        path,
2556        root,
2557        &relative_path,
2558        &metadata,
2559        config,
2560        include_globs,
2561        exclude_globs,
2562    ) {
2563        MetadataPolicyOutcome::Skip(record) => return Ok(Some(*record)),
2564        MetadataPolicyOutcome::Exclude => return Ok(None),
2565        MetadataPolicyOutcome::Continue => {}
2566    }
2567
2568    let bytes = match fs::read(path) {
2569        Ok(bytes) => bytes,
2570        Err(err) => {
2571            return Ok(Some(skipped_record(
2572                path,
2573                root,
2574                metadata.len(),
2575                FileStatus::ErrorInternal,
2576                vec![format!("failed to read file: {err}")],
2577            )));
2578        }
2579    };
2580
2581    // Content-level policy checks (vendor, generated, minified).
2582    let content_policy = check_content_policy(path, root, metadata.len(), &bytes, config);
2583    if let Some(record) = content_policy.skip_record {
2584        return Ok(Some(record));
2585    }
2586    let (vendor, generated, minified) = (
2587        content_policy.vendor,
2588        content_policy.generated,
2589        content_policy.minified,
2590    );
2591
2592    // Decode content, handling binary and decode failures.
2593    let (text, encoding, decode_warnings) =
2594        match decode_file_contents(path, root, metadata.len(), &bytes, config) {
2595            Ok(Some(result)) => result,
2596            Ok(None) => {
2597                return Ok(Some(skipped_record(
2598                    path,
2599                    root,
2600                    metadata.len(),
2601                    FileStatus::SkippedBinary,
2602                    vec!["binary file skipped by default".into()],
2603                )));
2604            }
2605            Err(err) => {
2606                let msg = err.to_string();
2607                if let Some(warn_msg) = msg.strip_prefix("__decode_warn__: ") {
2608                    return Ok(Some(skipped_record(
2609                        path,
2610                        root,
2611                        metadata.len(),
2612                        FileStatus::SkippedDecodeError,
2613                        vec![warn_msg.to_string()],
2614                    )));
2615                }
2616                return Err(err);
2617            }
2618        };
2619
2620    let language =
2621        match resolve_language(path, root, metadata.len(), &text, config, enabled_languages) {
2622            LanguageOutcome::Resolved(language) => language,
2623            LanguageOutcome::Skip(record) => return Ok(Some(*record)),
2624        };
2625
2626    let style_scope = match config.analysis.style_lang_scope.as_str() {
2627        "c_family" => StyleLangScope::CFamilyOnly,
2628        _ => StyleLangScope::All,
2629    };
2630    let ieee_opts = AnalysisOptions {
2631        blank_in_block_comment_as_comment: config.analysis.blank_in_block_comment_policy
2632            == BlankInBlockCommentPolicy::CountAsComment,
2633        collapse_continuation_lines: config.analysis.continuation_line_policy
2634            == ContinuationLinePolicy::CollapseToLogical,
2635        enable_style: config.analysis.style_analysis_enabled,
2636        style_lang_scope: style_scope,
2637    };
2638    let analysis = analyze_text(language, &text, ieee_opts);
2639    let effective_counts = compute_effective_counts(
2640        &analysis.raw,
2641        config.analysis.mixed_line_policy,
2642        config.analysis.python_docstrings_as_comments,
2643        config.analysis.count_compiler_directives,
2644    );
2645
2646    let mut warnings = decode_warnings;
2647    warnings.extend(analysis.warnings.clone());
2648
2649    // Compute a fast 64-bit content fingerprint for duplicate-file detection.
2650    let content_hash = {
2651        use std::hash::{DefaultHasher, Hash, Hasher};
2652        let mut h = DefaultHasher::new();
2653        bytes.hash(&mut h);
2654        h.finish()
2655    };
2656
2657    // Extract fields from analysis.raw before it is moved into FileRecord.
2658    let cyclomatic_complexity = if analysis.raw.cyclomatic_complexity > 0 {
2659        Some(analysis.raw.cyclomatic_complexity)
2660    } else {
2661        None
2662    };
2663    let lsloc = analysis.raw.lsloc;
2664
2665    Ok(Some(FileRecord {
2666        path: path_to_string(path),
2667        relative_path,
2668        language: Some(language),
2669        size_bytes: metadata.len(),
2670        detected_encoding: Some(encoding),
2671        raw_line_categories: analysis.raw,
2672        effective_counts,
2673        status: match analysis.parse_mode {
2674            ParseMode::Lexical | ParseMode::TreeSitter => FileStatus::AnalyzedExact,
2675            ParseMode::LexicalBestEffort => FileStatus::AnalyzedBestEffort,
2676        },
2677        warnings,
2678        generated,
2679        minified,
2680        vendor,
2681        parse_mode: Some(analysis.parse_mode),
2682        submodule: None,
2683        coverage: None,
2684        style_analysis: analysis.style_analysis,
2685        cyclomatic_complexity,
2686        lsloc,
2687        commit_count: None,
2688        last_commit_date: None,
2689        ownership: None,
2690        content_hash,
2691    }))
2692}
2693
2694const fn compute_effective_counts(
2695    raw: &RawLineCounts,
2696    mixed_line_policy: MixedLinePolicy,
2697    python_docstrings_as_comments: bool,
2698    count_compiler_directives: bool,
2699) -> EffectiveCounts {
2700    let mut effective = EffectiveCounts {
2701        code_lines: raw.code_only_lines,
2702        comment_lines: raw.single_comment_only_lines + raw.multi_comment_only_lines,
2703        blank_lines: raw.blank_only_lines,
2704        mixed_lines_separate: 0,
2705    };
2706
2707    if python_docstrings_as_comments {
2708        effective.comment_lines += raw.docstring_comment_lines;
2709    } else {
2710        effective.code_lines += raw.docstring_comment_lines;
2711    }
2712
2713    let mixed_total = raw.mixed_code_single_comment_lines + raw.mixed_code_multi_comment_lines;
2714    match mixed_line_policy {
2715        MixedLinePolicy::CodeOnly => effective.code_lines += mixed_total,
2716        MixedLinePolicy::CodeAndComment => {
2717            effective.code_lines += mixed_total;
2718            effective.comment_lines += mixed_total;
2719        }
2720        MixedLinePolicy::CommentOnly => effective.comment_lines += mixed_total,
2721        MixedLinePolicy::SeparateMixedCategory => effective.mixed_lines_separate += mixed_total,
2722    }
2723
2724    // IEEE 1045-1992 §4.2: optionally exclude preprocessor/compiler directives from code SLOC.
2725    // compiler_directive_lines is a subset of code_only_lines, so subtract it directly.
2726    if !count_compiler_directives {
2727        effective.code_lines = effective
2728            .code_lines
2729            .saturating_sub(raw.compiler_directive_lines);
2730    }
2731
2732    effective
2733}
2734
2735fn build_summary(analyzed: &[FileRecord], skipped: &[FileRecord]) -> SummaryTotals {
2736    let mut summary = SummaryTotals {
2737        files_considered: (analyzed.len() + skipped.len()) as u64,
2738        files_analyzed: analyzed.len() as u64,
2739        files_skipped: skipped.len() as u64,
2740        ..Default::default()
2741    };
2742
2743    for record in analyzed {
2744        summary.total_physical_lines += record.raw_line_categories.total_physical_lines;
2745        summary.code_lines += record.effective_counts.code_lines;
2746        summary.comment_lines += record.effective_counts.comment_lines;
2747        summary.blank_lines += record.effective_counts.blank_lines;
2748        summary.mixed_lines_separate += record.effective_counts.mixed_lines_separate;
2749        summary.functions += record.raw_line_categories.functions;
2750        summary.classes += record.raw_line_categories.classes;
2751        summary.variables += record.raw_line_categories.variables;
2752        summary.variables_member += record.raw_line_categories.variables_member;
2753        summary.variables_local += record.raw_line_categories.variables_local;
2754        summary.variables_global += record.raw_line_categories.variables_global;
2755        summary.macro_definitions += record.raw_line_categories.macro_definitions;
2756        summary.imports += record.raw_line_categories.imports;
2757        summary.test_count += record.raw_line_categories.test_count;
2758        summary.test_assertion_count += record.raw_line_categories.test_assertion_count;
2759        summary.test_suite_count += record.raw_line_categories.test_suite_count;
2760        summary.cyclomatic_complexity +=
2761            u64::from(record.raw_line_categories.cyclomatic_complexity);
2762        if let Some(lsloc) = record.raw_line_categories.lsloc {
2763            *summary.lsloc.get_or_insert(0) += u64::from(lsloc);
2764        }
2765        if let Some(cov) = &record.coverage {
2766            summary.coverage_lines_found += u64::from(cov.lines_found);
2767            summary.coverage_lines_hit += u64::from(cov.lines_hit);
2768            summary.coverage_functions_found += u64::from(cov.functions_found);
2769            summary.coverage_functions_hit += u64::from(cov.functions_hit);
2770            summary.coverage_branches_found += u64::from(cov.branches_found);
2771            summary.coverage_branches_hit += u64::from(cov.branches_hit);
2772        }
2773    }
2774
2775    summary
2776}
2777
2778/// Construct a zero-filled `LanguageSummary` for the given language.
2779const fn zeroed_summary(language: Language) -> LanguageSummary {
2780    LanguageSummary {
2781        language,
2782        files: 0,
2783        total_physical_lines: 0,
2784        code_lines: 0,
2785        comment_lines: 0,
2786        blank_lines: 0,
2787        mixed_lines_separate: 0,
2788        functions: 0,
2789        classes: 0,
2790        variables: 0,
2791        variables_member: 0,
2792        variables_local: 0,
2793        variables_global: 0,
2794        macro_definitions: 0,
2795        imports: 0,
2796        test_count: 0,
2797        test_assertion_count: 0,
2798        test_suite_count: 0,
2799        coverage_lines_found: 0,
2800        coverage_lines_hit: 0,
2801        coverage_functions_found: 0,
2802        coverage_functions_hit: 0,
2803        coverage_branches_found: 0,
2804        coverage_branches_hit: 0,
2805        cyclomatic_complexity: 0,
2806        lsloc: None,
2807    }
2808}
2809
2810/// Accumulate all per-file counters from `record` into an existing `LanguageSummary`.
2811fn accumulate_record_into_summary(entry: &mut LanguageSummary, record: &FileRecord) {
2812    entry.files += 1;
2813    let r = &record.raw_line_categories;
2814    entry.total_physical_lines += r.total_physical_lines;
2815    entry.code_lines += record.effective_counts.code_lines;
2816    entry.comment_lines += record.effective_counts.comment_lines;
2817    entry.blank_lines += record.effective_counts.blank_lines;
2818    entry.mixed_lines_separate += record.effective_counts.mixed_lines_separate;
2819    entry.functions += r.functions;
2820    entry.classes += r.classes;
2821    entry.variables += r.variables;
2822    entry.variables_member += r.variables_member;
2823    entry.variables_local += r.variables_local;
2824    entry.variables_global += r.variables_global;
2825    entry.macro_definitions += r.macro_definitions;
2826    entry.imports += r.imports;
2827    entry.test_count += r.test_count;
2828    entry.test_assertion_count += r.test_assertion_count;
2829    entry.test_suite_count += r.test_suite_count;
2830    entry.cyclomatic_complexity += u64::from(r.cyclomatic_complexity);
2831    if let Some(lsloc) = r.lsloc {
2832        *entry.lsloc.get_or_insert(0) += u64::from(lsloc);
2833    }
2834    if let Some(cov) = &record.coverage {
2835        entry.coverage_lines_found += u64::from(cov.lines_found);
2836        entry.coverage_lines_hit += u64::from(cov.lines_hit);
2837        entry.coverage_functions_found += u64::from(cov.functions_found);
2838        entry.coverage_functions_hit += u64::from(cov.functions_hit);
2839        entry.coverage_branches_found += u64::from(cov.branches_found);
2840        entry.coverage_branches_hit += u64::from(cov.branches_hit);
2841    }
2842}
2843
2844fn build_language_summaries(analyzed: &[FileRecord]) -> Vec<LanguageSummary> {
2845    let mut by_language: BTreeMap<Language, LanguageSummary> = BTreeMap::new();
2846    for record in analyzed {
2847        let Some(language) = record.language else {
2848            continue;
2849        };
2850        let entry = by_language
2851            .entry(language)
2852            .or_insert_with(|| zeroed_summary(language));
2853        accumulate_record_into_summary(entry, record);
2854    }
2855    by_language.into_values().collect()
2856}
2857
2858fn skipped_record(
2859    path: &Path,
2860    root: &Path,
2861    size_bytes: u64,
2862    status: FileStatus,
2863    warnings: Vec<String>,
2864) -> FileRecord {
2865    FileRecord {
2866        path: path_to_string(path),
2867        relative_path: relative_path_string(path, root),
2868        language: None,
2869        size_bytes,
2870        detected_encoding: None,
2871        raw_line_categories: RawLineCounts::default(),
2872        effective_counts: EffectiveCounts::default(),
2873        status,
2874        warnings,
2875        generated: false,
2876        minified: false,
2877        vendor: false,
2878        parse_mode: None,
2879        submodule: None,
2880        coverage: None,
2881        style_analysis: None,
2882        cyclomatic_complexity: None,
2883        lsloc: None,
2884        commit_count: None,
2885        last_commit_date: None,
2886        ownership: None,
2887        content_hash: 0,
2888    }
2889}
2890
2891/// Normalize a raw lossy path string: strip any Windows verbatim / extended-length prefix,
2892/// then convert backslashes to forward slashes.
2893///
2894/// `std::fs::canonicalize()` on Windows returns extended-length paths that begin with
2895/// `\\?\` (or `\\?\UNC\` for UNC shares). Once backslashes are normalized to forward
2896/// slashes this would otherwise leak into report output as `//?/C:/...`, which looks
2897/// broken. This operates purely on the raw string, so it behaves identically on every
2898/// platform (the tests run on Linux too).
2899///
2900/// - `\\?\C:\foo`            -> `C:/foo`
2901/// - `\\?\UNC\server\share`  -> `//server/share`
2902/// - anything else           -> backslashes replaced with slashes, otherwise unchanged
2903fn normalize_path_str(raw: &str) -> String {
2904    if let Some(unc) = raw.strip_prefix(r"\\?\UNC\") {
2905        // `\\?\UNC\server\share\...` denotes `\\server\share\...` -> `//server/share/...`
2906        format!("//{}", unc.replace('\\', "/"))
2907    } else if let Some(rest) = raw.strip_prefix(r"\\?\") {
2908        rest.replace('\\', "/")
2909    } else {
2910        raw.replace('\\', "/")
2911    }
2912}
2913
2914fn relative_path_string(path: &Path, root: &Path) -> String {
2915    normalize_path_str(&path.strip_prefix(root).unwrap_or(path).to_string_lossy())
2916}
2917
2918fn path_to_string(path: &Path) -> String {
2919    normalize_path_str(&path.to_string_lossy())
2920}
2921
2922/// Summary of the git-repository shape under a selected scan root.
2923///
2924/// Used to warn when a user points oxide-sloc at a folder that holds several
2925/// *independent* repositories (e.g. a `projects/` directory of separate clones),
2926/// which silently conflates unrelated codebases and their git metrics. A single
2927/// repository that contains git *submodules* is legitimate and does not count as
2928/// "multiple repos".
2929#[derive(Debug, Clone, Default)]
2930pub struct RepositoryLayout {
2931    /// The scan root this layout was computed for.
2932    pub root: PathBuf,
2933    /// `true` when the root directory is itself the top of a git repository.
2934    pub root_is_repo: bool,
2935    /// Submodule paths declared in the root's `.gitmodules`, relative to `root`.
2936    pub submodule_paths: Vec<PathBuf>,
2937    /// Independent (non-submodule) repositories found beneath `root`, relative to `root`.
2938    pub nested_repos: Vec<PathBuf>,
2939}
2940
2941impl RepositoryLayout {
2942    /// `true` when the selection spans more than one independent repository.
2943    ///
2944    /// If the root is itself a repo, any nested non-submodule repo is a foreign
2945    /// checkout vendored inside it. If the root is not a repo, two or more child
2946    /// repos means the user picked a parent-of-repos folder.
2947    #[must_use]
2948    pub const fn has_multiple_repos(&self) -> bool {
2949        if self.root_is_repo {
2950            !self.nested_repos.is_empty()
2951        } else {
2952            self.nested_repos.len() >= 2
2953        }
2954    }
2955}
2956
2957/// Depth (below the root) at which the nested-repo scan stops descending.
2958const REPO_SCAN_MAX_DEPTH: usize = 6;
2959/// Upper bound on directories visited by the nested-repo scan; a partial result
2960/// is acceptable for a best-effort caution.
2961const REPO_SCAN_MAX_DIRS: usize = 4000;
2962
2963/// Inspect `root` for independent git repositories nested beneath it.
2964///
2965/// Performs a bounded, prune-on-first-`.git` walk: once a directory is found to
2966/// be a repository (or a declared submodule) the scan does not descend into it,
2967/// so a repo's own submodules never register as independent repos. Best-effort
2968/// and infallible — IO errors are swallowed and simply yield a smaller result.
2969#[must_use]
2970pub fn detect_repository_layout(root: &Path) -> RepositoryLayout {
2971    let mut layout = RepositoryLayout {
2972        root: root.to_path_buf(),
2973        root_is_repo: is_git_root(root),
2974        submodule_paths: detect_submodules(root)
2975            .into_iter()
2976            .map(|(_, path)| path)
2977            .collect(),
2978        nested_repos: Vec::new(),
2979    };
2980
2981    // Absolute paths of declared submodules, so we can prune them from the walk.
2982    let submodule_dirs: HashSet<PathBuf> = layout
2983        .submodule_paths
2984        .iter()
2985        .map(|rel| root.join(rel))
2986        .collect();
2987
2988    // Stack of (dir, depth) to visit; start with the root's children (depth 1).
2989    let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
2990    let mut visited = 0usize;
2991
2992    while let Some((dir, depth)) = stack.pop() {
2993        if visited >= REPO_SCAN_MAX_DIRS {
2994            break;
2995        }
2996        let Ok(entries) = fs::read_dir(&dir) else {
2997            continue;
2998        };
2999        for entry in entries.flatten() {
3000            let child = entry.path();
3001            // Skip non-directories and any `.git` directory without counting them.
3002            if !child.is_dir() || child.file_name().and_then(|n| n.to_str()) == Some(".git") {
3003                continue;
3004            }
3005            visited += 1;
3006            match classify_child(&child, &submodule_dirs, root) {
3007                ChildAction::RecordRepo(rel) => layout.nested_repos.push(rel),
3008                ChildAction::Recurse if depth + 1 < REPO_SCAN_MAX_DEPTH => {
3009                    stack.push((child, depth + 1));
3010                }
3011                ChildAction::Skip | ChildAction::Recurse => {}
3012            }
3013        }
3014    }
3015
3016    layout.nested_repos.sort();
3017    layout
3018}
3019
3020/// What the nested-repo walk should do with one candidate child directory.
3021enum ChildAction {
3022    /// Prune without recording (a declared submodule — not an independent repo).
3023    Skip,
3024    /// A nested independent repository at this root-relative path; record it and do not recurse.
3025    RecordRepo(PathBuf),
3026    /// Not a repo boundary — descend into it (subject to the depth bound).
3027    Recurse,
3028}
3029
3030/// Classify one child directory during the nested-repo walk. Callers have already excluded
3031/// non-directories and `.git` directories.
3032fn classify_child(child: &Path, submodule_dirs: &HashSet<PathBuf>, root: &Path) -> ChildAction {
3033    if submodule_dirs.contains(child) {
3034        ChildAction::Skip
3035    } else if is_git_root(child) {
3036        ChildAction::RecordRepo(relative_path_buf(child, root))
3037    } else {
3038        ChildAction::Recurse
3039    }
3040}
3041
3042/// Path of `path` relative to `root` (falling back to `path` itself), as a `PathBuf`.
3043fn relative_path_buf(path: &Path, root: &Path) -> PathBuf {
3044    path.strip_prefix(root).unwrap_or(path).to_path_buf()
3045}
3046
3047/// Build the human-readable warning for a multi-repository selection.
3048fn format_multi_repo_warning(layout: &RepositoryLayout) -> String {
3049    const MAX_LISTED: usize = 5;
3050    let total = layout.nested_repos.len();
3051    let listed: Vec<String> = layout
3052        .nested_repos
3053        .iter()
3054        .take(MAX_LISTED)
3055        .map(|p| path_to_string(p))
3056        .collect();
3057    let mut joined = listed.join(", ");
3058    if total > MAX_LISTED {
3059        use std::fmt::Write as _;
3060        let _ = write!(joined, ", … and {} more", total - MAX_LISTED);
3061    }
3062    if layout.root_is_repo {
3063        format!(
3064            "This repository contains {total} nested git {} ({joined}) that are not registered \
3065             submodules. Their files are being counted as part of this project; if that is not \
3066             intended, exclude them or scan each repository separately.",
3067            if total == 1 {
3068                "repository"
3069            } else {
3070                "repositories"
3071            }
3072        )
3073    } else {
3074        format!(
3075            "The selected folder contains {total} independent git repositories ({joined}). \
3076             oxide-sloc analyzes one repository at a time — git metrics and totals are only \
3077             meaningful when the root is a single repository. Select one repository as the root \
3078             (submodules are fine).",
3079        )
3080    }
3081}
3082
3083/// Parse `.gitmodules` in `root` and return `(name, relative_path)` for each submodule found.
3084#[must_use]
3085pub fn detect_submodules(root: &Path) -> Vec<(String, PathBuf)> {
3086    let gitmodules = root.join(".gitmodules");
3087    if !gitmodules.is_file() {
3088        return Vec::new();
3089    }
3090    let Ok(content) = fs::read_to_string(&gitmodules) else {
3091        return Vec::new();
3092    };
3093
3094    let mut result = Vec::new();
3095    let mut current_name: Option<String> = None;
3096    let mut current_path: Option<PathBuf> = None;
3097
3098    for line in content.lines() {
3099        let trimmed = line.trim();
3100        if trimmed.starts_with("[submodule \"") && trimmed.ends_with("\"]") {
3101            if let (Some(name), Some(path)) = (current_name.take(), current_path.take()) {
3102                result.push((name, path));
3103            }
3104            let name = trimmed["[submodule \"".len()..trimmed.len() - 2].to_string();
3105            current_name = Some(name);
3106        } else if let Some(rest) = trimmed.strip_prefix("path")
3107            && let Some(eq_pos) = rest.find('=')
3108        {
3109            let path_str = rest[eq_pos + 1..].trim();
3110            current_path = Some(PathBuf::from(path_str));
3111        }
3112    }
3113    if let (Some(name), Some(path)) = (current_name, current_path) {
3114        result.push((name, path));
3115    }
3116
3117    result
3118}
3119
3120fn build_submodule_summaries(
3121    analyzed: &[FileRecord],
3122    submodules: &[(String, PathBuf)],
3123    root: &Path,
3124) -> Vec<SubmoduleSummary> {
3125    // Detect each submodule's git metadata concurrently: every call spawns several git
3126    // subprocesses (author, date, tags, describe), and on Windows process spawn is slow — running
3127    // a dozen-plus submodules sequentially added seconds of dead time to the "Summarizing
3128    // submodules" stage. The file aggregation below is cheap and stays sequential.
3129    let git_infos = parallel_submodule_git(submodules, root);
3130
3131    submodules
3132        .iter()
3133        .zip(git_infos)
3134        .map(|((name, path), git)| {
3135            let files: Vec<&FileRecord> = analyzed
3136                .iter()
3137                .filter(|f| f.submodule.as_deref() == Some(name.as_str()))
3138                .collect();
3139
3140            let files_analyzed = files.len() as u64;
3141            let total_physical_lines = files
3142                .iter()
3143                .map(|f| f.raw_line_categories.total_physical_lines)
3144                .sum();
3145            let code_lines = files.iter().map(|f| f.effective_counts.code_lines).sum();
3146            let comment_lines = files.iter().map(|f| f.effective_counts.comment_lines).sum();
3147            let blank_lines = files.iter().map(|f| f.effective_counts.blank_lines).sum();
3148            let language_summaries = build_language_summaries_from_slice(&files);
3149
3150            SubmoduleSummary {
3151                name: name.clone(),
3152                relative_path: path.to_string_lossy().replace('\\', "/"),
3153                files_analyzed,
3154                total_physical_lines,
3155                code_lines,
3156                comment_lines,
3157                blank_lines,
3158                language_summaries,
3159                git_commit_short: git.commit_short,
3160                git_commit_long: git.commit_long,
3161                git_branch: git.branch,
3162                git_commit_author: git.author,
3163                git_commit_date: git.commit_date,
3164                git_remote_url: git.remote_url,
3165            }
3166        })
3167        .filter(|s| s.files_analyzed > 0)
3168        .collect()
3169}
3170
3171/// Run [`detect_git_for_run`] for every submodule concurrently, returning the results index-aligned
3172/// with `submodules`. Bounded work-stealing over a small thread pool: each item does several git
3173/// subprocess calls, so fanning them out cuts the sequential spawn latency. A panicked worker
3174/// yields `GitInfo::default()` for its items (best-effort — a missing submodule SHA is non-fatal).
3175fn parallel_submodule_git(submodules: &[(String, PathBuf)], root: &Path) -> Vec<GitInfo> {
3176    let n = submodules.len();
3177    if n == 0 {
3178        return Vec::new();
3179    }
3180    let thread_count = std::thread::available_parallelism()
3181        .map_or(DEFAULT_ANALYSIS_THREADS, |t| {
3182            t.get().min(MAX_ANALYSIS_THREADS)
3183        })
3184        .min(n);
3185    let next_index = AtomicUsize::new(0);
3186
3187    let chunks: Vec<Vec<(usize, GitInfo)>> = std::thread::scope(|s| {
3188        let mut handles = Vec::with_capacity(thread_count);
3189        for _ in 0..thread_count {
3190            handles.push(s.spawn(|| {
3191                let mut local: Vec<(usize, GitInfo)> = Vec::new();
3192                loop {
3193                    let i = next_index.fetch_add(1, Ordering::Relaxed);
3194                    if i >= n {
3195                        break;
3196                    }
3197                    local.push((i, detect_git_for_run(&root.join(&submodules[i].1))));
3198                }
3199                local
3200            }));
3201        }
3202        handles
3203            .into_iter()
3204            .map(|h| h.join().unwrap_or_default())
3205            .collect()
3206    });
3207
3208    let mut out: Vec<GitInfo> = (0..n).map(|_| GitInfo::default()).collect();
3209    for chunk in chunks {
3210        for (i, info) in chunk {
3211            out[i] = info;
3212        }
3213    }
3214    out
3215}
3216
3217/// Dominant indent label from vote counts.
3218#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3219fn dominant_indent_label(files: &[&StyleAnalysis]) -> String {
3220    let mut votes = [0u32; 6];
3221    for f in files {
3222        let idx = match f.indent_style {
3223            IndentStyle::Tabs => 0,
3224            IndentStyle::Spaces2 => 1,
3225            IndentStyle::Spaces4 => 2,
3226            IndentStyle::Spaces8 => 3,
3227            IndentStyle::Mixed => 4,
3228            IndentStyle::Unknown => 5,
3229        };
3230        votes[idx] += 1;
3231    }
3232    let labels = ["Tabs", "2-Space", "4-Space", "8-Space", "Mixed", "\u{2014}"];
3233    labels[votes
3234        .iter()
3235        .enumerate()
3236        .max_by_key(|(_, v)| *v)
3237        .map_or(5, |(i, _)| i)]
3238    .to_string()
3239}
3240
3241/// Line-80 compliance percentage for a slice of style analyses.
3242#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3243fn line80_pct(files: &[&StyleAnalysis]) -> u8 {
3244    if files.is_empty() {
3245        return 0;
3246    }
3247    let compliant = files
3248        .iter()
3249        .filter(|f| f.total_lines == 0 || (f.lines_over_80 as f32 / f.total_lines as f32) <= 0.05)
3250        .count() as u32;
3251    ((compliant * 100) / files.len() as u32) as u8
3252}
3253
3254/// Column-N compliance percentage using the configured threshold (80, 100, or 120).
3255/// Falls back to the 80-col bucket for any threshold ≤ 80.
3256#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3257fn line_col_pct(files: &[&StyleAnalysis], threshold: u16) -> u8 {
3258    if files.is_empty() {
3259        return 0;
3260    }
3261    let compliant = files
3262        .iter()
3263        .filter(|f| {
3264            let over = if threshold <= 80 {
3265                f.lines_over_80
3266            } else if threshold <= 100 {
3267                f.lines_over_100
3268            } else {
3269                f.lines_over_120
3270            };
3271            f.total_lines == 0 || (over as f32 / f.total_lines as f32) <= 0.05
3272        })
3273        .count() as u32;
3274    ((compliant * 100) / files.len() as u32) as u8
3275}
3276
3277/// Build a `LanguageStyleGroup` from a non-empty slice of `StyleAnalysis` for one family.
3278#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3279fn build_language_group(
3280    family: &str,
3281    files: &[&StyleAnalysis],
3282    col_threshold: u16,
3283) -> LanguageStyleGroup {
3284    let count = files.len() as u32;
3285
3286    // Collect every unique guide name across all files in this group.
3287    let mut all_names: Vec<String> = Vec::new();
3288    for f in files {
3289        for g in &f.guide_scores {
3290            if !all_names.contains(&g.name) {
3291                all_names.push(g.name.clone());
3292            }
3293        }
3294    }
3295
3296    let mut guide_avg_scores: Vec<(String, u8)> = all_names
3297        .into_iter()
3298        .map(|name| {
3299            let sum: u32 = files
3300                .iter()
3301                .filter_map(|f| f.guide_scores.iter().find(|g| g.name == name))
3302                .map(|g| u32::from(g.score_pct))
3303                .sum();
3304            let avg = (sum / count) as u8;
3305            (name, avg)
3306        })
3307        .collect();
3308    guide_avg_scores.sort_by_key(|s| std::cmp::Reverse(s.1));
3309
3310    let (dominant_guide, dominant_score_pct) = guide_avg_scores
3311        .first()
3312        .map(|(n, s)| (n.clone(), *s))
3313        .unwrap_or_default();
3314
3315    let lcp = line_col_pct(files, col_threshold);
3316    LanguageStyleGroup {
3317        language_family: family.to_string(),
3318        files_count: count,
3319        dominant_guide,
3320        dominant_score_pct,
3321        common_indent_style: dominant_indent_label(files),
3322        guide_avg_scores,
3323        line80_compliant_pct: line80_pct(files),
3324        line_col_compliant_pct: lcp,
3325    }
3326}
3327
3328/// Build aggregate multi-language style-guide adherence.
3329/// Returns `None` when no files had style data.
3330#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
3331fn build_style_summary(analyzed: &[FileRecord], col_threshold: u16) -> Option<StyleSummary> {
3332    let all_style: Vec<&StyleAnalysis> = analyzed
3333        .iter()
3334        .filter_map(|f| f.style_analysis.as_ref())
3335        .collect();
3336
3337    if all_style.is_empty() {
3338        return None;
3339    }
3340
3341    // Group by language_family.
3342    let mut families: std::collections::BTreeMap<&str, Vec<&StyleAnalysis>> =
3343        std::collections::BTreeMap::new();
3344    for sa in &all_style {
3345        families
3346            .entry(sa.language_family.as_str())
3347            .or_default()
3348            .push(sa);
3349    }
3350
3351    let mut by_language: Vec<LanguageStyleGroup> = families
3352        .iter()
3353        .map(|(family, files)| build_language_group(family, files, col_threshold))
3354        .collect();
3355    by_language.sort_by_key(|g| std::cmp::Reverse(g.files_count));
3356
3357    let files_analyzed = all_style.len() as u32;
3358    let common_indent_style = dominant_indent_label(&all_style);
3359    let line80_compliant_pct = line80_pct(&all_style);
3360    let line_col_compliant_pct = line_col_pct(&all_style, col_threshold);
3361
3362    Some(StyleSummary {
3363        files_analyzed,
3364        common_indent_style,
3365        line80_compliant_pct,
3366        line_col_compliant_pct,
3367        col_threshold,
3368        by_language,
3369    })
3370}
3371
3372fn build_language_summaries_from_slice(files: &[&FileRecord]) -> Vec<LanguageSummary> {
3373    let mut map: BTreeMap<String, LanguageSummary> = BTreeMap::new();
3374    for file in files {
3375        let Some(lang) = file.language else { continue };
3376        let entry = map
3377            .entry(lang.display_name().to_string())
3378            .or_insert_with(|| zeroed_summary(lang));
3379        accumulate_record_into_summary(entry, file);
3380    }
3381    map.into_values().collect()
3382}
3383
3384fn file_name_eq(path: &Path, expected: &str) -> bool {
3385    path.file_name()
3386        .and_then(|name| name.to_str())
3387        .is_some_and(|name| name == expected)
3388}
3389
3390fn is_excluded_dir_path(path: &Path, excluded_dirs: &[String]) -> bool {
3391    path.components().any(|component| {
3392        component
3393            .as_os_str()
3394            .to_str()
3395            .is_some_and(|part| excluded_dirs.iter().any(|excluded| excluded == part))
3396    })
3397}
3398
3399fn is_vendor_path(path: &Path) -> bool {
3400    path.components().any(|component| {
3401        component
3402            .as_os_str()
3403            .to_str()
3404            .is_some_and(|part| matches!(part, "vendor" | "node_modules" | "packages"))
3405    })
3406}
3407
3408fn is_known_lockfile(path: &Path) -> bool {
3409    path.file_name()
3410        .and_then(|name| name.to_str())
3411        .is_some_and(|name| {
3412            matches!(
3413                name,
3414                "Cargo.lock"
3415                    | "package-lock.json"
3416                    | "yarn.lock"
3417                    | "pnpm-lock.yaml"
3418                    | "Pipfile.lock"
3419                    | "poetry.lock"
3420                    | "composer.lock"
3421            )
3422        })
3423}
3424
3425fn looks_generated(path: &Path, bytes: &[u8]) -> bool {
3426    let file_name = path
3427        .file_name()
3428        .and_then(|name| name.to_str())
3429        .unwrap_or_default();
3430    if file_name.contains(".generated.") || file_name.contains(".g.") {
3431        return true;
3432    }
3433
3434    let sample = String::from_utf8_lossy(&bytes[..bytes.len().min(GENERATED_SAMPLE_BYTES)])
3435        .to_ascii_lowercase();
3436    sample.contains("@generated") || sample.contains("generated by")
3437}
3438
3439fn looks_minified(path: &Path, bytes: &[u8]) -> bool {
3440    let file_name = path
3441        .file_name()
3442        .and_then(|name| name.to_str())
3443        .unwrap_or_default();
3444    if file_name.contains(".min.") {
3445        return true;
3446    }
3447
3448    let sample = String::from_utf8_lossy(&bytes[..bytes.len().min(MINIFIED_SAMPLE_BYTES)]);
3449    let longest_line = sample.lines().map(str::len).max().unwrap_or(0);
3450    let whitespace = sample.chars().filter(|c| c.is_whitespace()).count();
3451    longest_line > MINIFIED_LINE_THRESHOLD && whitespace * 100 < sample.len().max(1)
3452}
3453
3454fn is_binary(bytes: &[u8]) -> bool {
3455    if bytes.starts_with(&[0xEF, 0xBB, 0xBF])
3456        || bytes.starts_with(&[0xFF, 0xFE])
3457        || bytes.starts_with(&[0xFE, 0xFF])
3458    {
3459        return false;
3460    }
3461
3462    let sample = &bytes[..bytes.len().min(BINARY_SAMPLE_BYTES)];
3463    sample.contains(&0)
3464}
3465
3466/// Decode a BOM-stripped UTF-16 byte slice using the given encoding.
3467/// Returns `(text, encoding_label, warnings)`.
3468fn decode_utf16_bom(
3469    bom_stripped: &[u8],
3470    encoding: &'static encoding_rs::Encoding,
3471    label: &str,
3472) -> (String, String, Vec<String>) {
3473    let (cow, _, had_errors) = encoding.decode(bom_stripped);
3474    let mut warnings = Vec::new();
3475    if had_errors {
3476        warnings.push(format!("{label} decode contained replacement characters"));
3477    }
3478    (cow.into_owned(), label.into(), warnings)
3479}
3480
3481fn decode_bytes(bytes: &[u8]) -> std::result::Result<(String, String, Vec<String>), String> {
3482    if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
3483        let text = String::from_utf8(bytes[3..].to_vec()).map_err(|err| err.to_string())?;
3484        return Ok((text, "utf-8-bom".into(), vec![]));
3485    }
3486    if bytes.starts_with(&[0xFF, 0xFE]) {
3487        return Ok(decode_utf16_bom(&bytes[2..], UTF_16LE, "utf-16le"));
3488    }
3489    if bytes.starts_with(&[0xFE, 0xFF]) {
3490        return Ok(decode_utf16_bom(&bytes[2..], UTF_16BE, "utf-16be"));
3491    }
3492
3493    // Multiple statements in the else branch make map_or_else awkward here.
3494    #[allow(clippy::option_if_let_else)]
3495    if let Ok(text) = String::from_utf8(bytes.to_vec()) {
3496        Ok((text, "utf-8".into(), vec![]))
3497    } else {
3498        let (cow, _, had_errors) = WINDOWS_1252.decode(bytes);
3499        let mut warnings = vec!["decoded using windows-1252 fallback".into()];
3500        if had_errors {
3501            warnings.push("fallback decode contained replacement characters".into());
3502        }
3503        Ok((cow.into_owned(), "windows-1252".into(), warnings))
3504    }
3505}
3506
3507fn compile_globset(patterns: &[String]) -> Result<Option<GlobSet>> {
3508    if patterns.is_empty() {
3509        return Ok(None);
3510    }
3511
3512    let mut builder = GlobSetBuilder::new();
3513    for pattern in patterns {
3514        builder
3515            .add(Glob::new(pattern).with_context(|| format!("invalid glob pattern: {pattern}"))?);
3516    }
3517    Ok(Some(
3518        builder.build().context("failed to compile glob filters")?,
3519    ))
3520}
3521
3522fn parse_enabled_languages(enabled: &[String]) -> Result<Option<BTreeSet<Language>>> {
3523    if enabled.is_empty() {
3524        return Ok(None);
3525    }
3526
3527    let supported = supported_languages();
3528    let mut set = BTreeSet::new();
3529    for name in enabled {
3530        let language = Language::from_name(name)
3531            .with_context(|| format!("unsupported language in config: {name}"))?;
3532        if !supported.contains(&language) {
3533            anyhow::bail!("language {name} is not supported in this build");
3534        }
3535        set.insert(language);
3536    }
3537    Ok(Some(set))
3538}
3539
3540/// # Errors
3541///
3542/// Returns an error if serialization fails or the output file cannot be written.
3543pub fn write_json(run: &AnalysisRun, output_path: &Path) -> Result<()> {
3544    let json = serde_json::to_string_pretty(run).context("failed to serialize analysis run")?;
3545    fs::write(output_path, json)
3546        .with_context(|| format!("failed to write JSON output to {}", output_path.display()))
3547}
3548
3549/// # Errors
3550///
3551/// Returns an error if the file cannot be read or the JSON cannot be parsed.
3552pub fn read_json(path: &Path) -> Result<AnalysisRun> {
3553    let contents = fs::read_to_string(path)
3554        .with_context(|| format!("failed to read result file {}", path.display()))?;
3555    serde_json::from_str(&contents)
3556        .with_context(|| format!("failed to parse JSON result {}", path.display()))
3557}
3558
3559#[cfg(test)]
3560mod tests {
3561    use super::*;
3562
3563    #[test]
3564    fn normalize_path_str_strips_verbatim_drive_prefix() {
3565        assert_eq!(
3566            normalize_path_str(r"\\?\C:\jenkins-agent\repo\CMakeLists.txt"),
3567            "C:/jenkins-agent/repo/CMakeLists.txt"
3568        );
3569    }
3570
3571    #[test]
3572    fn normalize_path_str_strips_verbatim_unc_prefix() {
3573        assert_eq!(
3574            normalize_path_str(r"\\?\UNC\server\share\proj\main.rs"),
3575            "//server/share/proj/main.rs"
3576        );
3577    }
3578
3579    #[test]
3580    fn normalize_path_str_leaves_plain_paths_unchanged() {
3581        // Relative path with backslashes -> only slash normalization applies.
3582        assert_eq!(normalize_path_str(r"src\foo\bar.rs"), "src/foo/bar.rs");
3583        // Already-forward-slash path is untouched.
3584        assert_eq!(normalize_path_str("src/foo/bar.rs"), "src/foo/bar.rs");
3585        // Plain absolute drive path (no verbatim prefix) is untouched except slashes.
3586        assert_eq!(normalize_path_str(r"C:\foo\bar.rs"), "C:/foo/bar.rs");
3587    }
3588
3589    #[test]
3590    fn effective_counts_respect_code_only_policy() {
3591        let raw = RawLineCounts {
3592            code_only_lines: 2,
3593            single_comment_only_lines: 1,
3594            mixed_code_single_comment_lines: 3,
3595            docstring_comment_lines: 2,
3596            ..RawLineCounts::default()
3597        };
3598        let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, true);
3599        assert_eq!(counts.code_lines, 5);
3600        assert_eq!(counts.comment_lines, 3);
3601    }
3602
3603    #[test]
3604    fn effective_counts_can_separate_mixed() {
3605        let raw = RawLineCounts {
3606            mixed_code_single_comment_lines: 2,
3607            mixed_code_multi_comment_lines: 1,
3608            ..RawLineCounts::default()
3609        };
3610        let counts =
3611            compute_effective_counts(&raw, MixedLinePolicy::SeparateMixedCategory, true, true);
3612        assert_eq!(counts.mixed_lines_separate, 3);
3613        assert_eq!(counts.code_lines, 0);
3614        assert_eq!(counts.comment_lines, 0);
3615    }
3616
3617    #[test]
3618    fn windows_1252_fallback_decodes() {
3619        let bytes = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x96, 0x57];
3620        let (text, encoding, warnings) = decode_bytes(&bytes).unwrap();
3621        assert_eq!(encoding, "windows-1252");
3622        // 0x96 in windows-1252 decodes to U+2013; assert via escape to keep the source ASCII.
3623        assert!(text.contains('\u{2013}'));
3624        assert!(!warnings.is_empty());
3625    }
3626
3627    // ── Pure predicate tests ─────────────────────────────────────────────────
3628
3629    #[test]
3630    fn is_binary_detects_null_byte() {
3631        let bytes = b"hello\x00world";
3632        assert!(is_binary(bytes));
3633    }
3634
3635    #[test]
3636    fn is_binary_clean_text_is_not_binary() {
3637        let bytes = b"fn main() { println!(\"hello\"); }";
3638        assert!(!is_binary(bytes));
3639    }
3640
3641    #[test]
3642    fn is_binary_utf8_bom_not_binary() {
3643        let bytes = b"\xef\xbb\xbffn main() {}";
3644        assert!(!is_binary(bytes));
3645    }
3646
3647    #[test]
3648    fn looks_generated_at_generated_marker() {
3649        let bytes = b"// @generated by protoc-gen-rust\nfn foo() {}";
3650        assert!(looks_generated(Path::new("foo.rs"), bytes));
3651    }
3652
3653    #[test]
3654    fn looks_generated_do_not_edit_marker() {
3655        // "Code generated by" triggers detection (contains the "generated by" substring).
3656        let bytes = b"// Code generated by build.rs. DO NOT EDIT.\nuse foo;";
3657        assert!(looks_generated(Path::new("foo.rs"), bytes));
3658        // @generated also triggers detection independently.
3659        let bytes2 = b"// @generated\nuse foo;";
3660        assert!(looks_generated(Path::new("foo.rs"), bytes2));
3661    }
3662
3663    #[test]
3664    fn looks_generated_normal_file_not_generated() {
3665        let bytes = b"fn main() {\n    println!(\"hello\");\n}\n";
3666        assert!(!looks_generated(Path::new("main.rs"), bytes));
3667    }
3668
3669    #[test]
3670    fn looks_minified_dot_min_filename() {
3671        let bytes = b"function a(){return 1}";
3672        assert!(looks_minified(Path::new("bundle.min.js"), bytes));
3673    }
3674
3675    #[test]
3676    fn looks_minified_normal_file_not_minified() {
3677        let bytes = b"function hello() {\n    return 1;\n}\n";
3678        assert!(!looks_minified(Path::new("app.js"), bytes));
3679    }
3680
3681    #[test]
3682    fn looks_minified_very_long_line() {
3683        let long_line: Vec<u8> = b"x".repeat(MINIFIED_LINE_THRESHOLD + 1);
3684        assert!(looks_minified(Path::new("app.js"), &long_line));
3685    }
3686
3687    #[test]
3688    fn is_known_lockfile_cargo_lock() {
3689        assert!(is_known_lockfile(Path::new("Cargo.lock")));
3690    }
3691
3692    #[test]
3693    fn is_known_lockfile_package_lock_json() {
3694        assert!(is_known_lockfile(Path::new("package-lock.json")));
3695    }
3696
3697    #[test]
3698    fn is_known_lockfile_yarn_lock() {
3699        assert!(is_known_lockfile(Path::new("yarn.lock")));
3700    }
3701
3702    #[test]
3703    fn is_known_lockfile_normal_file_is_not_lockfile() {
3704        assert!(!is_known_lockfile(Path::new("src/lib.rs")));
3705    }
3706
3707    #[test]
3708    fn is_vendor_path_node_modules() {
3709        assert!(is_vendor_path(Path::new("node_modules/react/index.js")));
3710    }
3711
3712    #[test]
3713    fn is_vendor_path_vendor_dir() {
3714        assert!(is_vendor_path(Path::new("vendor/anyhow/src/lib.rs")));
3715    }
3716
3717    #[test]
3718    fn is_vendor_path_normal_src_is_not_vendor() {
3719        assert!(!is_vendor_path(Path::new("src/lib.rs")));
3720    }
3721
3722    #[test]
3723    fn is_excluded_dir_path_matches_excluded() {
3724        let excluded = vec![".git".into(), "target".into()];
3725        assert!(is_excluded_dir_path(Path::new(".git/config"), &excluded));
3726    }
3727
3728    #[test]
3729    fn is_excluded_dir_path_non_excluded_is_ok() {
3730        let excluded = vec![".git".into(), "target".into()];
3731        assert!(!is_excluded_dir_path(Path::new("src/main.rs"), &excluded));
3732    }
3733
3734    #[test]
3735    fn decode_bytes_utf8_bom_stripped() {
3736        let bytes = b"\xef\xbb\xbffn main() {}";
3737        let (text, encoding, _) = decode_bytes(bytes).unwrap();
3738        // BOM is detected — encoding label includes "bom" indicator
3739        assert!(
3740            encoding.contains("utf-8"),
3741            "should be utf-8 variant, got {encoding}"
3742        );
3743        assert!(text.starts_with("fn"));
3744    }
3745
3746    #[test]
3747    fn decode_bytes_plain_utf8() {
3748        let bytes = b"hello world";
3749        let (text, encoding, warnings) = decode_bytes(bytes).unwrap();
3750        assert_eq!(encoding, "utf-8");
3751        assert_eq!(text, "hello world");
3752        assert!(warnings.is_empty());
3753    }
3754
3755    // ── UTF-16 BOM decoding ──────────────────────────────────────────────────
3756
3757    #[test]
3758    fn decode_bytes_utf16le_bom() {
3759        // Encode "hi" as UTF-16 LE with BOM: FF FE 68 00 69 00
3760        let mut bytes = vec![0xFF, 0xFE];
3761        for ch in "hi\n".encode_utf16() {
3762            bytes.extend_from_slice(&ch.to_le_bytes());
3763        }
3764        let (text, encoding, _warnings) = decode_bytes(&bytes).unwrap();
3765        assert_eq!(encoding, "utf-16le");
3766        assert!(text.contains('h') && text.contains('i'));
3767    }
3768
3769    #[test]
3770    fn decode_bytes_utf16be_bom() {
3771        // Encode "ok" as UTF-16 BE with BOM: FE FF 00 6F 00 6B
3772        let mut bytes = vec![0xFE, 0xFF];
3773        for ch in "ok\n".encode_utf16() {
3774            bytes.extend_from_slice(&ch.to_be_bytes());
3775        }
3776        let (text, encoding, _warnings) = decode_bytes(&bytes).unwrap();
3777        assert_eq!(encoding, "utf-16be");
3778        assert!(text.contains('o') && text.contains('k'));
3779    }
3780
3781    #[test]
3782    fn is_binary_utf16le_bom_not_binary() {
3783        // UTF-16 LE BOM followed by null bytes — should NOT be binary
3784        let bytes = &[0xFF, 0xFE, 0x68, 0x00];
3785        assert!(!is_binary(bytes));
3786    }
3787
3788    #[test]
3789    fn is_binary_utf16be_bom_not_binary() {
3790        let bytes = &[0xFE, 0xFF, 0x00, 0x68];
3791        assert!(!is_binary(bytes));
3792    }
3793
3794    // ── MixedLinePolicy branches ─────────────────────────────────────────────
3795
3796    #[test]
3797    fn effective_counts_code_and_comment_policy() {
3798        let raw = RawLineCounts {
3799            mixed_code_single_comment_lines: 3,
3800            mixed_code_multi_comment_lines: 2,
3801            ..RawLineCounts::default()
3802        };
3803        let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeAndComment, true, true);
3804        // Both code and comment incremented by mixed_total (5)
3805        assert_eq!(counts.code_lines, 5);
3806        assert_eq!(counts.comment_lines, 5);
3807        assert_eq!(counts.mixed_lines_separate, 0);
3808    }
3809
3810    #[test]
3811    fn effective_counts_comment_only_policy() {
3812        let raw = RawLineCounts {
3813            mixed_code_single_comment_lines: 4,
3814            mixed_code_multi_comment_lines: 1,
3815            ..RawLineCounts::default()
3816        };
3817        let counts = compute_effective_counts(&raw, MixedLinePolicy::CommentOnly, true, true);
3818        assert_eq!(counts.code_lines, 0);
3819        assert_eq!(counts.comment_lines, 5);
3820        assert_eq!(counts.mixed_lines_separate, 0);
3821    }
3822
3823    #[test]
3824    fn effective_counts_docstrings_as_code_when_flag_false() {
3825        let raw = RawLineCounts {
3826            code_only_lines: 10,
3827            docstring_comment_lines: 3,
3828            ..RawLineCounts::default()
3829        };
3830        // python_docstrings_as_comments = false → docstrings counted as code
3831        let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, false, true);
3832        assert_eq!(counts.code_lines, 13);
3833        assert_eq!(counts.comment_lines, 0);
3834    }
3835
3836    #[test]
3837    fn effective_counts_exclude_compiler_directives() {
3838        let raw = RawLineCounts {
3839            code_only_lines: 10,
3840            compiler_directive_lines: 3,
3841            ..RawLineCounts::default()
3842        };
3843        // count_compiler_directives = false → subtract directive lines from code
3844        let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, false);
3845        assert_eq!(counts.code_lines, 7);
3846    }
3847
3848    #[test]
3849    fn effective_counts_directives_not_subtracted_below_zero() {
3850        let raw = RawLineCounts {
3851            code_only_lines: 2,
3852            compiler_directive_lines: 5, // more than code — saturating_sub
3853            ..RawLineCounts::default()
3854        };
3855        let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, false);
3856        assert_eq!(counts.code_lines, 0); // saturated at 0
3857    }
3858
3859    // ── COCOMO modes ─────────────────────────────────────────────────────────
3860
3861    #[test]
3862    fn cocomo_organic_computes_positive_values() {
3863        let est = compute_cocomo(5_000, CocomoMode::Organic);
3864        assert!(est.ksloc > 0.0);
3865        assert!(est.effort_person_months > 0.0);
3866        assert!(est.duration_months > 0.0);
3867        assert!(est.avg_staff > 0.0);
3868        assert_eq!(est.mode, CocomoMode::Organic);
3869    }
3870
3871    #[test]
3872    fn cocomo_semi_detached_computes_positive_values() {
3873        let est = compute_cocomo(20_000, CocomoMode::SemiDetached);
3874        assert!(est.ksloc > 0.0);
3875        assert!(est.effort_person_months > 0.0);
3876        assert!(est.duration_months > 0.0);
3877        assert_eq!(est.mode, CocomoMode::SemiDetached);
3878    }
3879
3880    #[test]
3881    fn cocomo_embedded_computes_positive_values() {
3882        let est = compute_cocomo(100_000, CocomoMode::Embedded);
3883        assert!(est.effort_person_months > 0.0);
3884        assert_eq!(est.mode, CocomoMode::Embedded);
3885    }
3886
3887    #[test]
3888    fn cocomo_zero_lines_produces_zero_effort() {
3889        let est = compute_cocomo(0, CocomoMode::Organic);
3890        assert!((est.ksloc).abs() < f64::EPSILON);
3891        // Zero KSLOC → effort = 2.4 * 0^1.05 = 0
3892        assert!((est.effort_person_months - 0.0).abs() < 0.01);
3893    }
3894
3895    // ── parse_activity_log (git hotspots) ─────────────────────────────────────
3896
3897    #[test]
3898    fn parse_activity_log_counts_and_dates_per_file() {
3899        let out = "\u{0}2024-03-02T10:00:00+00:00\n\
3900                   M\tsrc/a.rs\n\
3901                   A\tsrc/b.rs\n\
3902                   \u{0}2024-03-01T09:00:00+00:00\n\
3903                   M\tsrc/a.rs\n";
3904        let map = parse_activity_log(out);
3905        assert_eq!(map["src/a.rs"].0, 2, "a.rs touched in two commits");
3906        assert_eq!(map["src/b.rs"].0, 1, "b.rs touched once");
3907        // Newest-first: a.rs keeps the most recent date.
3908        assert_eq!(
3909            map["src/a.rs"].1.as_deref(),
3910            Some("2024-03-02T10:00:00+00:00")
3911        );
3912    }
3913
3914    #[test]
3915    fn parse_activity_log_attributes_rename_to_new_path() {
3916        let out = "\u{0}2024-03-02T10:00:00+00:00\nR100\tsrc/old.rs\tsrc/new.rs\n";
3917        let map = parse_activity_log(out);
3918        assert_eq!(map["src/new.rs"].0, 1);
3919        assert!(!map.contains_key("src/old.rs"));
3920    }
3921
3922    #[test]
3923    fn parse_activity_log_empty_is_empty() {
3924        assert!(parse_activity_log("").is_empty());
3925    }
3926
3927    // ── Attribution cost estimate ─────────────────────────────────────────────
3928    #[test]
3929    fn attribution_severity_buckets_by_file_count() {
3930        assert_eq!(
3931            classify_attribution_severity(0, 0),
3932            AttributionSeverity::Light
3933        );
3934        assert_eq!(
3935            classify_attribution_severity(1_999, 0),
3936            AttributionSeverity::Light
3937        );
3938        assert_eq!(
3939            classify_attribution_severity(2_000, 0),
3940            AttributionSeverity::Moderate
3941        );
3942        assert_eq!(
3943            classify_attribution_severity(9_999, 0),
3944            AttributionSeverity::Moderate
3945        );
3946        assert_eq!(
3947            classify_attribution_severity(10_000, 0),
3948            AttributionSeverity::Heavy
3949        );
3950    }
3951
3952    #[test]
3953    fn attribution_severity_promoted_by_deep_history() {
3954        // A moderate file count over a huge history is promoted to heavy.
3955        assert_eq!(
3956            classify_attribution_severity(3_000, 60_000),
3957            AttributionSeverity::Heavy
3958        );
3959        // A small-but-not-tiny repo with deep history becomes moderate.
3960        assert_eq!(
3961            classify_attribution_severity(1_500, 60_000),
3962            AttributionSeverity::Moderate
3963        );
3964        // A truly tiny repo stays light even with deep history.
3965        assert_eq!(
3966            classify_attribution_severity(100, 60_000),
3967            AttributionSeverity::Light
3968        );
3969        // Deep history never downgrades an already-heavy repo.
3970        assert_eq!(
3971            classify_attribution_severity(20_000, 60_000),
3972            AttributionSeverity::Heavy
3973        );
3974    }
3975
3976    #[test]
3977    fn attribution_estimate_holds_invariants() {
3978        // Exercises the full estimate body against a real path. Assertions are limited to
3979        // invariants that hold whether or not `.git` is present in the test environment, so the
3980        // test is robust in CI, offline tarball builds, and local dev alike.
3981        let est = estimate_attribution_cost(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
3982        assert_eq!(
3983            est.recommend_attribution,
3984            est.severity != AttributionSeverity::Heavy
3985        );
3986        assert_eq!(est.estimated_seconds, est.blameable_files.div_ceil(50));
3987        if !est.is_git {
3988            assert_eq!(est.blameable_files, 0);
3989            assert_eq!(est.commit_count, 0);
3990        }
3991    }
3992
3993    // ── Code-ownership attribution ────────────────────────────────────────────
3994
3995    #[test]
3996    fn parse_blame_porcelain_extracts_one_identity_per_line() {
3997        let out = "\
3998abc123 1 1 2
3999author Nima Shafie
4000author-mail <nimzshafie@gmail.com>
4001author-time 1700000000
4002summary first
4003filename src/a.rs
4004\tfirst line of code
4005abc123 2 2
4006author Nima Shafie
4007author-mail <nimzshafie@gmail.com>
4008\tsecond line
4009def456 3 3
4010author Other Dev
4011author-mail <other@example.com>
4012\tthird line
4013";
4014        let ids = parse_blame_porcelain(out);
4015        assert_eq!(ids.len(), 3, "one identity per TAB-prefixed content line");
4016        assert_eq!(ids[0].name, "Nima Shafie");
4017        assert_eq!(ids[0].email, "nimzshafie@gmail.com");
4018        assert_eq!(ids[2].name, "Other Dev");
4019        assert_eq!(ids[2].email, "other@example.com");
4020    }
4021
4022    #[test]
4023    fn parse_blame_porcelain_empty_is_empty() {
4024        assert!(parse_blame_porcelain("").is_empty());
4025    }
4026
4027    #[test]
4028    fn normalize_email_key_merges_case_and_plus_tag() {
4029        let a = RawIdentity {
4030            name: "Nima Shafie".into(),
4031            email: "Nima@Example.COM".into(),
4032        };
4033        let b = RawIdentity {
4034            name: "nshafie".into(),
4035            email: "nima+work@example.com".into(),
4036        };
4037        assert_eq!(normalize_email_key(&a), normalize_email_key(&b));
4038    }
4039
4040    #[test]
4041    fn normalize_email_key_distinct_emails_do_not_merge() {
4042        let a = RawIdentity {
4043            name: "Nima Shafie".into(),
4044            email: "nima@corp.example".into(),
4045        };
4046        let b = RawIdentity {
4047            name: "Nima Shafie".into(),
4048            email: "nima@personal.example".into(),
4049        };
4050        // Cross-email merges are a later interactive step, not an auto-merge.
4051        assert_ne!(normalize_email_key(&a), normalize_email_key(&b));
4052    }
4053
4054    #[test]
4055    fn normalize_email_key_missing_email_falls_back_to_name() {
4056        let a = RawIdentity {
4057            name: "Anon Dev".into(),
4058            email: String::new(),
4059        };
4060        let b = RawIdentity {
4061            name: "anon dev".into(),
4062            email: "not.committed.yet".into(),
4063        };
4064        assert_eq!(normalize_email_key(&a), "name:anon dev");
4065        assert_eq!(normalize_email_key(&a), normalize_email_key(&b));
4066    }
4067
4068    #[test]
4069    fn author_resolver_folds_same_email_and_orders_by_code() {
4070        let mut r = AuthorResolver::default();
4071        let id_a1 = r.resolve(&RawIdentity {
4072            name: "Nima Shafie".into(),
4073            email: "nima@example.com".into(),
4074        });
4075        let id_a2 = r.resolve(&RawIdentity {
4076            name: "nshafie".into(),
4077            email: "NIMA@example.com".into(),
4078        });
4079        let id_b = r.resolve(&RawIdentity {
4080            name: "Other".into(),
4081            email: "other@example.com".into(),
4082        });
4083        assert_eq!(id_a1, id_a2, "same email folds into one author");
4084        assert_ne!(id_a1, id_b);
4085
4086        // Give author B more code so it should sort first after finish().
4087        r.authors[id_a1 as usize].counts.code_lines = 10;
4088        r.authors[id_b as usize].counts.code_lines = 50;
4089        let mut records: Vec<FileRecord> = Vec::new();
4090        let authors = r.finish(&mut records);
4091        assert_eq!(authors.len(), 2);
4092        assert_eq!(authors[0].canonical_email, "other@example.com");
4093        assert_eq!(authors[0].id, 0);
4094        assert_eq!(authors[1].aliases.len(), 2, "two spellings recorded");
4095    }
4096
4097    // ── Post-hoc identity merging ─────────────────────────────────────────────
4098
4099    fn author(id: u32, name: &str, email: &str, code: u64) -> Author {
4100        Author {
4101            id,
4102            canonical_name: name.into(),
4103            canonical_email: email.into(),
4104            aliases: vec![RawIdentity {
4105                name: name.into(),
4106                email: email.into(),
4107            }],
4108            counts: AuthorLineCounts {
4109                code_lines: code,
4110                comment_lines: 0,
4111                blank_lines: 0,
4112                total_lines: code,
4113            },
4114        }
4115    }
4116
4117    /// A minimal `AnalysisRun` carrying the given authors and one empty-ownership file record.
4118    fn minimal_run_with_authors(authors: Vec<Author>) -> AnalysisRun {
4119        AnalysisRun {
4120            tool: ToolMetadata {
4121                name: "sloc".into(),
4122                version: "0.0.1".into(),
4123                run_id: "merge-test".into(),
4124                timestamp_utc: Utc::now(),
4125            },
4126            environment: EnvironmentMetadata {
4127                operating_system: "test".into(),
4128                architecture: "x86_64".into(),
4129                runtime_mode: "test".into(),
4130                initiator_username: "tester".into(),
4131                initiator_hostname: "testhost".into(),
4132                ci_name: None,
4133            },
4134            effective_configuration: AppConfig::default(),
4135            input_roots: vec!["/tmp/test".into()],
4136            summary_totals: SummaryTotals::default(),
4137            totals_by_language: vec![],
4138            per_file_records: vec![FileRecord {
4139                path: "a.rs".into(),
4140                relative_path: "a.rs".into(),
4141                language: Some(Language::Rust),
4142                size_bytes: 50,
4143                detected_encoding: Some("utf-8".into()),
4144                raw_line_categories: RawLineCounts::default(),
4145                effective_counts: EffectiveCounts::default(),
4146                status: FileStatus::AnalyzedExact,
4147                warnings: vec![],
4148                generated: false,
4149                minified: false,
4150                vendor: false,
4151                parse_mode: Some(ParseMode::Lexical),
4152                submodule: None,
4153                coverage: None,
4154                style_analysis: None,
4155                cyclomatic_complexity: None,
4156                lsloc: None,
4157                commit_count: None,
4158                last_commit_date: None,
4159                ownership: None,
4160                content_hash: 0,
4161            }],
4162            skipped_file_records: vec![],
4163            warnings: vec![],
4164            submodule_summaries: vec![],
4165            git_commit_short: None,
4166            git_branch: None,
4167            git_commit_long: None,
4168            git_commit_author: None,
4169            git_tags: None,
4170            git_nearest_tag: None,
4171            git_commit_date: None,
4172            git_remote_url: None,
4173            style_summary: None,
4174            cocomo: None,
4175            uloc: 0,
4176            dryness_pct: None,
4177            duplicate_groups: vec![],
4178            duplicates_excluded: 0,
4179            authors,
4180        }
4181    }
4182
4183    #[test]
4184    fn identity_map_merge_and_unmerge() {
4185        let mut map = IdentityMap::default();
4186        map.merge(
4187            &["nima@corp.com".into(), "nima@personal.com".into()],
4188            Some("Nima Shafie"),
4189        );
4190        assert_eq!(map.groups.len(), 1);
4191        assert!(map.group_for("NIMA@CORP.COM").is_some(), "case-insensitive");
4192        // Merging an overlapping selection extends the same group rather than duplicating.
4193        map.merge(
4194            &["nima@corp.com".into(), "nima@laptop.com".into()],
4195            Some("Nima Shafie"),
4196        );
4197        assert_eq!(map.groups.len(), 1);
4198        assert_eq!(map.groups[0].members.len(), 3);
4199        let canonical = map.groups[0].canonical_email.clone();
4200        map.unmerge(&canonical);
4201        assert!(map.groups.is_empty());
4202    }
4203
4204    #[test]
4205    fn identity_map_to_mailmap_lists_aliases() {
4206        let mut map = IdentityMap::default();
4207        map.merge(&["a@x.com".into(), "b@y.com".into()], Some("Real Name"));
4208        let mm = map.to_mailmap();
4209        // Canonical (a@x.com, the sorted-first) is not emitted as its own alias line.
4210        assert!(mm.contains("Real Name <a@x.com> <b@y.com>"));
4211        assert_eq!(mm.matches("Real Name <").count(), 1);
4212    }
4213
4214    #[test]
4215    fn apply_identity_map_folds_authors_and_ownership() {
4216        let mut run = minimal_run_with_authors(vec![
4217            author(0, "Nima Shafie", "nima@corp.com", 100),
4218            author(1, "nshafie", "nima@personal.com", 40),
4219            author(2, "Other", "other@x.com", 30),
4220        ]);
4221        // One file owned across the two soon-to-merge identities.
4222        run.per_file_records[0].ownership = Some(vec![
4223            FileOwnership {
4224                author_id: 0,
4225                counts: AuthorLineCounts {
4226                    code_lines: 100,
4227                    comment_lines: 0,
4228                    blank_lines: 0,
4229                    total_lines: 100,
4230                },
4231            },
4232            FileOwnership {
4233                author_id: 1,
4234                counts: AuthorLineCounts {
4235                    code_lines: 40,
4236                    comment_lines: 0,
4237                    blank_lines: 0,
4238                    total_lines: 40,
4239                },
4240            },
4241        ]);
4242
4243        let mut map = IdentityMap::default();
4244        map.merge(
4245            &["nima@corp.com".into(), "nima@personal.com".into()],
4246            Some("Nima Shafie"),
4247        );
4248        apply_identity_map(&mut run, &map);
4249
4250        assert_eq!(run.authors.len(), 2, "two identities folded into one");
4251        let nima = run
4252            .authors
4253            .iter()
4254            .find(|a| a.canonical_name == "Nima Shafie")
4255            .expect("merged author present");
4256        assert_eq!(nima.counts.code_lines, 140, "counts summed");
4257        assert_eq!(nima.aliases.len(), 2, "both aliases retained");
4258        assert_eq!(run.authors[0].canonical_name, "Nima Shafie", "sorts first");
4259        // The file's two ownership rows for the merged pair collapse into one.
4260        let own = run.per_file_records[0].ownership.as_ref().unwrap();
4261        let nima_own = own
4262            .iter()
4263            .find(|o| o.author_id == run.authors[0].id)
4264            .unwrap();
4265        assert_eq!(nima_own.counts.code_lines, 140);
4266    }
4267
4268    #[test]
4269    fn auto_merge_folds_github_noreply_into_real_email() {
4270        let mut run = minimal_run_with_authors(vec![
4271            author(0, "Nima Shafie", "nima@gmail.com", 200),
4272            author(
4273                1,
4274                "Nima Shafie",
4275                "69773301+NimaShafie@users.noreply.github.com",
4276                30,
4277            ),
4278            author(
4279                2,
4280                "copilot-swe-agent[bot]",
4281                "1+Copilot@users.noreply.github.com",
4282                10,
4283            ),
4284        ]);
4285        run.per_file_records[0].ownership = Some(vec![
4286            FileOwnership {
4287                author_id: 0,
4288                counts: AuthorLineCounts {
4289                    code_lines: 200,
4290                    comment_lines: 0,
4291                    blank_lines: 0,
4292                    total_lines: 200,
4293                },
4294            },
4295            FileOwnership {
4296                author_id: 1,
4297                counts: AuthorLineCounts {
4298                    code_lines: 30,
4299                    comment_lines: 0,
4300                    blank_lines: 0,
4301                    total_lines: 30,
4302                },
4303            },
4304        ]);
4305
4306        auto_merge_noreply_identities(&mut run);
4307
4308        // The two "Nima Shafie" rows collapse; the unrelated bot no-reply stays separate.
4309        assert_eq!(
4310            run.authors.len(),
4311            2,
4312            "noreply Nima folded into real-email Nima"
4313        );
4314        let nima = run
4315            .authors
4316            .iter()
4317            .find(|a| a.canonical_name == "Nima Shafie")
4318            .expect("merged author present");
4319        assert_eq!(
4320            nima.canonical_email, "nima@gmail.com",
4321            "real email preferred"
4322        );
4323        assert_eq!(nima.counts.code_lines, 230, "counts summed");
4324        assert!(
4325            run.authors
4326                .iter()
4327                .any(|a| a.canonical_name == "copilot-swe-agent[bot]"),
4328            "bot identity not merged into a same-named person",
4329        );
4330        // The file's two ownership rows for the merged pair collapse into one.
4331        let own = run.per_file_records[0].ownership.as_ref().unwrap();
4332        let nima_own = own.iter().find(|o| o.author_id == nima.id).unwrap();
4333        assert_eq!(nima_own.counts.code_lines, 230);
4334    }
4335
4336    // ── Path / git helpers ────────────────────────────────────────────────────
4337
4338    #[test]
4339    fn parse_url_line_extracts_url() {
4340        assert_eq!(
4341            parse_url_line("url = https://example.com/repo.git"),
4342            Some("https://example.com/repo.git")
4343        );
4344    }
4345
4346    #[test]
4347    fn parse_url_line_returns_none_for_non_url_key() {
4348        assert_eq!(
4349            parse_url_line("fetch = +refs/heads/*:refs/remotes/origin/*"),
4350            None
4351        );
4352    }
4353
4354    #[test]
4355    fn parse_url_line_returns_none_for_empty_url() {
4356        assert_eq!(parse_url_line("url = "), None);
4357    }
4358
4359    #[test]
4360    fn looks_generated_generated_filename_extension() {
4361        // Files with ".generated." in name are detected without reading bytes
4362        let bytes = b"// normal code\n";
4363        assert!(looks_generated(Path::new("schema.generated.ts"), bytes));
4364    }
4365
4366    #[test]
4367    fn looks_generated_dot_g_extension() {
4368        let bytes = b"// normal code\n";
4369        assert!(looks_generated(Path::new("parser.g.cs"), bytes));
4370    }
4371
4372    #[test]
4373    fn looks_minified_whitespace_ratio_is_ok() {
4374        // Low whitespace ratio but NOT over the line length threshold → not minified
4375        let normal = b"var x=1,y=2,z=3;\n";
4376        assert!(!looks_minified(Path::new("app.js"), normal));
4377    }
4378
4379    #[test]
4380    fn is_known_lockfile_pnpm() {
4381        assert!(is_known_lockfile(Path::new("pnpm-lock.yaml")));
4382    }
4383
4384    #[test]
4385    fn is_known_lockfile_pipfile() {
4386        assert!(is_known_lockfile(Path::new("Pipfile.lock")));
4387    }
4388
4389    #[test]
4390    fn is_known_lockfile_poetry() {
4391        assert!(is_known_lockfile(Path::new("poetry.lock")));
4392    }
4393
4394    #[test]
4395    fn is_known_lockfile_composer() {
4396        assert!(is_known_lockfile(Path::new("composer.lock")));
4397    }
4398
4399    // ── relative_path_string and path_to_string ──────────────────────────────
4400
4401    #[test]
4402    fn relative_path_string_strips_root_prefix() {
4403        let path = Path::new("/tmp/project/src/lib.rs");
4404        let root = Path::new("/tmp/project");
4405        let rel = relative_path_string(path, root);
4406        assert_eq!(rel, "src/lib.rs");
4407    }
4408
4409    #[test]
4410    fn relative_path_string_falls_back_to_full_path() {
4411        // When path is not under root, fall back to path itself
4412        let path = Path::new("/other/dir/file.rs");
4413        let root = Path::new("/tmp/project");
4414        let rel = relative_path_string(path, root);
4415        // Should not panic; returns path representation
4416        assert!(!rel.is_empty());
4417    }
4418
4419    // ── find_duplicate_groups ────────────────────────────────────────────────
4420
4421    #[test]
4422    fn find_duplicate_groups_returns_empty_for_unique_hashes() {
4423        use sloc_languages::{Language, ParseMode, RawLineCounts};
4424        let make_rec = |hash: u64, path: &str| FileRecord {
4425            path: path.into(),
4426            relative_path: path.into(),
4427            language: Some(Language::Rust),
4428            size_bytes: 10,
4429            detected_encoding: Some("utf-8".into()),
4430            raw_line_categories: RawLineCounts::default(),
4431            effective_counts: EffectiveCounts::default(),
4432            status: FileStatus::AnalyzedExact,
4433            warnings: vec![],
4434            generated: false,
4435            minified: false,
4436            vendor: false,
4437            parse_mode: Some(ParseMode::Lexical),
4438            submodule: None,
4439            coverage: None,
4440            style_analysis: None,
4441            cyclomatic_complexity: None,
4442            lsloc: None,
4443            commit_count: None,
4444            last_commit_date: None,
4445            ownership: None,
4446            content_hash: hash,
4447        };
4448        let analyzed = vec![make_rec(111, "a.rs"), make_rec(222, "b.rs")];
4449        let groups = find_duplicate_groups(&analyzed);
4450        assert!(groups.is_empty());
4451    }
4452
4453    #[test]
4454    fn find_duplicate_groups_returns_group_for_same_hash() {
4455        use sloc_languages::{Language, ParseMode, RawLineCounts};
4456        let make_rec = |hash: u64, path: &str| FileRecord {
4457            path: path.into(),
4458            relative_path: path.into(),
4459            language: Some(Language::Rust),
4460            size_bytes: 10,
4461            detected_encoding: Some("utf-8".into()),
4462            raw_line_categories: RawLineCounts::default(),
4463            effective_counts: EffectiveCounts::default(),
4464            status: FileStatus::AnalyzedExact,
4465            warnings: vec![],
4466            generated: false,
4467            minified: false,
4468            vendor: false,
4469            parse_mode: Some(ParseMode::Lexical),
4470            submodule: None,
4471            coverage: None,
4472            style_analysis: None,
4473            cyclomatic_complexity: None,
4474            lsloc: None,
4475            commit_count: None,
4476            last_commit_date: None,
4477            ownership: None,
4478            content_hash: hash,
4479        };
4480        let analyzed = vec![
4481            make_rec(999, "a.rs"),
4482            make_rec(999, "b.rs"),
4483            make_rec(123, "c.rs"),
4484        ];
4485        let groups = find_duplicate_groups(&analyzed);
4486        assert_eq!(groups.len(), 1);
4487        assert_eq!(groups[0].len(), 2);
4488    }
4489
4490    #[test]
4491    fn find_duplicate_groups_ignores_zero_hash() {
4492        use sloc_languages::{Language, ParseMode, RawLineCounts};
4493        let make_rec = |hash: u64, path: &str| FileRecord {
4494            path: path.into(),
4495            relative_path: path.into(),
4496            language: Some(Language::Rust),
4497            size_bytes: 10,
4498            detected_encoding: Some("utf-8".into()),
4499            raw_line_categories: RawLineCounts::default(),
4500            effective_counts: EffectiveCounts::default(),
4501            status: FileStatus::AnalyzedExact,
4502            warnings: vec![],
4503            generated: false,
4504            minified: false,
4505            vendor: false,
4506            parse_mode: Some(ParseMode::Lexical),
4507            submodule: None,
4508            coverage: None,
4509            style_analysis: None,
4510            cyclomatic_complexity: None,
4511            lsloc: None,
4512            commit_count: None,
4513            last_commit_date: None,
4514            ownership: None,
4515            content_hash: hash,
4516        };
4517        // hash=0 means "not computed" — must be excluded from duplicate detection
4518        let analyzed = vec![make_rec(0, "a.rs"), make_rec(0, "b.rs")];
4519        let groups = find_duplicate_groups(&analyzed);
4520        assert!(
4521            groups.is_empty(),
4522            "zero-hash files must not be grouped as duplicates"
4523        );
4524    }
4525
4526    // ── detect_submodules ────────────────────────────────────────────────────
4527
4528    #[test]
4529    fn detect_submodules_no_gitmodules_returns_empty() {
4530        let dir = tempfile::tempdir().unwrap();
4531        let result = detect_submodules(dir.path());
4532        assert!(result.is_empty());
4533    }
4534
4535    #[test]
4536    fn detect_submodules_parses_gitmodules_file() {
4537        let dir = tempfile::tempdir().unwrap();
4538        let content = "[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = https://github.com/example/lib.git\n";
4539        std::fs::write(dir.path().join(".gitmodules"), content).unwrap();
4540        let result = detect_submodules(dir.path());
4541        assert_eq!(result.len(), 1);
4542        assert_eq!(result[0].0, "vendor/lib");
4543    }
4544
4545    // ── write_json / read_json roundtrip ─────────────────────────────────────
4546
4547    #[test]
4548    fn write_json_read_json_roundtrip() {
4549        use chrono::Utc;
4550        use sloc_config::AppConfig;
4551        use sloc_languages::{Language, ParseMode, RawLineCounts};
4552        let dir = tempfile::tempdir().unwrap();
4553        let run = AnalysisRun {
4554            tool: ToolMetadata {
4555                name: "sloc".into(),
4556                version: "0.0.1".into(),
4557                run_id: "test-roundtrip".into(),
4558                timestamp_utc: Utc::now(),
4559            },
4560            environment: EnvironmentMetadata {
4561                operating_system: "test".into(),
4562                architecture: "x86_64".into(),
4563                runtime_mode: "test".into(),
4564                initiator_username: "tester".into(),
4565                initiator_hostname: "testhost".into(),
4566                ci_name: None,
4567            },
4568            effective_configuration: AppConfig::default(),
4569            input_roots: vec!["/tmp/test".into()],
4570            summary_totals: SummaryTotals {
4571                files_analyzed: 1,
4572                code_lines: 5,
4573                ..SummaryTotals::default()
4574            },
4575            totals_by_language: vec![],
4576            per_file_records: vec![FileRecord {
4577                path: "a.rs".into(),
4578                relative_path: "a.rs".into(),
4579                language: Some(Language::Rust),
4580                size_bytes: 50,
4581                detected_encoding: Some("utf-8".into()),
4582                raw_line_categories: RawLineCounts {
4583                    code_only_lines: 5,
4584                    ..RawLineCounts::default()
4585                },
4586                effective_counts: EffectiveCounts {
4587                    code_lines: 5,
4588                    ..EffectiveCounts::default()
4589                },
4590                status: FileStatus::AnalyzedExact,
4591                warnings: vec![],
4592                generated: false,
4593                minified: false,
4594                vendor: false,
4595                parse_mode: Some(ParseMode::Lexical),
4596                submodule: None,
4597                coverage: None,
4598                style_analysis: None,
4599                cyclomatic_complexity: None,
4600                lsloc: None,
4601                commit_count: None,
4602                last_commit_date: None,
4603                ownership: None,
4604                content_hash: 0,
4605            }],
4606            skipped_file_records: vec![],
4607            warnings: vec![],
4608            submodule_summaries: vec![],
4609            git_commit_short: Some("abc1234".into()),
4610            git_branch: Some("main".into()),
4611            git_commit_long: None,
4612            git_commit_author: None,
4613            git_tags: None,
4614            git_nearest_tag: None,
4615            git_commit_date: None,
4616            git_remote_url: None,
4617            style_summary: None,
4618            cocomo: None,
4619            uloc: 0,
4620            dryness_pct: None,
4621            duplicate_groups: vec![],
4622            duplicates_excluded: 0,
4623            authors: Vec::new(),
4624        };
4625        let json_path = dir.path().join("test.json");
4626        write_json(&run, &json_path).unwrap();
4627        let loaded = read_json(&json_path).unwrap();
4628        assert_eq!(loaded.summary_totals.files_analyzed, 1);
4629        assert_eq!(loaded.summary_totals.code_lines, 5);
4630        assert_eq!(loaded.git_commit_short.as_deref(), Some("abc1234"));
4631        assert_eq!(loaded.git_branch.as_deref(), Some("main"));
4632        assert_eq!(loaded.per_file_records.len(), 1);
4633    }
4634
4635    // ── detect_ci_system ─────────────────────────────────────────────────────
4636
4637    #[test]
4638    fn detect_ci_system_returns_none_without_env_vars() {
4639        // Remove known CI env vars so detection returns None
4640        for var in &[
4641            "JENKINS_URL",
4642            "JENKINS_HOME",
4643            "BUILD_URL",
4644            "GITHUB_ACTIONS",
4645            "GITLAB_CI",
4646            "CIRCLECI",
4647            "TRAVIS",
4648            "TF_BUILD",
4649            "TEAMCITY_VERSION",
4650        ] {
4651            // FIXME: Audit that the environment access only happens in single-threaded code.
4652            unsafe { std::env::remove_var(var) };
4653        }
4654        // Result depends on test runner env; just assert no panic
4655        let _ = detect_ci_system();
4656    }
4657
4658    // ── resolve_git_file_pointer ──────────────────────────────────────────────
4659
4660    #[test]
4661    fn resolve_git_file_pointer_valid_absolute_gitdir() {
4662        let dir = tempfile::tempdir().unwrap();
4663        // Create a real target directory (the "real" git dir)
4664        let real_git = dir.path().join("real.git");
4665        fs::create_dir_all(&real_git).unwrap();
4666        // Write a .git file pointing at the real git dir
4667        let git_file = dir.path().join(".git");
4668        fs::write(&git_file, format!("gitdir: {}\n", real_git.display())).unwrap();
4669
4670        let result = resolve_git_file_pointer(&git_file, dir.path());
4671        // Should resolve to the real git dir (or its canonicalized form)
4672        assert!(
4673            result.is_some(),
4674            "should resolve a valid absolute gitdir pointer"
4675        );
4676        assert!(result.unwrap().is_dir());
4677    }
4678
4679    #[test]
4680    fn resolve_git_file_pointer_missing_gitdir_prefix_returns_none() {
4681        let dir = tempfile::tempdir().unwrap();
4682        let git_file = dir.path().join(".git");
4683        fs::write(&git_file, "not a gitdir line\n").unwrap();
4684        assert!(resolve_git_file_pointer(&git_file, dir.path()).is_none());
4685    }
4686
4687    #[test]
4688    fn resolve_git_file_pointer_unreadable_path_returns_none() {
4689        assert!(
4690            resolve_git_file_pointer(
4691                Path::new("/nonexistent/__sloc_test_git_file__"),
4692                Path::new("/nonexistent")
4693            )
4694            .is_none()
4695        );
4696    }
4697
4698    #[test]
4699    fn resolve_git_file_pointer_nonexistent_target_returns_none() {
4700        let dir = tempfile::tempdir().unwrap();
4701        let git_file = dir.path().join(".git");
4702        fs::write(&git_file, "gitdir: /nonexistent/__sloc_fake_gitdir_xyz__\n").unwrap();
4703        // Target does not exist → returns None
4704        assert!(resolve_git_file_pointer(&git_file, dir.path()).is_none());
4705    }
4706
4707    #[test]
4708    fn resolve_git_file_pointer_relative_path() {
4709        let dir = tempfile::tempdir().unwrap();
4710        let real_git = dir.path().join("real_git_dir");
4711        fs::create_dir_all(&real_git).unwrap();
4712        let git_file = dir.path().join(".git");
4713        // Relative path — should be resolved relative to base_dir
4714        fs::write(&git_file, "gitdir: real_git_dir\n").unwrap();
4715        let result = resolve_git_file_pointer(&git_file, dir.path());
4716        assert!(result.is_some());
4717    }
4718
4719    // ── resolve_ref ──────────────────────────────────────────────────────────
4720
4721    #[test]
4722    fn resolve_ref_from_loose_file() {
4723        let dir = tempfile::tempdir().unwrap();
4724        let git_dir = dir.path();
4725        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4726        let sha = "abc1234567890abcdef1234567890abcdef123456";
4727        fs::write(git_dir.join("refs/heads/main"), format!("{sha}\n")).unwrap();
4728
4729        let result = resolve_ref(git_dir, "refs/heads/main");
4730        assert_eq!(result.as_deref(), Some(sha));
4731    }
4732
4733    #[test]
4734    fn resolve_ref_from_packed_refs() {
4735        let dir = tempfile::tempdir().unwrap();
4736        let git_dir = dir.path();
4737        let sha = "def5678def5678def5678def5678def5678def56";
4738        fs::write(
4739            git_dir.join("packed-refs"),
4740            format!("# pack-refs with: peeled fully-peeled sorted\n{sha} refs/heads/feature\n"),
4741        )
4742        .unwrap();
4743
4744        let result = resolve_ref(git_dir, "refs/heads/feature");
4745        assert_eq!(result.as_deref(), Some(sha));
4746    }
4747
4748    #[test]
4749    fn resolve_ref_not_found_returns_none() {
4750        let dir = tempfile::tempdir().unwrap();
4751        let result = resolve_ref(dir.path(), "refs/heads/nonexistent-branch-xyz");
4752        assert!(result.is_none());
4753    }
4754
4755    #[test]
4756    fn resolve_ref_packed_refs_skips_comment_and_peeled() {
4757        let dir = tempfile::tempdir().unwrap();
4758        let git_dir = dir.path();
4759        let sha = "aaa1111aaa1111aaa1111aaa1111aaa1111aaa11";
4760        fs::write(
4761            git_dir.join("packed-refs"),
4762            format!("# comment\n^peeled-object-sha\n{sha} refs/tags/v1.0\n"),
4763        )
4764        .unwrap();
4765
4766        let result = resolve_ref(git_dir, "refs/tags/v1.0");
4767        assert_eq!(result.as_deref(), Some(sha));
4768    }
4769
4770    #[test]
4771    fn resolve_ref_loose_sha_too_short_falls_through_to_packed() {
4772        let dir = tempfile::tempdir().unwrap();
4773        let git_dir = dir.path();
4774        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4775        // Write an invalid (too short) SHA to the loose file
4776        fs::write(git_dir.join("refs/heads/main"), "short\n").unwrap();
4777        // No packed-refs → None
4778        let result = resolve_ref(git_dir, "refs/heads/main");
4779        assert!(result.is_none());
4780    }
4781
4782    // ── read_git_remote_url ───────────────────────────────────────────────────
4783
4784    #[test]
4785    fn read_git_remote_url_parses_origin_url() {
4786        let dir = tempfile::tempdir().unwrap();
4787        let git_dir = dir.path().join(".git");
4788        fs::create_dir_all(&git_dir).unwrap();
4789        fs::write(
4790            git_dir.join("config"),
4791            "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://github.com/org/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
4792        )
4793        .unwrap();
4794        let url = read_git_remote_url(&git_dir);
4795        assert_eq!(url.as_deref(), Some("https://github.com/org/repo.git"));
4796    }
4797
4798    #[test]
4799    fn read_git_remote_url_no_config_returns_none() {
4800        let dir = tempfile::tempdir().unwrap();
4801        let git_dir = dir.path().join(".git");
4802        fs::create_dir_all(&git_dir).unwrap();
4803        // No config file
4804        let url = read_git_remote_url(&git_dir);
4805        assert!(url.is_none());
4806    }
4807
4808    // ── detect_git_for_run — HEAD edge cases ──────────────────────────────────
4809
4810    #[test]
4811    fn detect_git_for_run_no_git_dir_returns_default() {
4812        let dir = tempfile::tempdir().unwrap();
4813        // No .git directory or file
4814        let info = detect_git_for_run(dir.path());
4815        assert!(info.commit_long.is_none());
4816    }
4817
4818    #[test]
4819    fn detect_git_for_run_unreadable_head_returns_default() {
4820        let dir = tempfile::tempdir().unwrap();
4821        let git_dir = dir.path().join(".git");
4822        fs::create_dir_all(&git_dir).unwrap();
4823        // .git directory exists but no HEAD file → read fails → early return
4824        let info = detect_git_for_run(dir.path());
4825        assert!(info.commit_long.is_none());
4826    }
4827
4828    #[test]
4829    fn detect_git_for_run_detached_head_with_sha() {
4830        let dir = tempfile::tempdir().unwrap();
4831        let git_dir = dir.path().join(".git");
4832        fs::create_dir_all(&git_dir).unwrap();
4833        // Exactly 40 hex chars — the code checks len >= 40 and takes [..40]
4834        let sha = "abc1234567890abcdef1234567890abcdef12345";
4835        fs::write(git_dir.join("HEAD"), sha).unwrap();
4836        let info = detect_git_for_run(dir.path());
4837        // Detached HEAD — commit_long should be the first 40 chars of HEAD
4838        assert_eq!(info.commit_long.as_deref(), Some(sha));
4839        assert_eq!(info.commit_short.as_deref(), Some("abc1234"));
4840    }
4841
4842    #[test]
4843    fn detect_git_for_run_with_packed_ref() {
4844        let dir = tempfile::tempdir().unwrap();
4845        let git_dir = dir.path().join(".git");
4846        fs::create_dir_all(&git_dir).unwrap();
4847        // HEAD points to a ref resolved via packed-refs
4848        fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
4849        let sha = "deadbeef00000000000000000000000000000000";
4850        fs::write(
4851            git_dir.join("packed-refs"),
4852            format!("# pack-refs\n{sha} refs/heads/main\n"),
4853        )
4854        .unwrap();
4855        let info = detect_git_for_run(dir.path());
4856        assert_eq!(info.commit_long.as_deref(), Some(sha));
4857        assert_eq!(info.branch.as_deref(), Some("main"));
4858    }
4859
4860    #[test]
4861    fn detect_git_for_run_reads_origin_remote_url() {
4862        // A .git/config with an [remote "origin"] section exercises
4863        // read_git_remote_url + parse_url_line, which the dir-only tests miss.
4864        let dir = tempfile::tempdir().unwrap();
4865        let git_dir = dir.path().join(".git");
4866        fs::create_dir_all(&git_dir).unwrap();
4867        let sha = "deadbeef00000000000000000000000000000000";
4868        fs::write(git_dir.join("HEAD"), sha).unwrap();
4869        fs::write(
4870            git_dir.join("config"),
4871            "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = https://example.com/repo.git\n\tfetch = +refs/heads/*\n",
4872        )
4873        .unwrap();
4874        let info = detect_git_for_run(dir.path());
4875        assert_eq!(
4876            info.remote_url.as_deref(),
4877            Some("https://example.com/repo.git")
4878        );
4879    }
4880
4881    #[test]
4882    fn detect_git_for_run_follows_git_file_worktree_pointer() {
4883        // A worktree/submodule uses a `.git` *file* (not dir) containing
4884        // `gitdir: <path>`. This drives find_git_dir's is_file branch and
4885        // resolve_git_file_pointer, resolving to the real git data directory.
4886        let tmp = tempfile::tempdir().unwrap();
4887        let gitdata = tmp.path().join("gitdata");
4888        fs::create_dir_all(&gitdata).unwrap();
4889        let sha = "abc1234567890abcdef1234567890abcdef12345";
4890        fs::write(gitdata.join("HEAD"), sha).unwrap();
4891
4892        let project = tmp.path().join("project");
4893        fs::create_dir_all(&project).unwrap();
4894        // Forward-slash path is normalised to the OS separator by the resolver.
4895        let pointer = format!("gitdir: {}\n", gitdata.to_string_lossy().replace('\\', "/"));
4896        fs::write(project.join(".git"), pointer).unwrap();
4897
4898        let info = detect_git_for_run(&project);
4899        assert_eq!(info.commit_long.as_deref(), Some(sha));
4900    }
4901
4902    // ── ci_branch_from_env ───────────────────────────────────────────────────
4903
4904    // Note: ci_branch_from_env env-var tests share a mutex to avoid parallel interference.
4905    use std::sync::{Mutex, OnceLock};
4906    static CI_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4907    fn ci_env_lock() -> std::sync::MutexGuard<'static, ()> {
4908        CI_ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
4909    }
4910
4911    fn clear_branch_env_vars() {
4912        for v in &[
4913            "BRANCH_NAME",
4914            "GIT_BRANCH",
4915            "GITHUB_REF_NAME",
4916            "CI_COMMIT_BRANCH",
4917            "CIRCLE_BRANCH",
4918            "TRAVIS_BRANCH",
4919            "BUILD_SOURCEBRANCH",
4920        ] {
4921            // FIXME: Audit that the environment access only happens in single-threaded code.
4922            unsafe { std::env::remove_var(v) };
4923        }
4924    }
4925
4926    #[test]
4927    fn ci_branch_from_env_strips_refs_heads_prefix() {
4928        let _lock = ci_env_lock();
4929        clear_branch_env_vars();
4930        // Azure DevOps sets BUILD_SOURCEBRANCH = "refs/heads/main"
4931        // FIXME: Audit that the environment access only happens in single-threaded code.
4932        unsafe { std::env::set_var("BUILD_SOURCEBRANCH", "refs/heads/my-branch") };
4933        let branch = ci_branch_from_env();
4934        clear_branch_env_vars();
4935        assert_eq!(branch.as_deref(), Some("my-branch"));
4936    }
4937
4938    #[test]
4939    fn ci_branch_from_env_strips_origin_prefix() {
4940        let _lock = ci_env_lock();
4941        clear_branch_env_vars();
4942        // FIXME: Audit that the environment access only happens in single-threaded code.
4943        unsafe { std::env::set_var("GIT_BRANCH", "origin/develop") };
4944        let branch = ci_branch_from_env();
4945        clear_branch_env_vars();
4946        assert_eq!(branch.as_deref(), Some("develop"));
4947    }
4948
4949    #[test]
4950    fn ci_branch_from_env_returns_none_for_head() {
4951        let _lock = ci_env_lock();
4952        clear_branch_env_vars();
4953        // "HEAD" is filtered out; with no other vars, should return None
4954        // FIXME: Audit that the environment access only happens in single-threaded code.
4955        unsafe { std::env::set_var("BRANCH_NAME", "HEAD") };
4956        let branch = ci_branch_from_env();
4957        clear_branch_env_vars();
4958        // HEAD value is filtered → None (or falls through to other vars, but all cleared)
4959        assert!(branch.is_none(), "HEAD should be filtered, got: {branch:?}");
4960    }
4961
4962    // --- Multiple-repository detection -------------------------------------
4963
4964    /// Create `dir/.git/` so `is_git_root(dir)` is true (plain repo marker).
4965    fn make_git_dir(dir: &Path) {
4966        fs::create_dir_all(dir.join(".git")).unwrap();
4967    }
4968
4969    #[test]
4970    fn multi_repo_dir_warns() {
4971        let tmp = tempfile::tempdir().unwrap();
4972        let root = tmp.path();
4973        for name in ["repo-a", "repo-b", "repo-c"] {
4974            make_git_dir(&root.join(name));
4975        }
4976        let layout = detect_repository_layout(root);
4977        assert!(!layout.root_is_repo);
4978        assert_eq!(layout.nested_repos.len(), 3);
4979        assert!(layout.has_multiple_repos());
4980    }
4981
4982    #[test]
4983    fn repo_with_submodules_does_not_warn() {
4984        let tmp = tempfile::tempdir().unwrap();
4985        let root = tmp.path();
4986        make_git_dir(root);
4987        fs::write(
4988            root.join(".gitmodules"),
4989            "[submodule \"vendor/json\"]\n\tpath = vendor/json\n\turl = https://example/json.git\n\
4990             [submodule \"vendor/gtest\"]\n\tpath = vendor/gtest\n\turl = https://example/gtest.git\n",
4991        )
4992        .unwrap();
4993        // Submodule working trees carry a `.git` *file* pointer, but we prune them
4994        // by declared path regardless, so a plain marker dir is enough here.
4995        make_git_dir(&root.join("vendor/json"));
4996        make_git_dir(&root.join("vendor/gtest"));
4997        let layout = detect_repository_layout(root);
4998        assert!(layout.root_is_repo);
4999        assert!(layout.nested_repos.is_empty());
5000        assert!(!layout.has_multiple_repos());
5001    }
5002
5003    #[test]
5004    fn format_multi_repo_warning_root_repo_singular_and_truncated() {
5005        // root_is_repo=true with a single nested repo → the singular "repository"
5006        // wording. The dir-layout tests never format this branch.
5007        let one = RepositoryLayout {
5008            root: PathBuf::from("/proj"),
5009            root_is_repo: true,
5010            submodule_paths: vec![],
5011            nested_repos: vec![PathBuf::from("vendor/foreign")],
5012        };
5013        let msg = format_multi_repo_warning(&one);
5014        assert!(
5015            msg.contains("1 nested git repository"),
5016            "singular wording: {msg}"
5017        );
5018        assert!(!msg.contains("repositories"), "must not pluralise: {msg}");
5019
5020        // root_is_repo=true with more than MAX_LISTED (5) nested repos → the
5021        // "… and N more" truncation plus plural wording.
5022        let many = RepositoryLayout {
5023            root: PathBuf::from("/proj"),
5024            root_is_repo: true,
5025            submodule_paths: vec![],
5026            nested_repos: (0..7)
5027                .map(|i| PathBuf::from(format!("nested-{i}")))
5028                .collect(),
5029        };
5030        let msg = format_multi_repo_warning(&many);
5031        assert!(
5032            msg.contains("7 nested git repositories"),
5033            "plural wording: {msg}"
5034        );
5035        assert!(
5036            msg.contains("and 2 more"),
5037            "must truncate the listed set: {msg}"
5038        );
5039    }
5040
5041    #[test]
5042    fn repo_with_vendored_foreign_repo_warns() {
5043        let tmp = tempfile::tempdir().unwrap();
5044        let root = tmp.path();
5045        make_git_dir(root); // root is a repo
5046        make_git_dir(&root.join("vendor/foreign")); // not a declared submodule
5047        let layout = detect_repository_layout(root);
5048        assert!(layout.root_is_repo);
5049        assert_eq!(layout.nested_repos, vec![PathBuf::from("vendor/foreign")]);
5050        assert!(layout.has_multiple_repos());
5051    }
5052
5053    #[test]
5054    fn single_plain_dir_no_warn() {
5055        let tmp = tempfile::tempdir().unwrap();
5056        let root = tmp.path();
5057        fs::create_dir_all(root.join("src")).unwrap();
5058        fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
5059        let layout = detect_repository_layout(root);
5060        assert!(!layout.root_is_repo);
5061        assert!(layout.nested_repos.is_empty());
5062        assert!(!layout.has_multiple_repos());
5063    }
5064
5065    #[test]
5066    fn analyze_surfaces_multi_repo_warning() {
5067        let tmp = tempfile::tempdir().unwrap();
5068        let root = tmp.path();
5069        for name in ["repo-a", "repo-b"] {
5070            let repo = root.join(name);
5071            make_git_dir(&repo);
5072            fs::write(repo.join("main.rs"), "fn main() {}\n").unwrap();
5073        }
5074        let mut config = AppConfig::default();
5075        config.discovery.root_paths = vec![root.to_path_buf()];
5076        let run = analyze(&config, "analyze", None, None).unwrap();
5077        assert!(
5078            run.warnings
5079                .iter()
5080                .any(|w| w.contains("independent git repositories")),
5081            "expected multi-repo warning, got: {:?}",
5082            run.warnings
5083        );
5084    }
5085}