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