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