Skip to main content

sloc_core/
lib.rs

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