Skip to main content

sloc_core/
lib.rs

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