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