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