1#![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
44const MAX_ANALYSIS_THREADS: usize = 16;
48const DEFAULT_ANALYSIS_THREADS: usize = 4;
50const GENERATED_SAMPLE_BYTES: usize = 1024;
52const MINIFIED_SAMPLE_BYTES: usize = 4096;
54const MINIFIED_LINE_THRESHOLD: usize = 2000;
56const BINARY_SAMPLE_BYTES: usize = 8192;
58
59pub struct ProgressCounters {
61 pub files_done: Arc<AtomicUsize>,
63 pub files_total: Arc<AtomicUsize>,
65}
66
67enum MetadataPolicyOutcome {
69 Skip(Box<FileRecord>),
71 Exclude,
73 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
91#[serde(rename_all = "snake_case")]
92pub enum CocomoMode {
93 #[default]
95 Organic,
96 SemiDetached,
98 Embedded,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CocomoEstimate {
105 pub mode: CocomoMode,
106 pub ksloc: f64,
108 pub effort_person_months: f64,
110 pub duration_months: f64,
112 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 #[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 #[serde(default)]
167 pub test_assertion_count: u64,
168 #[serde(default)]
170 pub test_suite_count: u64,
171 #[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 #[serde(default)]
186 pub cyclomatic_complexity: u64,
187 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub coverage: Option<FileCoverage>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub style_analysis: Option<StyleAnalysis>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub cyclomatic_complexity: Option<u32>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub lsloc: Option<u32>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub commit_count: Option<u32>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub last_commit_date: Option<String>,
270 #[serde(skip)]
273 pub content_hash: u64,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct LanguageStyleGroup {
279 pub language_family: String,
281 pub files_count: u32,
283 pub dominant_guide: String,
285 pub dominant_score_pct: u8,
287 pub common_indent_style: String,
289 pub guide_avg_scores: Vec<(String, u8)>,
291 pub line80_compliant_pct: u8,
293 pub line_col_compliant_pct: u8,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct StyleSummary {
300 pub files_analyzed: u32,
302 pub common_indent_style: String,
304 pub line80_compliant_pct: u8,
306 pub line_col_compliant_pct: u8,
308 pub col_threshold: u16,
310 pub by_language: Vec<LanguageStyleGroup>,
312}
313
314pub type CppStyleSummary = StyleSummary;
317
318#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub git_commit_short: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub git_commit_long: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub git_branch: Option<String>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub git_commit_author: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub git_commit_date: Option<String>,
344 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
362 pub submodule_summaries: Vec<SubmoduleSummary>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub git_commit_short: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub git_commit_long: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub git_branch: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub git_commit_author: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub git_tags: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub git_nearest_tag: Option<String>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub git_commit_date: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub git_remote_url: Option<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub style_summary: Option<StyleSummary>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub cocomo: Option<CocomoEstimate>,
393 #[serde(default)]
395 pub uloc: u64,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub dryness_pct: Option<f32>,
399 #[serde(default, skip_serializing_if = "Vec::is_empty")]
401 pub duplicate_groups: Vec<Vec<String>>,
402 #[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
419fn 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
430fn 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
450fn 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 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 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
475fn resolve_ref(git_dir: &Path, refname: &str) -> Option<String> {
478 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 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
511fn 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
523fn 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
540fn detect_git_for_run(project_path: &Path) -> GitInfo {
544 let ci_branch = ci_branch_from_env();
546
547 let Some(git_dir) = find_git_dir(project_path) else {
548 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 (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 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 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
617fn run_git_cmd(dir: &Path, args: &[&str]) -> Option<String> {
619 let candidates: &[&str] = &[
623 "git",
625 "/usr/bin/git",
627 "/usr/local/bin/git",
628 "/opt/homebrew/bin/git",
629 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
652fn 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 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
680fn 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 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(¤t_date);
710 }
711 }
712 map
713}
714
715fn 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
743fn ci_branch_from_env() -> Option<String> {
746 const VARS: &[&str] = &[
747 "BRANCH_NAME", "GIT_BRANCH", "GITHUB_REF_NAME", "CI_COMMIT_BRANCH", "CIRCLE_BRANCH", "TRAVIS_BRANCH", "BUILD_SOURCEBRANCH", ];
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 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#[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 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(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#[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 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 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
1014fn 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#[allow(clippy::cast_precision_loss)] fn 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 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#[allow(clippy::cast_precision_loss)] fn 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
1085fn 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
1110fn 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 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 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#[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 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#[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#[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
1441fn 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
1506fn 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 let _ = (path, root, size_bytes); 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 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 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 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 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 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 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
1811const 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
1839fn 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#[derive(Debug, Clone, Default)]
1934pub struct RepositoryLayout {
1935 pub root: PathBuf,
1937 pub root_is_repo: bool,
1939 pub submodule_paths: Vec<PathBuf>,
1941 pub nested_repos: Vec<PathBuf>,
1943}
1944
1945impl RepositoryLayout {
1946 #[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
1961const REPO_SCAN_MAX_DEPTH: usize = 6;
1963const REPO_SCAN_MAX_DIRS: usize = 4000;
1966
1967#[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 let submodule_dirs: HashSet<PathBuf> = layout
1987 .submodule_paths
1988 .iter()
1989 .map(|rel| root.join(rel))
1990 .collect();
1991
1992 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 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; }
2016 if is_git_root(&child) {
2017 layout.nested_repos.push(relative_path_buf(&child, root));
2018 continue; }
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
2030fn relative_path_buf(path: &Path, root: &Path) -> PathBuf {
2032 path.strip_prefix(root).unwrap_or(path).to_path_buf()
2033}
2034
2035fn 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#[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#[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#[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#[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#[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 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#[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 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
2402fn 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 #[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
2476pub 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
2485pub 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 #[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 let bytes = b"// Code generated by build.rs. DO NOT EDIT.\nuse foo;";
2566 assert!(looks_generated(Path::new("foo.rs"), bytes));
2567 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 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 #[test]
2667 fn decode_bytes_utf16le_bom() {
2668 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 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 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 #[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 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 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 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, ..RawLineCounts::default()
2763 };
2764 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, false);
2765 assert_eq!(counts.code_lines, 0); }
2767
2768 #[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 assert!((est.effort_person_months - 0.0).abs() < 0.01);
2802 }
2803
2804 #[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 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 #[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 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 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 #[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 let path = Path::new("/other/dir/file.rs");
2913 let root = Path::new("/tmp/project");
2914 let rel = relative_path_string(path, root);
2915 assert!(!rel.is_empty());
2917 }
2918
2919 #[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 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 #[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 #[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 #[test]
3133 fn detect_ci_system_returns_none_without_env_vars() {
3134 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 let _ = detect_ci_system();
3150 }
3151
3152 #[test]
3155 fn resolve_git_file_pointer_valid_absolute_gitdir() {
3156 let dir = tempfile::tempdir().unwrap();
3157 let real_git = dir.path().join("real.git");
3159 fs::create_dir_all(&real_git).unwrap();
3160 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 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 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 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 #[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 fs::write(git_dir.join("refs/heads/main"), "short\n").unwrap();
3269 let result = resolve_ref(git_dir, "refs/heads/main");
3271 assert!(result.is_none());
3272 }
3273
3274 #[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 let url = read_git_remote_url(&git_dir);
3297 assert!(url.is_none());
3298 }
3299
3300 #[test]
3303 fn detect_git_for_run_no_git_dir_returns_default() {
3304 let dir = tempfile::tempdir().unwrap();
3305 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 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 let sha = "abc1234567890abcdef1234567890abcdef12345";
3327 fs::write(git_dir.join("HEAD"), sha).unwrap();
3328 let info = detect_git_for_run(dir.path());
3329 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 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 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 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 std::env::set_var("BRANCH_NAME", "HEAD");
3402 let branch = ci_branch_from_env();
3403 clear_branch_env_vars();
3404 assert!(branch.is_none(), "HEAD should be filtered, got: {branch:?}");
3406 }
3407
3408 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 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); make_git_dir(&root.join("vendor/foreign")); 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}