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::{BaselineEntry, BaselineStore, check_against_baseline, resolve_baselines_path};
11pub use coverage::{FileCoverage, aggregate_line_coverage, lookup_coverage, parse_lcov};
12pub use delta::{
13 FileChangeStatus, FileDelta, MultiFileDelta, MultiScanComparison, MultiScanPoint,
14 ScanComparison, SummaryDelta, compute_delta, compute_multi_delta,
15};
16pub use history::{
17 CleanupPolicy, CleanupPolicyStore, RegistryEntry, ScanRegistry, ScanSummarySnapshot,
18 WatchedDirsStore,
19};
20pub use maintenance::{
21 PrunePlan, PruneReport, PrunedRun, copy_tree, dir_size_bytes, execute_run_prune,
22 plan_run_prune, resolve_output_root, resolve_registry_path, rotate_log, rotated_log_paths,
23 run_output_dir,
24};
25
26use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
31
32use anyhow::{Context, Result};
33use chrono::{DateTime, Utc};
34use encoding_rs::{UTF_16BE, UTF_16LE, WINDOWS_1252};
35use globset::{Glob, GlobSet, GlobSetBuilder};
36use ignore::WalkBuilder;
37use serde::{Deserialize, Serialize};
38use uuid::Uuid;
39
40use sloc_config::{
41 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
42 FailureBehavior, MixedLinePolicy,
43};
44use sloc_languages::style::IndentStyle;
45use sloc_languages::{
46 AnalysisOptions, Language, LineCategory, ParseMode, RawLineCounts, StyleAnalysis,
47 StyleLangScope, analyze_text, classify_physical_lines, detect_language, supported_languages,
48};
49
50const MAX_ANALYSIS_THREADS: usize = 16;
54const DEFAULT_ANALYSIS_THREADS: usize = 4;
56const GENERATED_SAMPLE_BYTES: usize = 1024;
58const MINIFIED_SAMPLE_BYTES: usize = 4096;
60const MINIFIED_LINE_THRESHOLD: usize = 2000;
62const BINARY_SAMPLE_BYTES: usize = 8192;
64
65pub struct ProgressCounters {
67 pub files_done: Arc<AtomicUsize>,
69 pub files_total: Arc<AtomicUsize>,
71}
72
73enum MetadataPolicyOutcome {
75 Skip(Box<FileRecord>),
77 Exclude,
79 Continue,
81}
82
83#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum FileStatus {
86 AnalyzedExact,
87 AnalyzedBestEffort,
88 SkippedBinary,
89 SkippedDecodeError,
90 SkippedUnsupported,
91 SkippedByPolicy,
92 ErrorInternal,
93}
94
95#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum CocomoMode {
99 #[default]
101 Organic,
102 SemiDetached,
104 Embedded,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CocomoEstimate {
111 pub mode: CocomoMode,
112 pub ksloc: f64,
114 pub effort_person_months: f64,
116 pub duration_months: f64,
118 pub avg_staff: f64,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct EffectiveCounts {
124 pub code_lines: u64,
125 pub comment_lines: u64,
126 pub blank_lines: u64,
127 pub mixed_lines_separate: u64,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ToolMetadata {
132 pub name: String,
133 pub version: String,
134 pub run_id: String,
135 pub timestamp_utc: DateTime<Utc>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct EnvironmentMetadata {
140 pub operating_system: String,
141 pub architecture: String,
142 pub runtime_mode: String,
143 pub initiator_username: String,
144 pub initiator_hostname: String,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub ci_name: Option<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, Default)]
152pub struct SummaryTotals {
153 pub files_considered: u64,
154 pub files_analyzed: u64,
155 pub files_skipped: u64,
156 pub total_physical_lines: u64,
157 pub code_lines: u64,
158 pub comment_lines: u64,
159 pub blank_lines: u64,
160 pub mixed_lines_separate: u64,
161 #[serde(default)]
162 pub functions: u64,
163 #[serde(default)]
164 pub classes: u64,
165 #[serde(default)]
166 pub variables: u64,
167 #[serde(default)]
169 pub variables_member: u64,
170 #[serde(default)]
171 pub variables_local: u64,
172 #[serde(default)]
173 pub variables_global: u64,
174 #[serde(default)]
175 pub macro_definitions: u64,
176 #[serde(default)]
177 pub imports: u64,
178 #[serde(default)]
179 pub test_count: u64,
180 #[serde(default)]
182 pub test_assertion_count: u64,
183 #[serde(default)]
185 pub test_suite_count: u64,
186 #[serde(default)]
188 pub coverage_lines_found: u64,
189 #[serde(default)]
190 pub coverage_lines_hit: u64,
191 #[serde(default)]
192 pub coverage_functions_found: u64,
193 #[serde(default)]
194 pub coverage_functions_hit: u64,
195 #[serde(default)]
196 pub coverage_branches_found: u64,
197 #[serde(default)]
198 pub coverage_branches_hit: u64,
199 #[serde(default)]
201 pub cyclomatic_complexity: u64,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub lsloc: Option<u64>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct LanguageSummary {
209 pub language: Language,
210 pub files: u64,
211 pub total_physical_lines: u64,
212 pub code_lines: u64,
213 pub comment_lines: u64,
214 pub blank_lines: u64,
215 pub mixed_lines_separate: u64,
216 #[serde(default)]
217 pub functions: u64,
218 #[serde(default)]
219 pub classes: u64,
220 #[serde(default)]
221 pub variables: u64,
222 #[serde(default)]
224 pub variables_member: u64,
225 #[serde(default)]
226 pub variables_local: u64,
227 #[serde(default)]
228 pub variables_global: u64,
229 #[serde(default)]
230 pub macro_definitions: u64,
231 #[serde(default)]
232 pub imports: u64,
233 #[serde(default)]
234 pub test_count: u64,
235 #[serde(default)]
236 pub test_assertion_count: u64,
237 #[serde(default)]
238 pub test_suite_count: u64,
239 #[serde(default)]
240 pub coverage_lines_found: u64,
241 #[serde(default)]
242 pub coverage_lines_hit: u64,
243 #[serde(default)]
244 pub coverage_functions_found: u64,
245 #[serde(default)]
246 pub coverage_functions_hit: u64,
247 #[serde(default)]
248 pub coverage_branches_found: u64,
249 #[serde(default)]
250 pub coverage_branches_hit: u64,
251 #[serde(default)]
252 pub cyclomatic_complexity: u64,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub lsloc: Option<u64>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct FileRecord {
259 pub path: String,
260 pub relative_path: String,
261 pub language: Option<Language>,
262 pub size_bytes: u64,
263 pub detected_encoding: Option<String>,
264 pub raw_line_categories: RawLineCounts,
265 pub effective_counts: EffectiveCounts,
266 pub status: FileStatus,
267 pub warnings: Vec<String>,
268 pub generated: bool,
269 pub minified: bool,
270 pub vendor: bool,
271 pub parse_mode: Option<ParseMode>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub submodule: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub coverage: Option<FileCoverage>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub style_analysis: Option<StyleAnalysis>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub cyclomatic_complexity: Option<u32>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub lsloc: Option<u32>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub commit_count: Option<u32>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub last_commit_date: Option<String>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub ownership: Option<Vec<FileOwnership>>,
299 #[serde(skip)]
302 pub content_hash: u64,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct LanguageStyleGroup {
308 pub language_family: String,
310 pub files_count: u32,
312 pub dominant_guide: String,
314 pub dominant_score_pct: u8,
316 pub common_indent_style: String,
318 pub guide_avg_scores: Vec<(String, u8)>,
320 pub line80_compliant_pct: u8,
322 pub line_col_compliant_pct: u8,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct StyleSummary {
329 pub files_analyzed: u32,
331 pub common_indent_style: String,
333 pub line80_compliant_pct: u8,
335 pub line_col_compliant_pct: u8,
337 pub col_threshold: u16,
339 pub by_language: Vec<LanguageStyleGroup>,
341}
342
343pub type CppStyleSummary = StyleSummary;
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct SubmoduleSummary {
350 pub name: String,
351 pub relative_path: String,
352 pub files_analyzed: u64,
353 pub total_physical_lines: u64,
354 pub code_lines: u64,
355 pub comment_lines: u64,
356 pub blank_lines: u64,
357 pub language_summaries: Vec<LanguageSummary>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub git_commit_short: Option<String>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub git_commit_long: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub git_branch: Option<String>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub git_commit_author: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub git_commit_date: Option<String>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub git_remote_url: Option<String>,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
381pub struct RawIdentity {
382 pub name: String,
383 pub email: String,
384}
385
386#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
389pub struct AuthorLineCounts {
390 pub code_lines: u64,
391 pub comment_lines: u64,
392 pub blank_lines: u64,
393 pub total_lines: u64,
394}
395
396impl AuthorLineCounts {
397 fn add_category(&mut self, cat: LineCategory) {
398 match cat {
399 LineCategory::Code => self.code_lines += 1,
400 LineCategory::Comment => self.comment_lines += 1,
401 LineCategory::Blank => self.blank_lines += 1,
402 }
403 self.total_lines += 1;
404 }
405
406 fn add(&mut self, other: &AuthorLineCounts) {
407 self.code_lines += other.code_lines;
408 self.comment_lines += other.comment_lines;
409 self.blank_lines += other.blank_lines;
410 self.total_lines += other.total_lines;
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct FileOwnership {
417 pub author_id: u32,
418 pub counts: AuthorLineCounts,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct Author {
427 pub id: u32,
429 pub canonical_name: String,
430 pub canonical_email: String,
431 pub aliases: Vec<RawIdentity>,
433 pub counts: AuthorLineCounts,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct AnalysisRun {
439 pub tool: ToolMetadata,
440 pub environment: EnvironmentMetadata,
441 pub effective_configuration: AppConfig,
442 pub input_roots: Vec<String>,
443 pub summary_totals: SummaryTotals,
444 pub totals_by_language: Vec<LanguageSummary>,
445 pub per_file_records: Vec<FileRecord>,
446 pub skipped_file_records: Vec<FileRecord>,
447 pub warnings: Vec<String>,
448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
450 pub submodule_summaries: Vec<SubmoduleSummary>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub git_commit_short: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub git_commit_long: Option<String>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub git_branch: Option<String>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub git_commit_author: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub git_tags: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub git_nearest_tag: Option<String>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub git_commit_date: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub git_remote_url: Option<String>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub style_summary: Option<StyleSummary>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub cocomo: Option<CocomoEstimate>,
481 #[serde(default)]
483 pub uloc: u64,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub dryness_pct: Option<f32>,
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
489 pub duplicate_groups: Vec<Vec<String>>,
490 #[serde(default)]
492 pub duplicates_excluded: usize,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
497 pub authors: Vec<Author>,
498}
499
500#[derive(Default)]
501struct GitInfo {
502 commit_short: Option<String>,
503 commit_long: Option<String>,
504 branch: Option<String>,
505 author: Option<String>,
506 tags: Option<String>,
507 nearest_tag: Option<String>,
508 commit_date: Option<String>,
509 remote_url: Option<String>,
510}
511
512fn is_git_root(dir: &Path) -> bool {
516 let candidate = dir.join(".git");
517 if candidate.is_dir() {
518 return true;
519 }
520 candidate.is_file() && resolve_git_file_pointer(&candidate, dir).is_some()
521}
522
523fn find_git_dir(start: &Path) -> Option<PathBuf> {
527 let mut current = Some(start);
528 while let Some(dir) = current {
529 let candidate = dir.join(".git");
530 if candidate.is_dir() {
531 return Some(candidate);
532 }
533 if candidate.is_file()
534 && let Some(resolved) = resolve_git_file_pointer(&candidate, dir)
535 {
536 return Some(resolved);
537 }
538 current = dir.parent();
539 }
540 None
541}
542
543fn resolve_git_file_pointer(file: &Path, base_dir: &Path) -> Option<PathBuf> {
547 let content = fs::read_to_string(file).ok()?;
548 let ptr = content.trim().strip_prefix("gitdir: ")?;
549 let ptr_native = ptr.replace('/', std::path::MAIN_SEPARATOR_STR);
552 let resolved = if Path::new(&ptr_native).is_absolute() {
553 PathBuf::from(&ptr_native)
554 } else {
555 base_dir.join(&ptr_native)
556 };
557 let final_path = resolved.canonicalize().unwrap_or(resolved);
561 if final_path.is_dir() {
562 Some(final_path)
563 } else {
564 None
565 }
566}
567
568fn resolve_ref(git_dir: &Path, refname: &str) -> Option<String> {
571 let ref_path = refname
575 .split('/')
576 .fold(git_dir.to_path_buf(), |p, c| p.join(c));
577 if ref_path.exists() {
578 let sha = fs::read_to_string(&ref_path)
579 .ok()
580 .map(|s| s.trim().to_string())
581 .filter(|s| s.len() >= 40 && s.chars().all(|c| c.is_ascii_hexdigit()));
582 if sha.is_some() {
583 return sha;
584 }
585 }
586 let packed = fs::read_to_string(git_dir.join("packed-refs")).ok()?;
590 for line in packed.lines() {
591 if line.starts_with('#') || line.starts_with('^') {
592 continue;
593 }
594 let mut cols = line.splitn(2, ' ');
595 let sha = cols.next()?;
596 let name = cols.next()?.trim();
597 if name == refname {
598 return Some(sha.to_string());
599 }
600 }
601 None
602}
603
604fn parse_url_line(line: &str) -> Option<&str> {
606 let rest = line.strip_prefix("url")?;
607 let rest = rest.trim_start_matches([' ', '\t']);
608 let url = rest.strip_prefix('=')?.trim();
609 if url.is_empty() { None } else { Some(url) }
610}
611
612fn read_git_remote_url(git_dir: &Path) -> Option<String> {
614 let config = fs::read_to_string(git_dir.join("config")).ok()?;
615 let mut in_origin = false;
616 for line in config.lines() {
617 let trimmed = line.trim();
618 if trimmed.starts_with('[') {
619 in_origin = trimmed == r#"[remote "origin"]"#;
620 } else if in_origin && let Some(url) = parse_url_line(trimmed) {
621 return Some(url.to_owned());
622 }
623 }
624 None
625}
626
627fn detect_git_for_run(project_path: &Path) -> GitInfo {
631 let ci_branch = ci_branch_from_env();
633
634 let Some(git_dir) = find_git_dir(project_path) else {
635 return GitInfo {
638 branch: ci_branch,
639 ..GitInfo::default()
640 };
641 };
642
643 let head_raw = match fs::read_to_string(git_dir.join("HEAD")) {
644 Ok(s) => s.trim().to_string(),
645 Err(_) => {
646 return GitInfo {
647 branch: ci_branch,
648 ..GitInfo::default()
649 };
650 }
651 };
652
653 let (branch_from_head, commit_long) = head_raw.strip_prefix("ref: ").map_or_else(
654 || {
655 if head_raw.len() >= 40 && head_raw.chars().all(|c| c.is_ascii_hexdigit()) {
656 (None, Some(head_raw[..40].to_string()))
658 } else {
659 (None, None)
660 }
661 },
662 |refname| {
663 let branch = refname
664 .strip_prefix("refs/heads/")
665 .map(|b| b.trim().to_string());
666 let sha = resolve_ref(&git_dir, refname.trim());
667 (branch, sha)
668 },
669 );
670 let branch = branch_from_head.or(ci_branch);
673
674 let commit_short = commit_long
675 .as_deref()
676 .map(|s| s.chars().take(7).collect::<String>());
677
678 let author = run_git_cmd(project_path, &["log", "-1", "--format=%an", "HEAD"]);
679 let commit_date = run_git_cmd(project_path, &["log", "-1", "--format=%aI", "HEAD"]);
680 let remote_url = read_git_remote_url(&git_dir);
681
682 let tags = run_git_cmd(project_path, &["tag", "--points-at", "HEAD"]).map(|t| {
685 t.lines()
686 .filter(|l| !l.is_empty())
687 .collect::<Vec<_>>()
688 .join(", ")
689 });
690 let nearest_tag = run_git_cmd(project_path, &["describe", "--tags", "--abbrev=0", "HEAD"]);
691
692 GitInfo {
693 commit_short,
694 commit_long,
695 branch,
696 author,
697 tags,
698 nearest_tag,
699 commit_date,
700 remote_url,
701 }
702}
703
704fn run_git_cmd(dir: &Path, args: &[&str]) -> Option<String> {
706 let candidates: &[&str] = &[
710 "git",
712 "/usr/bin/git",
714 "/usr/local/bin/git",
715 "/opt/homebrew/bin/git",
716 r"C:\Program Files\Git\cmd\git.exe",
718 r"C:\Program Files\Git\bin\git.exe",
719 r"C:\Program Files (x86)\Git\cmd\git.exe",
720 ];
721 for &exe in candidates {
722 let result = std::process::Command::new(exe)
723 .args(["-c", "safe.directory=*"])
724 .args(args)
725 .current_dir(dir)
726 .output()
727 .ok()
728 .filter(|o| o.status.success())
729 .and_then(|o| String::from_utf8(o.stdout).ok())
730 .map(|s| s.trim().to_string())
731 .filter(|s| !s.is_empty());
732 if result.is_some() {
733 return result;
734 }
735 }
736 None
737}
738
739fn detect_file_activity(
744 project_path: &Path,
745 window_days: u32,
746) -> HashMap<String, (u32, Option<String>)> {
747 let since = format!("--since={window_days} days ago");
748 let out = run_git_cmd(
752 project_path,
753 &[
754 "-c",
755 "core.quotepath=false",
756 "log",
757 since.as_str(),
758 "--no-merges",
759 "--name-status",
760 "--relative",
761 "--pretty=format:%x00%aI",
762 ],
763 );
764 out.map(|s| parse_activity_log(&s)).unwrap_or_default()
765}
766
767fn parse_activity_log(out: &str) -> HashMap<String, (u32, Option<String>)> {
771 let mut map: HashMap<String, (u32, Option<String>)> = HashMap::new();
772 let mut current_date: Option<String> = None;
773 for line in out.lines() {
774 if let Some(date) = line.strip_prefix('\u{0}') {
775 let d = date.trim();
776 current_date = (!d.is_empty()).then(|| d.to_owned());
777 continue;
778 }
779 if line.trim().is_empty() {
780 continue;
781 }
782 let mut fields = line.split('\t');
784 let status = fields.next().unwrap_or("");
785 let path = if status.starts_with('R') || status.starts_with('C') {
786 fields.next_back()
787 } else {
788 fields.next()
789 };
790 let Some(path) = path.map(str::trim).filter(|p| !p.is_empty()) else {
791 continue;
792 };
793 let entry = map.entry(path.to_owned()).or_insert((0, None));
794 entry.0 += 1;
795 if entry.1.is_none() {
796 entry.1.clone_from(¤t_date);
797 }
798 }
799 map
800}
801
802fn attribute_ownership(root: &Path, records: &mut [FileRecord]) -> Vec<Author> {
810 let mut resolver = AuthorResolver::default();
811
812 for rec in records.iter_mut() {
813 let Some(language) = rec.language else {
814 continue;
815 };
816 let Ok(bytes) = std::fs::read(&rec.path) else {
818 continue;
819 };
820 let text = String::from_utf8_lossy(&bytes);
821 let categories = classify_physical_lines(language, &text);
822 let blame = blame_line_identities(root, &rec.relative_path);
823 if blame.is_empty() {
824 continue;
825 }
826
827 let mut per_file: HashMap<u32, AuthorLineCounts> = HashMap::new();
829 for (category, ident) in categories.iter().zip(blame.iter()) {
830 let id = resolver.resolve(ident);
831 per_file.entry(id).or_default().add_category(*category);
832 }
833
834 let mut ownership: Vec<FileOwnership> = per_file
835 .into_iter()
836 .map(|(author_id, counts)| {
837 resolver.authors[author_id as usize].counts.add(&counts);
838 FileOwnership { author_id, counts }
839 })
840 .collect();
841 ownership.sort_by_key(|entry| std::cmp::Reverse(entry.counts.total_lines));
842 rec.ownership = Some(ownership);
843 }
844
845 resolver.finish(records)
846}
847
848#[derive(Default)]
850struct AuthorResolver {
851 authors: Vec<Author>,
852 key_to_id: HashMap<String, u32>,
854 seen_aliases: Vec<HashSet<RawIdentity>>,
856}
857
858impl AuthorResolver {
859 fn resolve(&mut self, ident: &RawIdentity) -> u32 {
860 let key = normalize_email_key(ident);
861 if let Some(&id) = self.key_to_id.get(&key) {
862 if self.seen_aliases[id as usize].insert(ident.clone()) {
863 self.authors[id as usize].aliases.push(ident.clone());
864 }
865 return id;
866 }
867 let id = self.authors.len() as u32;
868 let canonical_name = if ident.name.trim().is_empty() {
869 ident.email.clone()
870 } else {
871 ident.name.clone()
872 };
873 self.authors.push(Author {
874 id,
875 canonical_name,
876 canonical_email: ident.email.clone(),
877 aliases: vec![ident.clone()],
878 counts: AuthorLineCounts::default(),
879 });
880 self.seen_aliases.push(HashSet::from([ident.clone()]));
881 self.key_to_id.insert(key, id);
882 id
883 }
884
885 fn finish(self, records: &mut [FileRecord]) -> Vec<Author> {
888 let mut order: Vec<usize> = (0..self.authors.len()).collect();
889 order.sort_by(|&a, &b| {
890 self.authors[b]
891 .counts
892 .code_lines
893 .cmp(&self.authors[a].counts.code_lines)
894 .then_with(|| {
895 self.authors[a]
896 .canonical_name
897 .cmp(&self.authors[b].canonical_name)
898 })
899 });
900 let mut remap = vec![0u32; self.authors.len()];
901 for (new_id, &old) in order.iter().enumerate() {
902 remap[old] = new_id as u32;
903 }
904 for rec in records.iter_mut() {
905 if let Some(ownership) = rec.ownership.as_mut() {
906 for entry in ownership.iter_mut() {
907 entry.author_id = remap[entry.author_id as usize];
908 }
909 }
910 }
911 let mut sorted: Vec<Author> = order.iter().map(|&old| self.authors[old].clone()).collect();
912 for (new_id, author) in sorted.iter_mut().enumerate() {
913 author.id = new_id as u32;
914 }
915 sorted
916 }
917}
918
919fn normalize_email_key(ident: &RawIdentity) -> String {
924 let email = ident.email.trim().to_lowercase();
925 if email.is_empty() || email == "not.committed.yet" || !email.contains('@') {
926 return format!("name:{}", ident.name.trim().to_lowercase());
927 }
928 match email.split_once('@') {
930 Some((local, domain)) => {
931 let core = local.split('+').next().unwrap_or(local);
932 format!("{core}@{domain}")
933 }
934 None => email,
935 }
936}
937
938fn blame_line_identities(root: &Path, rel: &str) -> Vec<RawIdentity> {
943 run_git_cmd(
944 root,
945 &["blame", "--line-porcelain", "-w", "-M", "-C", "--", rel],
946 )
947 .map(|out| parse_blame_porcelain(&out))
948 .unwrap_or_default()
949}
950
951fn parse_blame_porcelain(out: &str) -> Vec<RawIdentity> {
955 let mut identities = Vec::new();
956 let mut name = String::new();
957 let mut email = String::new();
958 for line in out.lines() {
959 if let Some(rest) = line.strip_prefix("author ") {
960 name = rest.trim().to_owned();
961 } else if let Some(rest) = line.strip_prefix("author-mail ") {
962 email = rest
963 .trim()
964 .trim_start_matches('<')
965 .trim_end_matches('>')
966 .to_owned();
967 } else if line.starts_with('\t') {
968 identities.push(RawIdentity {
969 name: std::mem::take(&mut name),
970 email: std::mem::take(&mut email),
971 });
972 }
973 }
974 identities
975}
976
977#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
986pub struct AuthorMergeGroup {
987 pub canonical_name: String,
989 pub canonical_email: String,
991 pub members: Vec<String>,
993}
994
995#[derive(Debug, Clone, Default, Serialize, Deserialize)]
997pub struct IdentityMap {
998 #[serde(default)]
999 pub groups: Vec<AuthorMergeGroup>,
1000}
1001
1002impl IdentityMap {
1003 #[must_use]
1006 pub fn load(path: &Path) -> Self {
1007 std::fs::read_to_string(path)
1008 .ok()
1009 .and_then(|s| serde_json::from_str(&s).ok())
1010 .unwrap_or_default()
1011 }
1012
1013 pub fn save(&self, path: &Path) -> Result<()> {
1018 let json = serde_json::to_string_pretty(self)?;
1019 std::fs::write(path, json)
1020 .with_context(|| format!("failed to write identity map to {}", path.display()))
1021 }
1022
1023 #[must_use]
1025 pub fn group_for(&self, email: &str) -> Option<&AuthorMergeGroup> {
1026 let key = email.trim().to_lowercase();
1027 self.groups.iter().find(|g| g.members.contains(&key))
1028 }
1029
1030 pub fn merge(&mut self, emails: &[String], name: Option<&str>) {
1035 let mut members: Vec<String> = emails
1036 .iter()
1037 .map(|e| e.trim().to_lowercase())
1038 .filter(|e| !e.is_empty())
1039 .collect();
1040 members.sort();
1041 members.dedup();
1042 if members.len() < 2 {
1043 return;
1044 }
1045 let mut absorbed: Vec<String> = Vec::new();
1047 self.groups.retain(|g| {
1048 if g.members.iter().any(|m| members.contains(m)) {
1049 absorbed.extend(g.members.iter().cloned());
1050 false
1051 } else {
1052 true
1053 }
1054 });
1055 members.extend(absorbed);
1056 members.sort();
1057 members.dedup();
1058 let canonical_email = members[0].clone();
1059 let canonical_name = name
1060 .map(str::trim)
1061 .filter(|s| !s.is_empty())
1062 .map_or_else(|| canonical_email.clone(), ToString::to_string);
1063 self.groups.push(AuthorMergeGroup {
1064 canonical_name,
1065 canonical_email,
1066 members,
1067 });
1068 }
1069
1070 pub fn unmerge(&mut self, canonical_email: &str) {
1072 let key = canonical_email.trim().to_lowercase();
1073 self.groups
1074 .retain(|g| g.canonical_email.to_lowercase() != key);
1075 }
1076
1077 #[must_use]
1080 pub fn to_mailmap(&self) -> String {
1081 let mut out = String::from(
1082 "# Generated by oxide-sloc — maps alternate author emails to a canonical identity.\n",
1083 );
1084 for g in &self.groups {
1085 for member in &g.members {
1086 if *member == g.canonical_email.to_lowercase() {
1087 continue;
1088 }
1089 out.push_str(&format!(
1090 "{} <{}> <{}>\n",
1091 g.canonical_name, g.canonical_email, member
1092 ));
1093 }
1094 }
1095 out
1096 }
1097}
1098
1099pub fn apply_identity_map(run: &mut AnalysisRun, map: &IdentityMap) {
1104 if map.groups.is_empty() || run.authors.is_empty() {
1105 return;
1106 }
1107 let resolved = resolve_merge_keys(&run.authors, map);
1108 let (merged, old_to_new) = merge_authors(&run.authors, &resolved);
1109 fold_file_ownership(&mut run.per_file_records, &old_to_new);
1110 sort_and_reindex_authors(run, merged);
1111}
1112
1113fn resolve_merge_keys(authors: &[Author], map: &IdentityMap) -> Vec<(String, String, String)> {
1117 authors
1118 .iter()
1119 .map(|a| {
1120 map.group_for(&a.canonical_email).map_or_else(
1121 || {
1122 (
1123 a.canonical_email.to_lowercase(),
1124 a.canonical_name.clone(),
1125 a.canonical_email.clone(),
1126 )
1127 },
1128 |g| {
1129 (
1130 g.canonical_email.to_lowercase(),
1131 g.canonical_name.clone(),
1132 g.canonical_email.clone(),
1133 )
1134 },
1135 )
1136 })
1137 .collect()
1138}
1139
1140fn merge_authors(
1144 authors: &[Author],
1145 resolved: &[(String, String, String)],
1146) -> (Vec<Author>, Vec<u32>) {
1147 let mut key_to_new: HashMap<String, u32> = HashMap::new();
1148 let mut merged: Vec<Author> = Vec::new();
1149 let mut old_to_new: Vec<u32> = vec![0; authors.len()];
1150 for (old_idx, (key, name, email)) in resolved.iter().enumerate() {
1151 let new_id = *key_to_new.entry(key.clone()).or_insert_with(|| {
1152 let id = merged.len() as u32;
1153 merged.push(Author {
1154 id,
1155 canonical_name: name.clone(),
1156 canonical_email: email.clone(),
1157 aliases: Vec::new(),
1158 counts: AuthorLineCounts::default(),
1159 });
1160 id
1161 });
1162 old_to_new[old_idx] = new_id;
1163 let src = &authors[old_idx];
1164 let dst = &mut merged[new_id as usize];
1165 dst.counts.add(&src.counts);
1166 for alias in &src.aliases {
1167 if !dst.aliases.contains(alias) {
1168 dst.aliases.push(alias.clone());
1169 }
1170 }
1171 }
1172 (merged, old_to_new)
1173}
1174
1175fn fold_file_ownership(records: &mut [FileRecord], old_to_new: &[u32]) {
1177 for rec in records {
1178 if let Some(ownership) = rec.ownership.as_mut() {
1179 let mut by_new: HashMap<u32, AuthorLineCounts> = HashMap::new();
1180 for entry in ownership.iter() {
1181 let new_id = old_to_new[entry.author_id as usize];
1182 by_new.entry(new_id).or_default().add(&entry.counts);
1183 }
1184 let mut folded: Vec<FileOwnership> = by_new
1185 .into_iter()
1186 .map(|(author_id, counts)| FileOwnership { author_id, counts })
1187 .collect();
1188 folded.sort_by_key(|e| std::cmp::Reverse(e.counts.total_lines));
1189 *ownership = folded;
1190 }
1191 }
1192}
1193
1194fn sort_and_reindex_authors(run: &mut AnalysisRun, merged: Vec<Author>) {
1197 let mut order: Vec<usize> = (0..merged.len()).collect();
1198 order.sort_by(|&a, &b| {
1199 merged[b]
1200 .counts
1201 .code_lines
1202 .cmp(&merged[a].counts.code_lines)
1203 .then_with(|| merged[a].canonical_name.cmp(&merged[b].canonical_name))
1204 });
1205 let mut remap = vec![0u32; merged.len()];
1206 for (new_id, &old) in order.iter().enumerate() {
1207 remap[old] = new_id as u32;
1208 }
1209 for rec in &mut run.per_file_records {
1210 if let Some(ownership) = rec.ownership.as_mut() {
1211 for entry in ownership.iter_mut() {
1212 entry.author_id = remap[entry.author_id as usize];
1213 }
1214 }
1215 }
1216 let mut sorted: Vec<Author> = order.iter().map(|&old| merged[old].clone()).collect();
1217 for (new_id, author) in sorted.iter_mut().enumerate() {
1218 author.id = new_id as u32;
1219 }
1220 run.authors = sorted;
1221}
1222
1223fn detect_ci_system() -> Option<&'static str> {
1225 let ev = |k: &str| std::env::var(k).is_ok();
1226 let ev_true = |k: &str| std::env::var(k).as_deref() == Ok("true");
1227 if ev("JENKINS_URL") || ev("JENKINS_HOME") || ev("BUILD_URL") {
1228 return Some("Jenkins");
1229 }
1230 if ev_true("GITHUB_ACTIONS") {
1231 return Some("GitHub Actions");
1232 }
1233 if ev_true("GITLAB_CI") {
1234 return Some("GitLab CI");
1235 }
1236 if ev_true("CIRCLECI") {
1237 return Some("CircleCI");
1238 }
1239 if ev_true("TRAVIS") {
1240 return Some("Travis CI");
1241 }
1242 if ev_true("TF_BUILD") {
1243 return Some("Azure DevOps");
1244 }
1245 if ev("TEAMCITY_VERSION") {
1246 return Some("TeamCity");
1247 }
1248 None
1249}
1250
1251fn ci_branch_from_env() -> Option<String> {
1254 const VARS: &[&str] = &[
1255 "BRANCH_NAME", "GIT_BRANCH", "GITHUB_REF_NAME", "CI_COMMIT_BRANCH", "CIRCLE_BRANCH", "TRAVIS_BRANCH", "BUILD_SOURCEBRANCH", ];
1263 for &var in VARS {
1264 if let Ok(val) = std::env::var(var) {
1265 let val = val.trim();
1266 let val = val
1267 .strip_prefix("refs/heads/")
1268 .or_else(|| val.strip_prefix("origin/"))
1269 .unwrap_or(val);
1270 if !val.is_empty() && val != "HEAD" {
1271 return Some(val.to_string());
1272 }
1273 }
1274 }
1275 None
1276}
1277
1278fn get_current_username() -> String {
1279 std::env::var("USERNAME")
1280 .or_else(|_| std::env::var("USER"))
1281 .unwrap_or_else(|_| "unknown".to_string())
1282}
1283
1284fn non_empty_env(var: &str) -> Option<String> {
1285 let v = std::env::var(var).ok()?;
1286 if v.is_empty() { None } else { Some(v) }
1287}
1288
1289fn is_jenkins_env() -> bool {
1290 std::env::var("JENKINS_URL").is_ok()
1291 || std::env::var("JENKINS_HOME").is_ok()
1292 || std::env::var("BUILD_URL").is_ok()
1293}
1294
1295fn get_hostname() -> String {
1296 if is_jenkins_env()
1299 && let Some(n) = non_empty_env("NODE_NAME")
1300 {
1301 return n;
1302 }
1303 if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true")
1304 && let Some(r) = non_empty_env("RUNNER_NAME")
1305 {
1306 return r;
1307 }
1308 if std::env::var("GITLAB_CI").as_deref() == Ok("true")
1309 && let Some(r) = non_empty_env("CI_RUNNER_DESCRIPTION")
1310 {
1311 return r;
1312 }
1313 std::env::var("COMPUTERNAME")
1314 .or_else(|_| std::env::var("HOSTNAME"))
1315 .or_else(|_| std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()))
1316 .unwrap_or_else(|_| "unknown".to_string())
1317}
1318
1319#[allow(clippy::too_many_arguments)]
1321fn walk_root(
1322 root: &Path,
1323 config: &AppConfig,
1324 include_globs: Option<&GlobSet>,
1325 exclude_globs: Option<&GlobSet>,
1326 enabled_languages: Option<&BTreeSet<Language>>,
1327 seen_paths: &mut HashSet<PathBuf>,
1328 analyzed: &mut Vec<FileRecord>,
1329 skipped: &mut Vec<FileRecord>,
1330 warnings: &mut Vec<String>,
1331 cancel: Option<&AtomicBool>,
1332 progress: Option<&ProgressCounters>,
1333) -> Result<()> {
1334 let mut builder = WalkBuilder::new(root);
1335 builder
1336 .follow_links(config.discovery.follow_symlinks)
1337 .hidden(config.discovery.ignore_hidden_files)
1338 .ignore(config.discovery.honor_ignore_files)
1339 .parents(config.discovery.honor_ignore_files)
1340 .git_ignore(config.discovery.honor_ignore_files)
1341 .git_global(config.discovery.honor_ignore_files)
1342 .git_exclude(config.discovery.honor_ignore_files);
1343
1344 let paths = collect_walk_paths(&builder, seen_paths, warnings);
1345 if paths.is_empty() {
1346 return Ok(());
1347 }
1348
1349 if let Some(p) = progress {
1350 p.files_total.fetch_add(paths.len(), Ordering::Relaxed);
1351 }
1352
1353 let chunk_results = run_parallel_analysis(
1354 &paths,
1355 root,
1356 config,
1357 include_globs,
1358 exclude_globs,
1359 enabled_languages,
1360 cancel,
1361 progress,
1362 )?;
1363 merge_chunk_results(chunk_results, analyzed, skipped, warnings)
1364}
1365
1366fn collect_walk_paths(
1367 builder: &WalkBuilder,
1368 seen_paths: &mut HashSet<PathBuf>,
1369 warnings: &mut Vec<String>,
1370) -> Vec<PathBuf> {
1371 let (tx, rx) = std::sync::mpsc::channel::<std::result::Result<PathBuf, String>>();
1375
1376 builder.build_parallel().run(|| {
1377 let tx = tx.clone();
1378 Box::new(move |entry| {
1379 match entry {
1380 Err(e) => {
1381 let _ = tx.send(Err(format!("discovery warning: {e}")));
1382 }
1383 Ok(e) => {
1384 let path = e.into_path();
1385 if !path.is_dir() {
1386 let _ = tx.send(Ok(path));
1387 }
1388 }
1389 }
1390 ignore::WalkState::Continue
1391 })
1392 });
1393
1394 drop(tx);
1397
1398 rx.into_iter()
1399 .filter_map(|msg| match msg {
1400 Ok(path) => {
1401 if seen_paths.insert(path.clone()) {
1402 Some(path)
1403 } else {
1404 None
1405 }
1406 }
1407 Err(warn) => {
1408 warnings.push(warn);
1409 None
1410 }
1411 })
1412 .collect()
1413}
1414
1415#[allow(clippy::too_many_arguments)]
1417fn worker_loop(
1418 paths: &[PathBuf],
1419 root: &Path,
1420 config: &AppConfig,
1421 include_globs: Option<&GlobSet>,
1422 exclude_globs: Option<&GlobSet>,
1423 enabled_languages: Option<&BTreeSet<Language>>,
1424 cancel: Option<&AtomicBool>,
1425 next_index: &AtomicUsize,
1426 files_done: Option<&AtomicUsize>,
1427) -> Vec<Result<Option<FileRecord>>> {
1428 let mut results = Vec::new();
1429 loop {
1430 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1431 results.push(Err(anyhow::anyhow!("analysis cancelled")));
1432 break;
1433 }
1434 let i = next_index.fetch_add(1, Ordering::Relaxed);
1435 if i >= paths.len() {
1436 break;
1437 }
1438 results.push(analyze_candidate_file(
1439 &paths[i],
1440 root,
1441 config,
1442 include_globs,
1443 exclude_globs,
1444 enabled_languages,
1445 ));
1446 if let Some(fd) = files_done {
1447 fd.fetch_add(1, Ordering::Relaxed);
1448 }
1449 }
1450 results
1451}
1452
1453#[allow(clippy::too_many_arguments)]
1454fn run_parallel_analysis(
1455 paths: &[PathBuf],
1456 root: &Path,
1457 config: &AppConfig,
1458 include_globs: Option<&GlobSet>,
1459 exclude_globs: Option<&GlobSet>,
1460 enabled_languages: Option<&BTreeSet<Language>>,
1461 cancel: Option<&AtomicBool>,
1462 progress: Option<&ProgressCounters>,
1463) -> Result<Vec<Vec<Result<Option<FileRecord>>>>> {
1464 let thread_count = std::thread::available_parallelism().map_or(DEFAULT_ANALYSIS_THREADS, |n| {
1465 n.get().min(MAX_ANALYSIS_THREADS)
1466 });
1467 let next_index = AtomicUsize::new(0);
1471 let files_done: Option<&AtomicUsize> = progress.map(|p| p.files_done.as_ref());
1472
1473 std::thread::scope(|s| -> Result<Vec<Vec<Result<Option<FileRecord>>>>> {
1474 let mut handles = Vec::with_capacity(thread_count);
1477 for _ in 0..thread_count {
1478 handles.push(s.spawn(|| {
1479 worker_loop(
1480 paths,
1481 root,
1482 config,
1483 include_globs,
1484 exclude_globs,
1485 enabled_languages,
1486 cancel,
1487 &next_index,
1488 files_done,
1489 )
1490 }));
1491 }
1492 handles
1493 .into_iter()
1494 .map(|h| {
1495 h.join()
1496 .map_err(|_| anyhow::anyhow!("analysis thread panicked"))
1497 })
1498 .collect()
1499 })
1500}
1501
1502fn merge_chunk_results(
1503 chunk_results: Vec<Vec<Result<Option<FileRecord>>>>,
1504 analyzed: &mut Vec<FileRecord>,
1505 skipped: &mut Vec<FileRecord>,
1506 warnings: &mut Vec<String>,
1507) -> Result<()> {
1508 for chunk in chunk_results {
1509 for result in chunk {
1510 if let Some(record) = result? {
1511 push_record(record, analyzed, skipped, warnings);
1512 }
1513 }
1514 }
1515 Ok(())
1516}
1517
1518fn process_submodules(config: &AppConfig, analyzed: &mut [FileRecord]) -> Vec<SubmoduleSummary> {
1520 let root = config.discovery.root_paths[0]
1521 .canonicalize()
1522 .unwrap_or_else(|_| config.discovery.root_paths[0].clone());
1523 let submodules = detect_submodules(&root);
1524 if submodules.is_empty() {
1525 return Vec::new();
1526 }
1527
1528 for file in analyzed.iter_mut() {
1529 for (name, sub_path) in &submodules {
1530 let prefix = sub_path.to_string_lossy().replace('\\', "/");
1531 let rel = &file.relative_path;
1532 if rel == &prefix || rel.starts_with(&format!("{prefix}/")) {
1533 file.submodule = Some(name.clone());
1534 break;
1535 }
1536 }
1537 }
1538
1539 build_submodule_summaries(analyzed, &submodules, &root)
1540}
1541
1542#[allow(clippy::cast_precision_loss)] fn compute_cocomo(code_lines: u64, mode: CocomoMode) -> CocomoEstimate {
1545 let ksloc = code_lines as f64 / 1_000.0;
1546 let (a, b, c, d): (f64, f64, f64, f64) = match mode {
1547 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
1548 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
1549 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
1550 };
1551 let effort = a * ksloc.powf(b);
1552 let duration = c * effort.powf(d);
1553 let avg_staff = if duration > 0.0 {
1554 effort / duration
1555 } else {
1556 0.0
1557 };
1558 CocomoEstimate {
1560 mode,
1561 ksloc: (ksloc * 100.0).round() / 100.0,
1562 effort_person_months: (effort * 100.0).round() / 100.0,
1563 duration_months: (duration * 100.0).round() / 100.0,
1564 avg_staff: (avg_staff * 100.0).round() / 100.0,
1565 }
1566}
1567
1568#[allow(clippy::cast_precision_loss)] fn compute_uloc(analyzed: &[FileRecord]) -> (u64, Option<f32>) {
1571 use std::collections::HashSet as StdHashSet;
1572 let mut unique: StdHashSet<u64> = StdHashSet::new();
1573 let mut total_code: u64 = 0;
1574 for record in analyzed {
1575 total_code += record.effective_counts.code_lines;
1576 for &hash in &record.raw_line_categories.code_line_hashes {
1577 unique.insert(hash);
1578 }
1579 }
1580 let uloc = unique.len() as u64;
1581 let dryness = if total_code > 0 {
1582 Some((uloc as f32 / total_code as f32) * 100.0)
1583 } else {
1584 None
1585 };
1586 (uloc, dryness)
1587}
1588
1589fn find_duplicate_groups(analyzed: &[FileRecord]) -> Vec<Vec<String>> {
1592 let mut by_hash: std::collections::HashMap<u64, Vec<&str>> = std::collections::HashMap::new();
1593 for record in analyzed {
1594 if record.content_hash != 0 {
1595 by_hash
1596 .entry(record.content_hash)
1597 .or_default()
1598 .push(&record.relative_path);
1599 }
1600 }
1601 let mut groups: Vec<Vec<String>> = by_hash
1602 .into_values()
1603 .filter(|v| v.len() >= 2)
1604 .map(|v| {
1605 let mut paths: Vec<String> = v.into_iter().map(str::to_owned).collect();
1606 paths.sort();
1607 paths
1608 })
1609 .collect();
1610 groups.sort_by(|a, b| a[0].cmp(&b[0]));
1611 groups
1612}
1613
1614fn assemble_run(
1616 config: &AppConfig,
1617 runtime_mode: &str,
1618 mut analyzed: Vec<FileRecord>,
1619 skipped: Vec<FileRecord>,
1620 warnings: Vec<String>,
1621 submodule_summaries: Vec<SubmoduleSummary>,
1622) -> AnalysisRun {
1623 let summary = build_summary(&analyzed, &skipped);
1624 let language_summaries = build_language_summaries(&analyzed);
1625 let col_threshold = config.analysis.style_col_threshold;
1626 let style_summary = build_style_summary(&analyzed, col_threshold);
1627
1628 let (uloc, dryness_pct) = compute_uloc(&analyzed);
1630 let duplicate_groups = find_duplicate_groups(&analyzed);
1631 let cocomo = if summary.code_lines > 0 {
1632 Some(compute_cocomo(summary.code_lines, CocomoMode::Organic))
1633 } else {
1634 None
1635 };
1636
1637 let first_root = config
1638 .discovery
1639 .root_paths
1640 .first()
1641 .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()));
1642 let git = first_root
1643 .as_deref()
1644 .map(detect_git_for_run)
1645 .unwrap_or_default();
1646
1647 let activity_window = config.analysis.activity_window_days.unwrap_or(0);
1650 if let (true, Some(root)) = (activity_window > 0, first_root.as_deref()) {
1651 apply_file_activity(root, activity_window, &mut analyzed);
1652 }
1653
1654 let authors = if config.analysis.attribution {
1657 first_root
1658 .as_deref()
1659 .map(|root| attribute_ownership(root, &mut analyzed))
1660 .unwrap_or_default()
1661 } else {
1662 Vec::new()
1663 };
1664
1665 let now = Utc::now();
1666 let run_id = {
1667 let uuid_suffix = Uuid::new_v4().simple().to_string();
1668 format!("{}-{}", now.format("%Y%m%d-%H%M"), uuid_suffix)
1669 };
1670
1671 AnalysisRun {
1672 tool: ToolMetadata {
1673 name: "sloc".into(),
1674 version: env!("CARGO_PKG_VERSION").into(),
1675 run_id,
1676 timestamp_utc: now,
1677 },
1678 environment: EnvironmentMetadata {
1679 operating_system: std::env::consts::OS.into(),
1680 architecture: std::env::consts::ARCH.into(),
1681 runtime_mode: runtime_mode.into(),
1682 initiator_username: get_current_username(),
1683 initiator_hostname: get_hostname(),
1684 ci_name: if is_jenkins_env() {
1685 Some(format!("Jenkins\t{}", get_hostname()))
1686 } else {
1687 detect_ci_system().map(str::to_string)
1688 },
1689 },
1690 effective_configuration: config.clone(),
1691 input_roots: config
1692 .discovery
1693 .root_paths
1694 .iter()
1695 .map(|p| path_to_string(p))
1696 .collect(),
1697 summary_totals: summary,
1698 totals_by_language: language_summaries,
1699 per_file_records: analyzed,
1700 skipped_file_records: skipped,
1701 warnings,
1702 submodule_summaries,
1703 git_commit_short: git.commit_short,
1704 git_commit_long: git.commit_long,
1705 git_branch: git.branch,
1706 git_commit_author: git.author,
1707 git_tags: git.tags,
1708 git_nearest_tag: git.nearest_tag,
1709 git_commit_date: git.commit_date,
1710 git_remote_url: git.remote_url,
1711 style_summary,
1712 cocomo,
1713 uloc,
1714 dryness_pct,
1715 duplicate_groups,
1716 duplicates_excluded: 0,
1717 authors,
1718 }
1719}
1720
1721fn apply_file_activity(root: &Path, window_days: u32, analyzed: &mut [FileRecord]) {
1725 let activity = detect_file_activity(root, window_days);
1726 if activity.is_empty() {
1727 return;
1728 }
1729 for rec in analyzed {
1730 if let Some((count, date)) = activity.get(&rec.relative_path) {
1731 rec.commit_count = Some(*count);
1732 rec.last_commit_date.clone_from(date);
1733 }
1734 }
1735}
1736
1737#[allow(clippy::too_many_lines)]
1742pub fn analyze(
1743 config: &AppConfig,
1744 runtime_mode: &str,
1745 cancel: Option<&AtomicBool>,
1746 progress: Option<&ProgressCounters>,
1747) -> Result<AnalysisRun> {
1748 config.validate()?;
1749
1750 if config.discovery.root_paths.is_empty() {
1751 anyhow::bail!("no input paths were provided");
1752 }
1753
1754 let include_globs = compile_globset(&config.discovery.include_globs)?;
1755 let exclude_globs = compile_globset(&config.discovery.exclude_globs)?;
1756 let enabled_languages = parse_enabled_languages(&config.analysis.enabled_languages)?;
1757
1758 let mut analyzed = Vec::new();
1759 let mut skipped = Vec::new();
1760 let mut warnings = Vec::new();
1761 let mut seen_paths = HashSet::new();
1762
1763 for root in &config.discovery.root_paths {
1764 if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1765 anyhow::bail!("analysis cancelled");
1766 }
1767
1768 let root = root.canonicalize().unwrap_or_else(|_| root.clone());
1769
1770 if root.is_file() {
1771 if let Some(record) = analyze_candidate_file(
1772 &root,
1773 root.parent().unwrap_or_else(|| Path::new(".")),
1774 config,
1775 include_globs.as_ref(),
1776 exclude_globs.as_ref(),
1777 enabled_languages.as_ref(),
1778 )? {
1779 push_record(record, &mut analyzed, &mut skipped, &mut warnings);
1780 }
1781 continue;
1782 }
1783
1784 let layout = detect_repository_layout(&root);
1785 if layout.has_multiple_repos() {
1786 warnings.push(format_multi_repo_warning(&layout));
1787 }
1788
1789 walk_root(
1790 &root,
1791 config,
1792 include_globs.as_ref(),
1793 exclude_globs.as_ref(),
1794 enabled_languages.as_ref(),
1795 &mut seen_paths,
1796 &mut analyzed,
1797 &mut skipped,
1798 &mut warnings,
1799 cancel,
1800 progress,
1801 )?;
1802 }
1803
1804 analyzed.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1805 skipped.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
1806
1807 let submodule_summaries = if config.discovery.submodule_breakdown {
1809 process_submodules(config, &mut analyzed)
1810 } else {
1811 Vec::new()
1812 };
1813
1814 attach_coverage(config, &mut analyzed, &mut warnings);
1815
1816 Ok(assemble_run(
1817 config,
1818 runtime_mode,
1819 analyzed,
1820 skipped,
1821 warnings,
1822 submodule_summaries,
1823 ))
1824}
1825
1826fn attach_coverage(config: &AppConfig, analyzed: &mut [FileRecord], warnings: &mut Vec<String>) {
1827 let Some(cov_path) = coverage::resolve_coverage_file(config.analysis.coverage_file.as_deref())
1828 else {
1829 return;
1830 };
1831 tracing::debug!(path = %cov_path.display(), "loading coverage file");
1832 match fs::read_to_string(&cov_path) {
1833 Ok(content) => {
1834 let cov_map = coverage::parse_coverage_auto(&cov_path, &content);
1835 let mut matched: u32 = 0;
1836 let mut unmatched: u32 = 0;
1837 for record in analyzed.iter_mut() {
1838 record.coverage =
1839 coverage::lookup_coverage(&cov_map, &record.relative_path).cloned();
1840 if record.coverage.is_some() {
1841 matched += 1;
1842 } else {
1843 unmatched += 1;
1844 }
1845 }
1846 tracing::debug!(
1847 path = %cov_path.display(),
1848 coverage_entries = cov_map.len(),
1849 files_matched = matched,
1850 files_unmatched = unmatched,
1851 "coverage attached"
1852 );
1853 if unmatched > 0 && matched == 0 {
1854 tracing::warn!(
1855 path = %cov_path.display(),
1856 "coverage file loaded but no source files could be matched — check that paths in the coverage report match the scanned directory"
1857 );
1858 }
1859 }
1860 Err(e) => {
1861 tracing::warn!(path = %cov_path.display(), error = %e, "coverage file could not be read");
1862 warnings.push(format!(
1863 "coverage file '{}' could not be read: {e}",
1864 cov_path.display()
1865 ));
1866 }
1867 }
1868}
1869
1870fn push_record(
1871 record: FileRecord,
1872 analyzed: &mut Vec<FileRecord>,
1873 skipped: &mut Vec<FileRecord>,
1874 warnings: &mut Vec<String>,
1875) {
1876 warnings.extend(
1877 record
1878 .warnings
1879 .iter()
1880 .map(|warning| format!("{}: {warning}", record.relative_path)),
1881 );
1882
1883 match record.status {
1884 FileStatus::AnalyzedExact | FileStatus::AnalyzedBestEffort => analyzed.push(record),
1885 _ => skipped.push(record),
1886 }
1887}
1888
1889#[inline]
1891fn skip_with_reason(
1892 path: &Path,
1893 root: &Path,
1894 size: u64,
1895 reason: impl Into<String>,
1896) -> MetadataPolicyOutcome {
1897 MetadataPolicyOutcome::Skip(Box::new(skipped_record(
1898 path,
1899 root,
1900 size,
1901 FileStatus::SkippedByPolicy,
1902 vec![reason.into()],
1903 )))
1904}
1905
1906#[allow(clippy::too_many_arguments)]
1910fn check_metadata_policy(
1911 path: &Path,
1912 root: &Path,
1913 relative_path: &str,
1914 metadata: &fs::Metadata,
1915 config: &AppConfig,
1916 include_globs: Option<&GlobSet>,
1917 exclude_globs: Option<&GlobSet>,
1918) -> MetadataPolicyOutcome {
1919 let size = metadata.len();
1920
1921 if metadata.file_type().is_symlink() && !config.discovery.follow_symlinks {
1922 return skip_with_reason(path, root, size, "symlink skipped by policy");
1923 }
1924 if file_name_eq(path, ".gitignore") {
1925 return skip_with_reason(path, root, size, ".gitignore is always excluded");
1926 }
1927 if is_excluded_dir_path(path, &config.discovery.excluded_directories) {
1928 return skip_with_reason(path, root, size, "path matched excluded directory setting");
1929 }
1930 if size > config.discovery.max_file_size_bytes {
1931 return skip_with_reason(
1932 path,
1933 root,
1934 size,
1935 format!(
1936 "file exceeded max_file_size_bytes ({})",
1937 config.discovery.max_file_size_bytes
1938 ),
1939 );
1940 }
1941 if let Some(globs) = include_globs
1942 && !globs.is_match(Path::new(relative_path))
1943 && !globs.is_match(path)
1944 {
1945 return MetadataPolicyOutcome::Exclude;
1946 }
1947 if let Some(globs) = exclude_globs
1948 && (globs.is_match(Path::new(relative_path)) || globs.is_match(path))
1949 {
1950 return skip_with_reason(path, root, size, "path matched exclude glob");
1951 }
1952 if is_known_lockfile(path) && !config.analysis.include_lockfiles {
1953 return skip_with_reason(path, root, size, "lockfile skipped by default policy");
1954 }
1955
1956 MetadataPolicyOutcome::Continue
1957}
1958
1959struct ContentPolicyResult {
1960 vendor: bool,
1961 generated: bool,
1962 minified: bool,
1963 skip_record: Option<FileRecord>,
1964}
1965
1966fn check_content_policy(
1969 path: &Path,
1970 root: &Path,
1971 size_bytes: u64,
1972 bytes: &[u8],
1973 config: &AppConfig,
1974) -> ContentPolicyResult {
1975 let vendor = is_vendor_path(path);
1976 if vendor && config.analysis.vendor_directory_detection {
1977 return ContentPolicyResult {
1978 vendor,
1979 generated: false,
1980 minified: false,
1981 skip_record: Some(skipped_record(
1982 path,
1983 root,
1984 size_bytes,
1985 FileStatus::SkippedByPolicy,
1986 vec!["vendor file skipped by policy".into()],
1987 )),
1988 };
1989 }
1990
1991 let generated = config.analysis.generated_file_detection && looks_generated(path, bytes);
1992 if generated {
1993 return ContentPolicyResult {
1994 vendor,
1995 generated,
1996 minified: false,
1997 skip_record: Some(skipped_record(
1998 path,
1999 root,
2000 size_bytes,
2001 FileStatus::SkippedByPolicy,
2002 vec!["generated file skipped by policy".into()],
2003 )),
2004 };
2005 }
2006
2007 let minified = config.analysis.minified_file_detection && looks_minified(path, bytes);
2008 if minified {
2009 return ContentPolicyResult {
2010 vendor,
2011 generated,
2012 minified,
2013 skip_record: Some(skipped_record(
2014 path,
2015 root,
2016 size_bytes,
2017 FileStatus::SkippedByPolicy,
2018 vec!["minified file skipped by policy".into()],
2019 )),
2020 };
2021 }
2022
2023 ContentPolicyResult {
2024 vendor,
2025 generated,
2026 minified,
2027 skip_record: None,
2028 }
2029}
2030
2031fn decode_file_contents(
2033 path: &Path,
2034 root: &Path,
2035 size_bytes: u64,
2036 bytes: &[u8],
2037 config: &AppConfig,
2038) -> Result<Option<(String, String, Vec<String>)>> {
2039 if is_binary(bytes) {
2040 return match config.analysis.binary_file_behavior {
2041 BinaryFileBehavior::Skip => Ok(None),
2042 BinaryFileBehavior::Fail => {
2043 anyhow::bail!("binary file encountered: {}", path.display())
2044 }
2045 };
2046 }
2047
2048 match decode_bytes(bytes) {
2049 Ok(result) => Ok(Some(result)),
2050 Err(err) => match config.analysis.decode_failure_behavior {
2051 FailureBehavior::WarnSkip => {
2052 let _ = (path, root, size_bytes); Err(anyhow::anyhow!("__decode_warn__: {err}"))
2057 }
2058 FailureBehavior::Fail => {
2059 anyhow::bail!("decode failure for {}: {err}", path.display())
2060 }
2061 },
2062 }
2063}
2064
2065enum LanguageOutcome {
2068 Resolved(Language),
2069 Skip(Box<FileRecord>),
2070}
2071
2072fn resolve_language(
2076 path: &Path,
2077 root: &Path,
2078 size_bytes: u64,
2079 text: &str,
2080 config: &AppConfig,
2081 enabled_languages: Option<&BTreeSet<Language>>,
2082) -> LanguageOutcome {
2083 let first_line = text.lines().next();
2084 let language = detect_language(
2085 path,
2086 first_line,
2087 &config.analysis.extension_overrides,
2088 config.analysis.shebang_detection,
2089 );
2090
2091 let Some(mut language) = language else {
2092 return LanguageOutcome::Skip(Box::new(skipped_record(
2093 path,
2094 root,
2095 size_bytes,
2096 FileStatus::SkippedUnsupported,
2097 vec!["unsupported or undetected language".into()],
2098 )));
2099 };
2100
2101 if language == Language::C
2105 && path.extension().and_then(|e| e.to_str()) == Some("h")
2106 && sloc_languages::looks_like_cpp(text)
2107 {
2108 language = Language::Cpp;
2109 }
2110
2111 if let Some(enabled) = enabled_languages
2112 && !enabled.contains(&language)
2113 {
2114 return LanguageOutcome::Skip(Box::new(skipped_record(
2115 path,
2116 root,
2117 size_bytes,
2118 FileStatus::SkippedByPolicy,
2119 vec![format!(
2120 "language {} disabled by configuration",
2121 language.display_name()
2122 )],
2123 )));
2124 }
2125
2126 LanguageOutcome::Resolved(language)
2127}
2128
2129#[allow(clippy::too_many_lines)]
2130fn analyze_candidate_file(
2131 path: &Path,
2132 root: &Path,
2133 config: &AppConfig,
2134 include_globs: Option<&GlobSet>,
2135 exclude_globs: Option<&GlobSet>,
2136 enabled_languages: Option<&BTreeSet<Language>>,
2137) -> Result<Option<FileRecord>> {
2138 let metadata = match fs::symlink_metadata(path) {
2139 Ok(metadata) => metadata,
2140 Err(err) => {
2141 return Ok(Some(skipped_record(
2142 path,
2143 root,
2144 0,
2145 FileStatus::ErrorInternal,
2146 vec![format!("failed to read metadata: {err}")],
2147 )));
2148 }
2149 };
2150
2151 let relative_path = relative_path_string(path, root);
2152
2153 match check_metadata_policy(
2155 path,
2156 root,
2157 &relative_path,
2158 &metadata,
2159 config,
2160 include_globs,
2161 exclude_globs,
2162 ) {
2163 MetadataPolicyOutcome::Skip(record) => return Ok(Some(*record)),
2164 MetadataPolicyOutcome::Exclude => return Ok(None),
2165 MetadataPolicyOutcome::Continue => {}
2166 }
2167
2168 let bytes = match fs::read(path) {
2169 Ok(bytes) => bytes,
2170 Err(err) => {
2171 return Ok(Some(skipped_record(
2172 path,
2173 root,
2174 metadata.len(),
2175 FileStatus::ErrorInternal,
2176 vec![format!("failed to read file: {err}")],
2177 )));
2178 }
2179 };
2180
2181 let content_policy = check_content_policy(path, root, metadata.len(), &bytes, config);
2183 if let Some(record) = content_policy.skip_record {
2184 return Ok(Some(record));
2185 }
2186 let (vendor, generated, minified) = (
2187 content_policy.vendor,
2188 content_policy.generated,
2189 content_policy.minified,
2190 );
2191
2192 let (text, encoding, decode_warnings) =
2194 match decode_file_contents(path, root, metadata.len(), &bytes, config) {
2195 Ok(Some(result)) => result,
2196 Ok(None) => {
2197 return Ok(Some(skipped_record(
2198 path,
2199 root,
2200 metadata.len(),
2201 FileStatus::SkippedBinary,
2202 vec!["binary file skipped by default".into()],
2203 )));
2204 }
2205 Err(err) => {
2206 let msg = err.to_string();
2207 if let Some(warn_msg) = msg.strip_prefix("__decode_warn__: ") {
2208 return Ok(Some(skipped_record(
2209 path,
2210 root,
2211 metadata.len(),
2212 FileStatus::SkippedDecodeError,
2213 vec![warn_msg.to_string()],
2214 )));
2215 }
2216 return Err(err);
2217 }
2218 };
2219
2220 let language =
2221 match resolve_language(path, root, metadata.len(), &text, config, enabled_languages) {
2222 LanguageOutcome::Resolved(language) => language,
2223 LanguageOutcome::Skip(record) => return Ok(Some(*record)),
2224 };
2225
2226 let style_scope = match config.analysis.style_lang_scope.as_str() {
2227 "c_family" => StyleLangScope::CFamilyOnly,
2228 _ => StyleLangScope::All,
2229 };
2230 let ieee_opts = AnalysisOptions {
2231 blank_in_block_comment_as_comment: config.analysis.blank_in_block_comment_policy
2232 == BlankInBlockCommentPolicy::CountAsComment,
2233 collapse_continuation_lines: config.analysis.continuation_line_policy
2234 == ContinuationLinePolicy::CollapseToLogical,
2235 enable_style: config.analysis.style_analysis_enabled,
2236 style_lang_scope: style_scope,
2237 };
2238 let analysis = analyze_text(language, &text, ieee_opts);
2239 let effective_counts = compute_effective_counts(
2240 &analysis.raw,
2241 config.analysis.mixed_line_policy,
2242 config.analysis.python_docstrings_as_comments,
2243 config.analysis.count_compiler_directives,
2244 );
2245
2246 let mut warnings = decode_warnings;
2247 warnings.extend(analysis.warnings.clone());
2248
2249 let content_hash = {
2251 use std::hash::{DefaultHasher, Hash, Hasher};
2252 let mut h = DefaultHasher::new();
2253 bytes.hash(&mut h);
2254 h.finish()
2255 };
2256
2257 let cyclomatic_complexity = if analysis.raw.cyclomatic_complexity > 0 {
2259 Some(analysis.raw.cyclomatic_complexity)
2260 } else {
2261 None
2262 };
2263 let lsloc = analysis.raw.lsloc;
2264
2265 Ok(Some(FileRecord {
2266 path: path_to_string(path),
2267 relative_path,
2268 language: Some(language),
2269 size_bytes: metadata.len(),
2270 detected_encoding: Some(encoding),
2271 raw_line_categories: analysis.raw,
2272 effective_counts,
2273 status: match analysis.parse_mode {
2274 ParseMode::Lexical | ParseMode::TreeSitter => FileStatus::AnalyzedExact,
2275 ParseMode::LexicalBestEffort => FileStatus::AnalyzedBestEffort,
2276 },
2277 warnings,
2278 generated,
2279 minified,
2280 vendor,
2281 parse_mode: Some(analysis.parse_mode),
2282 submodule: None,
2283 coverage: None,
2284 style_analysis: analysis.style_analysis,
2285 cyclomatic_complexity,
2286 lsloc,
2287 commit_count: None,
2288 last_commit_date: None,
2289 ownership: None,
2290 content_hash,
2291 }))
2292}
2293
2294const fn compute_effective_counts(
2295 raw: &RawLineCounts,
2296 mixed_line_policy: MixedLinePolicy,
2297 python_docstrings_as_comments: bool,
2298 count_compiler_directives: bool,
2299) -> EffectiveCounts {
2300 let mut effective = EffectiveCounts {
2301 code_lines: raw.code_only_lines,
2302 comment_lines: raw.single_comment_only_lines + raw.multi_comment_only_lines,
2303 blank_lines: raw.blank_only_lines,
2304 mixed_lines_separate: 0,
2305 };
2306
2307 if python_docstrings_as_comments {
2308 effective.comment_lines += raw.docstring_comment_lines;
2309 } else {
2310 effective.code_lines += raw.docstring_comment_lines;
2311 }
2312
2313 let mixed_total = raw.mixed_code_single_comment_lines + raw.mixed_code_multi_comment_lines;
2314 match mixed_line_policy {
2315 MixedLinePolicy::CodeOnly => effective.code_lines += mixed_total,
2316 MixedLinePolicy::CodeAndComment => {
2317 effective.code_lines += mixed_total;
2318 effective.comment_lines += mixed_total;
2319 }
2320 MixedLinePolicy::CommentOnly => effective.comment_lines += mixed_total,
2321 MixedLinePolicy::SeparateMixedCategory => effective.mixed_lines_separate += mixed_total,
2322 }
2323
2324 if !count_compiler_directives {
2327 effective.code_lines = effective
2328 .code_lines
2329 .saturating_sub(raw.compiler_directive_lines);
2330 }
2331
2332 effective
2333}
2334
2335fn build_summary(analyzed: &[FileRecord], skipped: &[FileRecord]) -> SummaryTotals {
2336 let mut summary = SummaryTotals {
2337 files_considered: (analyzed.len() + skipped.len()) as u64,
2338 files_analyzed: analyzed.len() as u64,
2339 files_skipped: skipped.len() as u64,
2340 ..Default::default()
2341 };
2342
2343 for record in analyzed {
2344 summary.total_physical_lines += record.raw_line_categories.total_physical_lines;
2345 summary.code_lines += record.effective_counts.code_lines;
2346 summary.comment_lines += record.effective_counts.comment_lines;
2347 summary.blank_lines += record.effective_counts.blank_lines;
2348 summary.mixed_lines_separate += record.effective_counts.mixed_lines_separate;
2349 summary.functions += record.raw_line_categories.functions;
2350 summary.classes += record.raw_line_categories.classes;
2351 summary.variables += record.raw_line_categories.variables;
2352 summary.variables_member += record.raw_line_categories.variables_member;
2353 summary.variables_local += record.raw_line_categories.variables_local;
2354 summary.variables_global += record.raw_line_categories.variables_global;
2355 summary.macro_definitions += record.raw_line_categories.macro_definitions;
2356 summary.imports += record.raw_line_categories.imports;
2357 summary.test_count += record.raw_line_categories.test_count;
2358 summary.test_assertion_count += record.raw_line_categories.test_assertion_count;
2359 summary.test_suite_count += record.raw_line_categories.test_suite_count;
2360 summary.cyclomatic_complexity +=
2361 u64::from(record.raw_line_categories.cyclomatic_complexity);
2362 if let Some(lsloc) = record.raw_line_categories.lsloc {
2363 *summary.lsloc.get_or_insert(0) += u64::from(lsloc);
2364 }
2365 if let Some(cov) = &record.coverage {
2366 summary.coverage_lines_found += u64::from(cov.lines_found);
2367 summary.coverage_lines_hit += u64::from(cov.lines_hit);
2368 summary.coverage_functions_found += u64::from(cov.functions_found);
2369 summary.coverage_functions_hit += u64::from(cov.functions_hit);
2370 summary.coverage_branches_found += u64::from(cov.branches_found);
2371 summary.coverage_branches_hit += u64::from(cov.branches_hit);
2372 }
2373 }
2374
2375 summary
2376}
2377
2378const fn zeroed_summary(language: Language) -> LanguageSummary {
2380 LanguageSummary {
2381 language,
2382 files: 0,
2383 total_physical_lines: 0,
2384 code_lines: 0,
2385 comment_lines: 0,
2386 blank_lines: 0,
2387 mixed_lines_separate: 0,
2388 functions: 0,
2389 classes: 0,
2390 variables: 0,
2391 variables_member: 0,
2392 variables_local: 0,
2393 variables_global: 0,
2394 macro_definitions: 0,
2395 imports: 0,
2396 test_count: 0,
2397 test_assertion_count: 0,
2398 test_suite_count: 0,
2399 coverage_lines_found: 0,
2400 coverage_lines_hit: 0,
2401 coverage_functions_found: 0,
2402 coverage_functions_hit: 0,
2403 coverage_branches_found: 0,
2404 coverage_branches_hit: 0,
2405 cyclomatic_complexity: 0,
2406 lsloc: None,
2407 }
2408}
2409
2410fn accumulate_record_into_summary(entry: &mut LanguageSummary, record: &FileRecord) {
2412 entry.files += 1;
2413 let r = &record.raw_line_categories;
2414 entry.total_physical_lines += r.total_physical_lines;
2415 entry.code_lines += record.effective_counts.code_lines;
2416 entry.comment_lines += record.effective_counts.comment_lines;
2417 entry.blank_lines += record.effective_counts.blank_lines;
2418 entry.mixed_lines_separate += record.effective_counts.mixed_lines_separate;
2419 entry.functions += r.functions;
2420 entry.classes += r.classes;
2421 entry.variables += r.variables;
2422 entry.variables_member += r.variables_member;
2423 entry.variables_local += r.variables_local;
2424 entry.variables_global += r.variables_global;
2425 entry.macro_definitions += r.macro_definitions;
2426 entry.imports += r.imports;
2427 entry.test_count += r.test_count;
2428 entry.test_assertion_count += r.test_assertion_count;
2429 entry.test_suite_count += r.test_suite_count;
2430 entry.cyclomatic_complexity += u64::from(r.cyclomatic_complexity);
2431 if let Some(lsloc) = r.lsloc {
2432 *entry.lsloc.get_or_insert(0) += u64::from(lsloc);
2433 }
2434 if let Some(cov) = &record.coverage {
2435 entry.coverage_lines_found += u64::from(cov.lines_found);
2436 entry.coverage_lines_hit += u64::from(cov.lines_hit);
2437 entry.coverage_functions_found += u64::from(cov.functions_found);
2438 entry.coverage_functions_hit += u64::from(cov.functions_hit);
2439 entry.coverage_branches_found += u64::from(cov.branches_found);
2440 entry.coverage_branches_hit += u64::from(cov.branches_hit);
2441 }
2442}
2443
2444fn build_language_summaries(analyzed: &[FileRecord]) -> Vec<LanguageSummary> {
2445 let mut by_language: BTreeMap<Language, LanguageSummary> = BTreeMap::new();
2446 for record in analyzed {
2447 let Some(language) = record.language else {
2448 continue;
2449 };
2450 let entry = by_language
2451 .entry(language)
2452 .or_insert_with(|| zeroed_summary(language));
2453 accumulate_record_into_summary(entry, record);
2454 }
2455 by_language.into_values().collect()
2456}
2457
2458fn skipped_record(
2459 path: &Path,
2460 root: &Path,
2461 size_bytes: u64,
2462 status: FileStatus,
2463 warnings: Vec<String>,
2464) -> FileRecord {
2465 FileRecord {
2466 path: path_to_string(path),
2467 relative_path: relative_path_string(path, root),
2468 language: None,
2469 size_bytes,
2470 detected_encoding: None,
2471 raw_line_categories: RawLineCounts::default(),
2472 effective_counts: EffectiveCounts::default(),
2473 status,
2474 warnings,
2475 generated: false,
2476 minified: false,
2477 vendor: false,
2478 parse_mode: None,
2479 submodule: None,
2480 coverage: None,
2481 style_analysis: None,
2482 cyclomatic_complexity: None,
2483 lsloc: None,
2484 commit_count: None,
2485 last_commit_date: None,
2486 ownership: None,
2487 content_hash: 0,
2488 }
2489}
2490
2491fn normalize_path_str(raw: &str) -> String {
2504 if let Some(unc) = raw.strip_prefix(r"\\?\UNC\") {
2505 format!("//{}", unc.replace('\\', "/"))
2507 } else if let Some(rest) = raw.strip_prefix(r"\\?\") {
2508 rest.replace('\\', "/")
2509 } else {
2510 raw.replace('\\', "/")
2511 }
2512}
2513
2514fn relative_path_string(path: &Path, root: &Path) -> String {
2515 normalize_path_str(&path.strip_prefix(root).unwrap_or(path).to_string_lossy())
2516}
2517
2518fn path_to_string(path: &Path) -> String {
2519 normalize_path_str(&path.to_string_lossy())
2520}
2521
2522#[derive(Debug, Clone, Default)]
2530pub struct RepositoryLayout {
2531 pub root: PathBuf,
2533 pub root_is_repo: bool,
2535 pub submodule_paths: Vec<PathBuf>,
2537 pub nested_repos: Vec<PathBuf>,
2539}
2540
2541impl RepositoryLayout {
2542 #[must_use]
2548 pub const fn has_multiple_repos(&self) -> bool {
2549 if self.root_is_repo {
2550 !self.nested_repos.is_empty()
2551 } else {
2552 self.nested_repos.len() >= 2
2553 }
2554 }
2555}
2556
2557const REPO_SCAN_MAX_DEPTH: usize = 6;
2559const REPO_SCAN_MAX_DIRS: usize = 4000;
2562
2563#[must_use]
2570pub fn detect_repository_layout(root: &Path) -> RepositoryLayout {
2571 let mut layout = RepositoryLayout {
2572 root: root.to_path_buf(),
2573 root_is_repo: is_git_root(root),
2574 submodule_paths: detect_submodules(root)
2575 .into_iter()
2576 .map(|(_, path)| path)
2577 .collect(),
2578 nested_repos: Vec::new(),
2579 };
2580
2581 let submodule_dirs: HashSet<PathBuf> = layout
2583 .submodule_paths
2584 .iter()
2585 .map(|rel| root.join(rel))
2586 .collect();
2587
2588 let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
2590 let mut visited = 0usize;
2591
2592 while let Some((dir, depth)) = stack.pop() {
2593 if visited >= REPO_SCAN_MAX_DIRS {
2594 break;
2595 }
2596 let Ok(entries) = fs::read_dir(&dir) else {
2597 continue;
2598 };
2599 for entry in entries.flatten() {
2600 let child = entry.path();
2601 if !child.is_dir() || child.file_name().and_then(|n| n.to_str()) == Some(".git") {
2603 continue;
2604 }
2605 visited += 1;
2606 match classify_child(&child, &submodule_dirs, root) {
2607 ChildAction::RecordRepo(rel) => layout.nested_repos.push(rel),
2608 ChildAction::Recurse if depth + 1 < REPO_SCAN_MAX_DEPTH => {
2609 stack.push((child, depth + 1));
2610 }
2611 ChildAction::Skip | ChildAction::Recurse => {}
2612 }
2613 }
2614 }
2615
2616 layout.nested_repos.sort();
2617 layout
2618}
2619
2620enum ChildAction {
2622 Skip,
2624 RecordRepo(PathBuf),
2626 Recurse,
2628}
2629
2630fn classify_child(child: &Path, submodule_dirs: &HashSet<PathBuf>, root: &Path) -> ChildAction {
2633 if submodule_dirs.contains(child) {
2634 ChildAction::Skip
2635 } else if is_git_root(child) {
2636 ChildAction::RecordRepo(relative_path_buf(child, root))
2637 } else {
2638 ChildAction::Recurse
2639 }
2640}
2641
2642fn relative_path_buf(path: &Path, root: &Path) -> PathBuf {
2644 path.strip_prefix(root).unwrap_or(path).to_path_buf()
2645}
2646
2647fn format_multi_repo_warning(layout: &RepositoryLayout) -> String {
2649 const MAX_LISTED: usize = 5;
2650 let total = layout.nested_repos.len();
2651 let listed: Vec<String> = layout
2652 .nested_repos
2653 .iter()
2654 .take(MAX_LISTED)
2655 .map(|p| path_to_string(p))
2656 .collect();
2657 let mut joined = listed.join(", ");
2658 if total > MAX_LISTED {
2659 use std::fmt::Write as _;
2660 let _ = write!(joined, ", … and {} more", total - MAX_LISTED);
2661 }
2662 if layout.root_is_repo {
2663 format!(
2664 "This repository contains {total} nested git {} ({joined}) that are not registered \
2665 submodules. Their files are being counted as part of this project; if that is not \
2666 intended, exclude them or scan each repository separately.",
2667 if total == 1 {
2668 "repository"
2669 } else {
2670 "repositories"
2671 }
2672 )
2673 } else {
2674 format!(
2675 "The selected folder contains {total} independent git repositories ({joined}). \
2676 oxide-sloc analyzes one repository at a time — git metrics and totals are only \
2677 meaningful when the root is a single repository. Select one repository as the root \
2678 (submodules are fine).",
2679 )
2680 }
2681}
2682
2683#[must_use]
2685pub fn detect_submodules(root: &Path) -> Vec<(String, PathBuf)> {
2686 let gitmodules = root.join(".gitmodules");
2687 if !gitmodules.is_file() {
2688 return Vec::new();
2689 }
2690 let Ok(content) = fs::read_to_string(&gitmodules) else {
2691 return Vec::new();
2692 };
2693
2694 let mut result = Vec::new();
2695 let mut current_name: Option<String> = None;
2696 let mut current_path: Option<PathBuf> = None;
2697
2698 for line in content.lines() {
2699 let trimmed = line.trim();
2700 if trimmed.starts_with("[submodule \"") && trimmed.ends_with("\"]") {
2701 if let (Some(name), Some(path)) = (current_name.take(), current_path.take()) {
2702 result.push((name, path));
2703 }
2704 let name = trimmed["[submodule \"".len()..trimmed.len() - 2].to_string();
2705 current_name = Some(name);
2706 } else if let Some(rest) = trimmed.strip_prefix("path")
2707 && let Some(eq_pos) = rest.find('=')
2708 {
2709 let path_str = rest[eq_pos + 1..].trim();
2710 current_path = Some(PathBuf::from(path_str));
2711 }
2712 }
2713 if let (Some(name), Some(path)) = (current_name, current_path) {
2714 result.push((name, path));
2715 }
2716
2717 result
2718}
2719
2720fn build_submodule_summaries(
2721 analyzed: &[FileRecord],
2722 submodules: &[(String, PathBuf)],
2723 root: &Path,
2724) -> Vec<SubmoduleSummary> {
2725 submodules
2726 .iter()
2727 .map(|(name, path)| {
2728 let files: Vec<&FileRecord> = analyzed
2729 .iter()
2730 .filter(|f| f.submodule.as_deref() == Some(name.as_str()))
2731 .collect();
2732
2733 let files_analyzed = files.len() as u64;
2734 let total_physical_lines = files
2735 .iter()
2736 .map(|f| f.raw_line_categories.total_physical_lines)
2737 .sum();
2738 let code_lines = files.iter().map(|f| f.effective_counts.code_lines).sum();
2739 let comment_lines = files.iter().map(|f| f.effective_counts.comment_lines).sum();
2740 let blank_lines = files.iter().map(|f| f.effective_counts.blank_lines).sum();
2741 let language_summaries = build_language_summaries_from_slice(&files);
2742
2743 let git = detect_git_for_run(&root.join(path));
2744
2745 SubmoduleSummary {
2746 name: name.clone(),
2747 relative_path: path.to_string_lossy().replace('\\', "/"),
2748 files_analyzed,
2749 total_physical_lines,
2750 code_lines,
2751 comment_lines,
2752 blank_lines,
2753 language_summaries,
2754 git_commit_short: git.commit_short,
2755 git_commit_long: git.commit_long,
2756 git_branch: git.branch,
2757 git_commit_author: git.author,
2758 git_commit_date: git.commit_date,
2759 git_remote_url: git.remote_url,
2760 }
2761 })
2762 .filter(|s| s.files_analyzed > 0)
2763 .collect()
2764}
2765
2766#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
2768fn dominant_indent_label(files: &[&StyleAnalysis]) -> String {
2769 let mut votes = [0u32; 6];
2770 for f in files {
2771 let idx = match f.indent_style {
2772 IndentStyle::Tabs => 0,
2773 IndentStyle::Spaces2 => 1,
2774 IndentStyle::Spaces4 => 2,
2775 IndentStyle::Spaces8 => 3,
2776 IndentStyle::Mixed => 4,
2777 IndentStyle::Unknown => 5,
2778 };
2779 votes[idx] += 1;
2780 }
2781 let labels = ["Tabs", "2-Space", "4-Space", "8-Space", "Mixed", "\u{2014}"];
2782 labels[votes
2783 .iter()
2784 .enumerate()
2785 .max_by_key(|(_, v)| *v)
2786 .map_or(5, |(i, _)| i)]
2787 .to_string()
2788}
2789
2790#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
2792fn line80_pct(files: &[&StyleAnalysis]) -> u8 {
2793 if files.is_empty() {
2794 return 0;
2795 }
2796 let compliant = files
2797 .iter()
2798 .filter(|f| f.total_lines == 0 || (f.lines_over_80 as f32 / f.total_lines as f32) <= 0.05)
2799 .count() as u32;
2800 ((compliant * 100) / files.len() as u32) as u8
2801}
2802
2803#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
2806fn line_col_pct(files: &[&StyleAnalysis], threshold: u16) -> u8 {
2807 if files.is_empty() {
2808 return 0;
2809 }
2810 let compliant = files
2811 .iter()
2812 .filter(|f| {
2813 let over = if threshold <= 80 {
2814 f.lines_over_80
2815 } else if threshold <= 100 {
2816 f.lines_over_100
2817 } else {
2818 f.lines_over_120
2819 };
2820 f.total_lines == 0 || (over as f32 / f.total_lines as f32) <= 0.05
2821 })
2822 .count() as u32;
2823 ((compliant * 100) / files.len() as u32) as u8
2824}
2825
2826#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
2828fn build_language_group(
2829 family: &str,
2830 files: &[&StyleAnalysis],
2831 col_threshold: u16,
2832) -> LanguageStyleGroup {
2833 let count = files.len() as u32;
2834
2835 let mut all_names: Vec<String> = Vec::new();
2837 for f in files {
2838 for g in &f.guide_scores {
2839 if !all_names.contains(&g.name) {
2840 all_names.push(g.name.clone());
2841 }
2842 }
2843 }
2844
2845 let mut guide_avg_scores: Vec<(String, u8)> = all_names
2846 .into_iter()
2847 .map(|name| {
2848 let sum: u32 = files
2849 .iter()
2850 .filter_map(|f| f.guide_scores.iter().find(|g| g.name == name))
2851 .map(|g| u32::from(g.score_pct))
2852 .sum();
2853 let avg = (sum / count) as u8;
2854 (name, avg)
2855 })
2856 .collect();
2857 guide_avg_scores.sort_by_key(|s| std::cmp::Reverse(s.1));
2858
2859 let (dominant_guide, dominant_score_pct) = guide_avg_scores
2860 .first()
2861 .map(|(n, s)| (n.clone(), *s))
2862 .unwrap_or_default();
2863
2864 let lcp = line_col_pct(files, col_threshold);
2865 LanguageStyleGroup {
2866 language_family: family.to_string(),
2867 files_count: count,
2868 dominant_guide,
2869 dominant_score_pct,
2870 common_indent_style: dominant_indent_label(files),
2871 guide_avg_scores,
2872 line80_compliant_pct: line80_pct(files),
2873 line_col_compliant_pct: lcp,
2874 }
2875}
2876
2877#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
2880fn build_style_summary(analyzed: &[FileRecord], col_threshold: u16) -> Option<StyleSummary> {
2881 let all_style: Vec<&StyleAnalysis> = analyzed
2882 .iter()
2883 .filter_map(|f| f.style_analysis.as_ref())
2884 .collect();
2885
2886 if all_style.is_empty() {
2887 return None;
2888 }
2889
2890 let mut families: std::collections::BTreeMap<&str, Vec<&StyleAnalysis>> =
2892 std::collections::BTreeMap::new();
2893 for sa in &all_style {
2894 families
2895 .entry(sa.language_family.as_str())
2896 .or_default()
2897 .push(sa);
2898 }
2899
2900 let mut by_language: Vec<LanguageStyleGroup> = families
2901 .iter()
2902 .map(|(family, files)| build_language_group(family, files, col_threshold))
2903 .collect();
2904 by_language.sort_by_key(|g| std::cmp::Reverse(g.files_count));
2905
2906 let files_analyzed = all_style.len() as u32;
2907 let common_indent_style = dominant_indent_label(&all_style);
2908 let line80_compliant_pct = line80_pct(&all_style);
2909 let line_col_compliant_pct = line_col_pct(&all_style, col_threshold);
2910
2911 Some(StyleSummary {
2912 files_analyzed,
2913 common_indent_style,
2914 line80_compliant_pct,
2915 line_col_compliant_pct,
2916 col_threshold,
2917 by_language,
2918 })
2919}
2920
2921fn build_language_summaries_from_slice(files: &[&FileRecord]) -> Vec<LanguageSummary> {
2922 let mut map: BTreeMap<String, LanguageSummary> = BTreeMap::new();
2923 for file in files {
2924 let Some(lang) = file.language else { continue };
2925 let entry = map
2926 .entry(lang.display_name().to_string())
2927 .or_insert_with(|| zeroed_summary(lang));
2928 accumulate_record_into_summary(entry, file);
2929 }
2930 map.into_values().collect()
2931}
2932
2933fn file_name_eq(path: &Path, expected: &str) -> bool {
2934 path.file_name()
2935 .and_then(|name| name.to_str())
2936 .is_some_and(|name| name == expected)
2937}
2938
2939fn is_excluded_dir_path(path: &Path, excluded_dirs: &[String]) -> bool {
2940 path.components().any(|component| {
2941 component
2942 .as_os_str()
2943 .to_str()
2944 .is_some_and(|part| excluded_dirs.iter().any(|excluded| excluded == part))
2945 })
2946}
2947
2948fn is_vendor_path(path: &Path) -> bool {
2949 path.components().any(|component| {
2950 component
2951 .as_os_str()
2952 .to_str()
2953 .is_some_and(|part| matches!(part, "vendor" | "node_modules" | "packages"))
2954 })
2955}
2956
2957fn is_known_lockfile(path: &Path) -> bool {
2958 path.file_name()
2959 .and_then(|name| name.to_str())
2960 .is_some_and(|name| {
2961 matches!(
2962 name,
2963 "Cargo.lock"
2964 | "package-lock.json"
2965 | "yarn.lock"
2966 | "pnpm-lock.yaml"
2967 | "Pipfile.lock"
2968 | "poetry.lock"
2969 | "composer.lock"
2970 )
2971 })
2972}
2973
2974fn looks_generated(path: &Path, bytes: &[u8]) -> bool {
2975 let file_name = path
2976 .file_name()
2977 .and_then(|name| name.to_str())
2978 .unwrap_or_default();
2979 if file_name.contains(".generated.") || file_name.contains(".g.") {
2980 return true;
2981 }
2982
2983 let sample = String::from_utf8_lossy(&bytes[..bytes.len().min(GENERATED_SAMPLE_BYTES)])
2984 .to_ascii_lowercase();
2985 sample.contains("@generated") || sample.contains("generated by")
2986}
2987
2988fn looks_minified(path: &Path, bytes: &[u8]) -> bool {
2989 let file_name = path
2990 .file_name()
2991 .and_then(|name| name.to_str())
2992 .unwrap_or_default();
2993 if file_name.contains(".min.") {
2994 return true;
2995 }
2996
2997 let sample = String::from_utf8_lossy(&bytes[..bytes.len().min(MINIFIED_SAMPLE_BYTES)]);
2998 let longest_line = sample.lines().map(str::len).max().unwrap_or(0);
2999 let whitespace = sample.chars().filter(|c| c.is_whitespace()).count();
3000 longest_line > MINIFIED_LINE_THRESHOLD && whitespace * 100 < sample.len().max(1)
3001}
3002
3003fn is_binary(bytes: &[u8]) -> bool {
3004 if bytes.starts_with(&[0xEF, 0xBB, 0xBF])
3005 || bytes.starts_with(&[0xFF, 0xFE])
3006 || bytes.starts_with(&[0xFE, 0xFF])
3007 {
3008 return false;
3009 }
3010
3011 let sample = &bytes[..bytes.len().min(BINARY_SAMPLE_BYTES)];
3012 sample.contains(&0)
3013}
3014
3015fn decode_utf16_bom(
3018 bom_stripped: &[u8],
3019 encoding: &'static encoding_rs::Encoding,
3020 label: &str,
3021) -> (String, String, Vec<String>) {
3022 let (cow, _, had_errors) = encoding.decode(bom_stripped);
3023 let mut warnings = Vec::new();
3024 if had_errors {
3025 warnings.push(format!("{label} decode contained replacement characters"));
3026 }
3027 (cow.into_owned(), label.into(), warnings)
3028}
3029
3030fn decode_bytes(bytes: &[u8]) -> std::result::Result<(String, String, Vec<String>), String> {
3031 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
3032 let text = String::from_utf8(bytes[3..].to_vec()).map_err(|err| err.to_string())?;
3033 return Ok((text, "utf-8-bom".into(), vec![]));
3034 }
3035 if bytes.starts_with(&[0xFF, 0xFE]) {
3036 return Ok(decode_utf16_bom(&bytes[2..], UTF_16LE, "utf-16le"));
3037 }
3038 if bytes.starts_with(&[0xFE, 0xFF]) {
3039 return Ok(decode_utf16_bom(&bytes[2..], UTF_16BE, "utf-16be"));
3040 }
3041
3042 #[allow(clippy::option_if_let_else)]
3044 if let Ok(text) = String::from_utf8(bytes.to_vec()) {
3045 Ok((text, "utf-8".into(), vec![]))
3046 } else {
3047 let (cow, _, had_errors) = WINDOWS_1252.decode(bytes);
3048 let mut warnings = vec!["decoded using windows-1252 fallback".into()];
3049 if had_errors {
3050 warnings.push("fallback decode contained replacement characters".into());
3051 }
3052 Ok((cow.into_owned(), "windows-1252".into(), warnings))
3053 }
3054}
3055
3056fn compile_globset(patterns: &[String]) -> Result<Option<GlobSet>> {
3057 if patterns.is_empty() {
3058 return Ok(None);
3059 }
3060
3061 let mut builder = GlobSetBuilder::new();
3062 for pattern in patterns {
3063 builder
3064 .add(Glob::new(pattern).with_context(|| format!("invalid glob pattern: {pattern}"))?);
3065 }
3066 Ok(Some(
3067 builder.build().context("failed to compile glob filters")?,
3068 ))
3069}
3070
3071fn parse_enabled_languages(enabled: &[String]) -> Result<Option<BTreeSet<Language>>> {
3072 if enabled.is_empty() {
3073 return Ok(None);
3074 }
3075
3076 let supported = supported_languages();
3077 let mut set = BTreeSet::new();
3078 for name in enabled {
3079 let language = Language::from_name(name)
3080 .with_context(|| format!("unsupported language in config: {name}"))?;
3081 if !supported.contains(&language) {
3082 anyhow::bail!("language {name} is not supported in this build");
3083 }
3084 set.insert(language);
3085 }
3086 Ok(Some(set))
3087}
3088
3089pub fn write_json(run: &AnalysisRun, output_path: &Path) -> Result<()> {
3093 let json = serde_json::to_string_pretty(run).context("failed to serialize analysis run")?;
3094 fs::write(output_path, json)
3095 .with_context(|| format!("failed to write JSON output to {}", output_path.display()))
3096}
3097
3098pub fn read_json(path: &Path) -> Result<AnalysisRun> {
3102 let contents = fs::read_to_string(path)
3103 .with_context(|| format!("failed to read result file {}", path.display()))?;
3104 serde_json::from_str(&contents)
3105 .with_context(|| format!("failed to parse JSON result {}", path.display()))
3106}
3107
3108#[cfg(test)]
3109mod tests {
3110 use super::*;
3111
3112 #[test]
3113 fn normalize_path_str_strips_verbatim_drive_prefix() {
3114 assert_eq!(
3115 normalize_path_str(r"\\?\C:\jenkins-agent\repo\CMakeLists.txt"),
3116 "C:/jenkins-agent/repo/CMakeLists.txt"
3117 );
3118 }
3119
3120 #[test]
3121 fn normalize_path_str_strips_verbatim_unc_prefix() {
3122 assert_eq!(
3123 normalize_path_str(r"\\?\UNC\server\share\proj\main.rs"),
3124 "//server/share/proj/main.rs"
3125 );
3126 }
3127
3128 #[test]
3129 fn normalize_path_str_leaves_plain_paths_unchanged() {
3130 assert_eq!(normalize_path_str(r"src\foo\bar.rs"), "src/foo/bar.rs");
3132 assert_eq!(normalize_path_str("src/foo/bar.rs"), "src/foo/bar.rs");
3134 assert_eq!(normalize_path_str(r"C:\foo\bar.rs"), "C:/foo/bar.rs");
3136 }
3137
3138 #[test]
3139 fn effective_counts_respect_code_only_policy() {
3140 let raw = RawLineCounts {
3141 code_only_lines: 2,
3142 single_comment_only_lines: 1,
3143 mixed_code_single_comment_lines: 3,
3144 docstring_comment_lines: 2,
3145 ..RawLineCounts::default()
3146 };
3147 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, true);
3148 assert_eq!(counts.code_lines, 5);
3149 assert_eq!(counts.comment_lines, 3);
3150 }
3151
3152 #[test]
3153 fn effective_counts_can_separate_mixed() {
3154 let raw = RawLineCounts {
3155 mixed_code_single_comment_lines: 2,
3156 mixed_code_multi_comment_lines: 1,
3157 ..RawLineCounts::default()
3158 };
3159 let counts =
3160 compute_effective_counts(&raw, MixedLinePolicy::SeparateMixedCategory, true, true);
3161 assert_eq!(counts.mixed_lines_separate, 3);
3162 assert_eq!(counts.code_lines, 0);
3163 assert_eq!(counts.comment_lines, 0);
3164 }
3165
3166 #[test]
3167 fn windows_1252_fallback_decodes() {
3168 let bytes = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x96, 0x57];
3169 let (text, encoding, warnings) = decode_bytes(&bytes).unwrap();
3170 assert_eq!(encoding, "windows-1252");
3171 assert!(text.contains('\u{2013}'));
3173 assert!(!warnings.is_empty());
3174 }
3175
3176 #[test]
3179 fn is_binary_detects_null_byte() {
3180 let bytes = b"hello\x00world";
3181 assert!(is_binary(bytes));
3182 }
3183
3184 #[test]
3185 fn is_binary_clean_text_is_not_binary() {
3186 let bytes = b"fn main() { println!(\"hello\"); }";
3187 assert!(!is_binary(bytes));
3188 }
3189
3190 #[test]
3191 fn is_binary_utf8_bom_not_binary() {
3192 let bytes = b"\xef\xbb\xbffn main() {}";
3193 assert!(!is_binary(bytes));
3194 }
3195
3196 #[test]
3197 fn looks_generated_at_generated_marker() {
3198 let bytes = b"// @generated by protoc-gen-rust\nfn foo() {}";
3199 assert!(looks_generated(Path::new("foo.rs"), bytes));
3200 }
3201
3202 #[test]
3203 fn looks_generated_do_not_edit_marker() {
3204 let bytes = b"// Code generated by build.rs. DO NOT EDIT.\nuse foo;";
3206 assert!(looks_generated(Path::new("foo.rs"), bytes));
3207 let bytes2 = b"// @generated\nuse foo;";
3209 assert!(looks_generated(Path::new("foo.rs"), bytes2));
3210 }
3211
3212 #[test]
3213 fn looks_generated_normal_file_not_generated() {
3214 let bytes = b"fn main() {\n println!(\"hello\");\n}\n";
3215 assert!(!looks_generated(Path::new("main.rs"), bytes));
3216 }
3217
3218 #[test]
3219 fn looks_minified_dot_min_filename() {
3220 let bytes = b"function a(){return 1}";
3221 assert!(looks_minified(Path::new("bundle.min.js"), bytes));
3222 }
3223
3224 #[test]
3225 fn looks_minified_normal_file_not_minified() {
3226 let bytes = b"function hello() {\n return 1;\n}\n";
3227 assert!(!looks_minified(Path::new("app.js"), bytes));
3228 }
3229
3230 #[test]
3231 fn looks_minified_very_long_line() {
3232 let long_line: Vec<u8> = b"x".repeat(MINIFIED_LINE_THRESHOLD + 1);
3233 assert!(looks_minified(Path::new("app.js"), &long_line));
3234 }
3235
3236 #[test]
3237 fn is_known_lockfile_cargo_lock() {
3238 assert!(is_known_lockfile(Path::new("Cargo.lock")));
3239 }
3240
3241 #[test]
3242 fn is_known_lockfile_package_lock_json() {
3243 assert!(is_known_lockfile(Path::new("package-lock.json")));
3244 }
3245
3246 #[test]
3247 fn is_known_lockfile_yarn_lock() {
3248 assert!(is_known_lockfile(Path::new("yarn.lock")));
3249 }
3250
3251 #[test]
3252 fn is_known_lockfile_normal_file_is_not_lockfile() {
3253 assert!(!is_known_lockfile(Path::new("src/lib.rs")));
3254 }
3255
3256 #[test]
3257 fn is_vendor_path_node_modules() {
3258 assert!(is_vendor_path(Path::new("node_modules/react/index.js")));
3259 }
3260
3261 #[test]
3262 fn is_vendor_path_vendor_dir() {
3263 assert!(is_vendor_path(Path::new("vendor/anyhow/src/lib.rs")));
3264 }
3265
3266 #[test]
3267 fn is_vendor_path_normal_src_is_not_vendor() {
3268 assert!(!is_vendor_path(Path::new("src/lib.rs")));
3269 }
3270
3271 #[test]
3272 fn is_excluded_dir_path_matches_excluded() {
3273 let excluded = vec![".git".into(), "target".into()];
3274 assert!(is_excluded_dir_path(Path::new(".git/config"), &excluded));
3275 }
3276
3277 #[test]
3278 fn is_excluded_dir_path_non_excluded_is_ok() {
3279 let excluded = vec![".git".into(), "target".into()];
3280 assert!(!is_excluded_dir_path(Path::new("src/main.rs"), &excluded));
3281 }
3282
3283 #[test]
3284 fn decode_bytes_utf8_bom_stripped() {
3285 let bytes = b"\xef\xbb\xbffn main() {}";
3286 let (text, encoding, _) = decode_bytes(bytes).unwrap();
3287 assert!(
3289 encoding.contains("utf-8"),
3290 "should be utf-8 variant, got {encoding}"
3291 );
3292 assert!(text.starts_with("fn"));
3293 }
3294
3295 #[test]
3296 fn decode_bytes_plain_utf8() {
3297 let bytes = b"hello world";
3298 let (text, encoding, warnings) = decode_bytes(bytes).unwrap();
3299 assert_eq!(encoding, "utf-8");
3300 assert_eq!(text, "hello world");
3301 assert!(warnings.is_empty());
3302 }
3303
3304 #[test]
3307 fn decode_bytes_utf16le_bom() {
3308 let mut bytes = vec![0xFF, 0xFE];
3310 for ch in "hi\n".encode_utf16() {
3311 bytes.extend_from_slice(&ch.to_le_bytes());
3312 }
3313 let (text, encoding, _warnings) = decode_bytes(&bytes).unwrap();
3314 assert_eq!(encoding, "utf-16le");
3315 assert!(text.contains('h') && text.contains('i'));
3316 }
3317
3318 #[test]
3319 fn decode_bytes_utf16be_bom() {
3320 let mut bytes = vec![0xFE, 0xFF];
3322 for ch in "ok\n".encode_utf16() {
3323 bytes.extend_from_slice(&ch.to_be_bytes());
3324 }
3325 let (text, encoding, _warnings) = decode_bytes(&bytes).unwrap();
3326 assert_eq!(encoding, "utf-16be");
3327 assert!(text.contains('o') && text.contains('k'));
3328 }
3329
3330 #[test]
3331 fn is_binary_utf16le_bom_not_binary() {
3332 let bytes = &[0xFF, 0xFE, 0x68, 0x00];
3334 assert!(!is_binary(bytes));
3335 }
3336
3337 #[test]
3338 fn is_binary_utf16be_bom_not_binary() {
3339 let bytes = &[0xFE, 0xFF, 0x00, 0x68];
3340 assert!(!is_binary(bytes));
3341 }
3342
3343 #[test]
3346 fn effective_counts_code_and_comment_policy() {
3347 let raw = RawLineCounts {
3348 mixed_code_single_comment_lines: 3,
3349 mixed_code_multi_comment_lines: 2,
3350 ..RawLineCounts::default()
3351 };
3352 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeAndComment, true, true);
3353 assert_eq!(counts.code_lines, 5);
3355 assert_eq!(counts.comment_lines, 5);
3356 assert_eq!(counts.mixed_lines_separate, 0);
3357 }
3358
3359 #[test]
3360 fn effective_counts_comment_only_policy() {
3361 let raw = RawLineCounts {
3362 mixed_code_single_comment_lines: 4,
3363 mixed_code_multi_comment_lines: 1,
3364 ..RawLineCounts::default()
3365 };
3366 let counts = compute_effective_counts(&raw, MixedLinePolicy::CommentOnly, true, true);
3367 assert_eq!(counts.code_lines, 0);
3368 assert_eq!(counts.comment_lines, 5);
3369 assert_eq!(counts.mixed_lines_separate, 0);
3370 }
3371
3372 #[test]
3373 fn effective_counts_docstrings_as_code_when_flag_false() {
3374 let raw = RawLineCounts {
3375 code_only_lines: 10,
3376 docstring_comment_lines: 3,
3377 ..RawLineCounts::default()
3378 };
3379 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, false, true);
3381 assert_eq!(counts.code_lines, 13);
3382 assert_eq!(counts.comment_lines, 0);
3383 }
3384
3385 #[test]
3386 fn effective_counts_exclude_compiler_directives() {
3387 let raw = RawLineCounts {
3388 code_only_lines: 10,
3389 compiler_directive_lines: 3,
3390 ..RawLineCounts::default()
3391 };
3392 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, false);
3394 assert_eq!(counts.code_lines, 7);
3395 }
3396
3397 #[test]
3398 fn effective_counts_directives_not_subtracted_below_zero() {
3399 let raw = RawLineCounts {
3400 code_only_lines: 2,
3401 compiler_directive_lines: 5, ..RawLineCounts::default()
3403 };
3404 let counts = compute_effective_counts(&raw, MixedLinePolicy::CodeOnly, true, false);
3405 assert_eq!(counts.code_lines, 0); }
3407
3408 #[test]
3411 fn cocomo_organic_computes_positive_values() {
3412 let est = compute_cocomo(5_000, CocomoMode::Organic);
3413 assert!(est.ksloc > 0.0);
3414 assert!(est.effort_person_months > 0.0);
3415 assert!(est.duration_months > 0.0);
3416 assert!(est.avg_staff > 0.0);
3417 assert_eq!(est.mode, CocomoMode::Organic);
3418 }
3419
3420 #[test]
3421 fn cocomo_semi_detached_computes_positive_values() {
3422 let est = compute_cocomo(20_000, CocomoMode::SemiDetached);
3423 assert!(est.ksloc > 0.0);
3424 assert!(est.effort_person_months > 0.0);
3425 assert!(est.duration_months > 0.0);
3426 assert_eq!(est.mode, CocomoMode::SemiDetached);
3427 }
3428
3429 #[test]
3430 fn cocomo_embedded_computes_positive_values() {
3431 let est = compute_cocomo(100_000, CocomoMode::Embedded);
3432 assert!(est.effort_person_months > 0.0);
3433 assert_eq!(est.mode, CocomoMode::Embedded);
3434 }
3435
3436 #[test]
3437 fn cocomo_zero_lines_produces_zero_effort() {
3438 let est = compute_cocomo(0, CocomoMode::Organic);
3439 assert!((est.ksloc).abs() < f64::EPSILON);
3440 assert!((est.effort_person_months - 0.0).abs() < 0.01);
3442 }
3443
3444 #[test]
3447 fn parse_activity_log_counts_and_dates_per_file() {
3448 let out = "\u{0}2024-03-02T10:00:00+00:00\n\
3449 M\tsrc/a.rs\n\
3450 A\tsrc/b.rs\n\
3451 \u{0}2024-03-01T09:00:00+00:00\n\
3452 M\tsrc/a.rs\n";
3453 let map = parse_activity_log(out);
3454 assert_eq!(map["src/a.rs"].0, 2, "a.rs touched in two commits");
3455 assert_eq!(map["src/b.rs"].0, 1, "b.rs touched once");
3456 assert_eq!(
3458 map["src/a.rs"].1.as_deref(),
3459 Some("2024-03-02T10:00:00+00:00")
3460 );
3461 }
3462
3463 #[test]
3464 fn parse_activity_log_attributes_rename_to_new_path() {
3465 let out = "\u{0}2024-03-02T10:00:00+00:00\nR100\tsrc/old.rs\tsrc/new.rs\n";
3466 let map = parse_activity_log(out);
3467 assert_eq!(map["src/new.rs"].0, 1);
3468 assert!(!map.contains_key("src/old.rs"));
3469 }
3470
3471 #[test]
3472 fn parse_activity_log_empty_is_empty() {
3473 assert!(parse_activity_log("").is_empty());
3474 }
3475
3476 #[test]
3479 fn parse_blame_porcelain_extracts_one_identity_per_line() {
3480 let out = "\
3481abc123 1 1 2
3482author Nima Shafie
3483author-mail <nimzshafie@gmail.com>
3484author-time 1700000000
3485summary first
3486filename src/a.rs
3487\tfirst line of code
3488abc123 2 2
3489author Nima Shafie
3490author-mail <nimzshafie@gmail.com>
3491\tsecond line
3492def456 3 3
3493author Other Dev
3494author-mail <other@example.com>
3495\tthird line
3496";
3497 let ids = parse_blame_porcelain(out);
3498 assert_eq!(ids.len(), 3, "one identity per TAB-prefixed content line");
3499 assert_eq!(ids[0].name, "Nima Shafie");
3500 assert_eq!(ids[0].email, "nimzshafie@gmail.com");
3501 assert_eq!(ids[2].name, "Other Dev");
3502 assert_eq!(ids[2].email, "other@example.com");
3503 }
3504
3505 #[test]
3506 fn parse_blame_porcelain_empty_is_empty() {
3507 assert!(parse_blame_porcelain("").is_empty());
3508 }
3509
3510 #[test]
3511 fn normalize_email_key_merges_case_and_plus_tag() {
3512 let a = RawIdentity {
3513 name: "Nima Shafie".into(),
3514 email: "Nima@Example.COM".into(),
3515 };
3516 let b = RawIdentity {
3517 name: "nshafie".into(),
3518 email: "nima+work@example.com".into(),
3519 };
3520 assert_eq!(normalize_email_key(&a), normalize_email_key(&b));
3521 }
3522
3523 #[test]
3524 fn normalize_email_key_distinct_emails_do_not_merge() {
3525 let a = RawIdentity {
3526 name: "Nima Shafie".into(),
3527 email: "nima@corp.example".into(),
3528 };
3529 let b = RawIdentity {
3530 name: "Nima Shafie".into(),
3531 email: "nima@personal.example".into(),
3532 };
3533 assert_ne!(normalize_email_key(&a), normalize_email_key(&b));
3535 }
3536
3537 #[test]
3538 fn normalize_email_key_missing_email_falls_back_to_name() {
3539 let a = RawIdentity {
3540 name: "Anon Dev".into(),
3541 email: String::new(),
3542 };
3543 let b = RawIdentity {
3544 name: "anon dev".into(),
3545 email: "not.committed.yet".into(),
3546 };
3547 assert_eq!(normalize_email_key(&a), "name:anon dev");
3548 assert_eq!(normalize_email_key(&a), normalize_email_key(&b));
3549 }
3550
3551 #[test]
3552 fn author_resolver_folds_same_email_and_orders_by_code() {
3553 let mut r = AuthorResolver::default();
3554 let id_a1 = r.resolve(&RawIdentity {
3555 name: "Nima Shafie".into(),
3556 email: "nima@example.com".into(),
3557 });
3558 let id_a2 = r.resolve(&RawIdentity {
3559 name: "nshafie".into(),
3560 email: "NIMA@example.com".into(),
3561 });
3562 let id_b = r.resolve(&RawIdentity {
3563 name: "Other".into(),
3564 email: "other@example.com".into(),
3565 });
3566 assert_eq!(id_a1, id_a2, "same email folds into one author");
3567 assert_ne!(id_a1, id_b);
3568
3569 r.authors[id_a1 as usize].counts.code_lines = 10;
3571 r.authors[id_b as usize].counts.code_lines = 50;
3572 let mut records: Vec<FileRecord> = Vec::new();
3573 let authors = r.finish(&mut records);
3574 assert_eq!(authors.len(), 2);
3575 assert_eq!(authors[0].canonical_email, "other@example.com");
3576 assert_eq!(authors[0].id, 0);
3577 assert_eq!(authors[1].aliases.len(), 2, "two spellings recorded");
3578 }
3579
3580 fn author(id: u32, name: &str, email: &str, code: u64) -> Author {
3583 Author {
3584 id,
3585 canonical_name: name.into(),
3586 canonical_email: email.into(),
3587 aliases: vec![RawIdentity {
3588 name: name.into(),
3589 email: email.into(),
3590 }],
3591 counts: AuthorLineCounts {
3592 code_lines: code,
3593 comment_lines: 0,
3594 blank_lines: 0,
3595 total_lines: code,
3596 },
3597 }
3598 }
3599
3600 fn minimal_run_with_authors(authors: Vec<Author>) -> AnalysisRun {
3602 AnalysisRun {
3603 tool: ToolMetadata {
3604 name: "sloc".into(),
3605 version: "0.0.1".into(),
3606 run_id: "merge-test".into(),
3607 timestamp_utc: Utc::now(),
3608 },
3609 environment: EnvironmentMetadata {
3610 operating_system: "test".into(),
3611 architecture: "x86_64".into(),
3612 runtime_mode: "test".into(),
3613 initiator_username: "tester".into(),
3614 initiator_hostname: "testhost".into(),
3615 ci_name: None,
3616 },
3617 effective_configuration: AppConfig::default(),
3618 input_roots: vec!["/tmp/test".into()],
3619 summary_totals: SummaryTotals::default(),
3620 totals_by_language: vec![],
3621 per_file_records: vec![FileRecord {
3622 path: "a.rs".into(),
3623 relative_path: "a.rs".into(),
3624 language: Some(Language::Rust),
3625 size_bytes: 50,
3626 detected_encoding: Some("utf-8".into()),
3627 raw_line_categories: RawLineCounts::default(),
3628 effective_counts: EffectiveCounts::default(),
3629 status: FileStatus::AnalyzedExact,
3630 warnings: vec![],
3631 generated: false,
3632 minified: false,
3633 vendor: false,
3634 parse_mode: Some(ParseMode::Lexical),
3635 submodule: None,
3636 coverage: None,
3637 style_analysis: None,
3638 cyclomatic_complexity: None,
3639 lsloc: None,
3640 commit_count: None,
3641 last_commit_date: None,
3642 ownership: None,
3643 content_hash: 0,
3644 }],
3645 skipped_file_records: vec![],
3646 warnings: vec![],
3647 submodule_summaries: vec![],
3648 git_commit_short: None,
3649 git_branch: None,
3650 git_commit_long: None,
3651 git_commit_author: None,
3652 git_tags: None,
3653 git_nearest_tag: None,
3654 git_commit_date: None,
3655 git_remote_url: None,
3656 style_summary: None,
3657 cocomo: None,
3658 uloc: 0,
3659 dryness_pct: None,
3660 duplicate_groups: vec![],
3661 duplicates_excluded: 0,
3662 authors,
3663 }
3664 }
3665
3666 #[test]
3667 fn identity_map_merge_and_unmerge() {
3668 let mut map = IdentityMap::default();
3669 map.merge(
3670 &["nima@corp.com".into(), "nima@personal.com".into()],
3671 Some("Nima Shafie"),
3672 );
3673 assert_eq!(map.groups.len(), 1);
3674 assert!(map.group_for("NIMA@CORP.COM").is_some(), "case-insensitive");
3675 map.merge(
3677 &["nima@corp.com".into(), "nima@laptop.com".into()],
3678 Some("Nima Shafie"),
3679 );
3680 assert_eq!(map.groups.len(), 1);
3681 assert_eq!(map.groups[0].members.len(), 3);
3682 let canonical = map.groups[0].canonical_email.clone();
3683 map.unmerge(&canonical);
3684 assert!(map.groups.is_empty());
3685 }
3686
3687 #[test]
3688 fn identity_map_to_mailmap_lists_aliases() {
3689 let mut map = IdentityMap::default();
3690 map.merge(&["a@x.com".into(), "b@y.com".into()], Some("Real Name"));
3691 let mm = map.to_mailmap();
3692 assert!(mm.contains("Real Name <a@x.com> <b@y.com>"));
3694 assert_eq!(mm.matches("Real Name <").count(), 1);
3695 }
3696
3697 #[test]
3698 fn apply_identity_map_folds_authors_and_ownership() {
3699 let mut run = minimal_run_with_authors(vec![
3700 author(0, "Nima Shafie", "nima@corp.com", 100),
3701 author(1, "nshafie", "nima@personal.com", 40),
3702 author(2, "Other", "other@x.com", 30),
3703 ]);
3704 run.per_file_records[0].ownership = Some(vec![
3706 FileOwnership {
3707 author_id: 0,
3708 counts: AuthorLineCounts {
3709 code_lines: 100,
3710 comment_lines: 0,
3711 blank_lines: 0,
3712 total_lines: 100,
3713 },
3714 },
3715 FileOwnership {
3716 author_id: 1,
3717 counts: AuthorLineCounts {
3718 code_lines: 40,
3719 comment_lines: 0,
3720 blank_lines: 0,
3721 total_lines: 40,
3722 },
3723 },
3724 ]);
3725
3726 let mut map = IdentityMap::default();
3727 map.merge(
3728 &["nima@corp.com".into(), "nima@personal.com".into()],
3729 Some("Nima Shafie"),
3730 );
3731 apply_identity_map(&mut run, &map);
3732
3733 assert_eq!(run.authors.len(), 2, "two identities folded into one");
3734 let nima = run
3735 .authors
3736 .iter()
3737 .find(|a| a.canonical_name == "Nima Shafie")
3738 .expect("merged author present");
3739 assert_eq!(nima.counts.code_lines, 140, "counts summed");
3740 assert_eq!(nima.aliases.len(), 2, "both aliases retained");
3741 assert_eq!(run.authors[0].canonical_name, "Nima Shafie", "sorts first");
3742 let own = run.per_file_records[0].ownership.as_ref().unwrap();
3744 let nima_own = own
3745 .iter()
3746 .find(|o| o.author_id == run.authors[0].id)
3747 .unwrap();
3748 assert_eq!(nima_own.counts.code_lines, 140);
3749 }
3750
3751 #[test]
3754 fn parse_url_line_extracts_url() {
3755 assert_eq!(
3756 parse_url_line("url = https://example.com/repo.git"),
3757 Some("https://example.com/repo.git")
3758 );
3759 }
3760
3761 #[test]
3762 fn parse_url_line_returns_none_for_non_url_key() {
3763 assert_eq!(
3764 parse_url_line("fetch = +refs/heads/*:refs/remotes/origin/*"),
3765 None
3766 );
3767 }
3768
3769 #[test]
3770 fn parse_url_line_returns_none_for_empty_url() {
3771 assert_eq!(parse_url_line("url = "), None);
3772 }
3773
3774 #[test]
3775 fn looks_generated_generated_filename_extension() {
3776 let bytes = b"// normal code\n";
3778 assert!(looks_generated(Path::new("schema.generated.ts"), bytes));
3779 }
3780
3781 #[test]
3782 fn looks_generated_dot_g_extension() {
3783 let bytes = b"// normal code\n";
3784 assert!(looks_generated(Path::new("parser.g.cs"), bytes));
3785 }
3786
3787 #[test]
3788 fn looks_minified_whitespace_ratio_is_ok() {
3789 let normal = b"var x=1,y=2,z=3;\n";
3791 assert!(!looks_minified(Path::new("app.js"), normal));
3792 }
3793
3794 #[test]
3795 fn is_known_lockfile_pnpm() {
3796 assert!(is_known_lockfile(Path::new("pnpm-lock.yaml")));
3797 }
3798
3799 #[test]
3800 fn is_known_lockfile_pipfile() {
3801 assert!(is_known_lockfile(Path::new("Pipfile.lock")));
3802 }
3803
3804 #[test]
3805 fn is_known_lockfile_poetry() {
3806 assert!(is_known_lockfile(Path::new("poetry.lock")));
3807 }
3808
3809 #[test]
3810 fn is_known_lockfile_composer() {
3811 assert!(is_known_lockfile(Path::new("composer.lock")));
3812 }
3813
3814 #[test]
3817 fn relative_path_string_strips_root_prefix() {
3818 let path = Path::new("/tmp/project/src/lib.rs");
3819 let root = Path::new("/tmp/project");
3820 let rel = relative_path_string(path, root);
3821 assert_eq!(rel, "src/lib.rs");
3822 }
3823
3824 #[test]
3825 fn relative_path_string_falls_back_to_full_path() {
3826 let path = Path::new("/other/dir/file.rs");
3828 let root = Path::new("/tmp/project");
3829 let rel = relative_path_string(path, root);
3830 assert!(!rel.is_empty());
3832 }
3833
3834 #[test]
3837 fn find_duplicate_groups_returns_empty_for_unique_hashes() {
3838 use sloc_languages::{Language, ParseMode, RawLineCounts};
3839 let make_rec = |hash: u64, path: &str| FileRecord {
3840 path: path.into(),
3841 relative_path: path.into(),
3842 language: Some(Language::Rust),
3843 size_bytes: 10,
3844 detected_encoding: Some("utf-8".into()),
3845 raw_line_categories: RawLineCounts::default(),
3846 effective_counts: EffectiveCounts::default(),
3847 status: FileStatus::AnalyzedExact,
3848 warnings: vec![],
3849 generated: false,
3850 minified: false,
3851 vendor: false,
3852 parse_mode: Some(ParseMode::Lexical),
3853 submodule: None,
3854 coverage: None,
3855 style_analysis: None,
3856 cyclomatic_complexity: None,
3857 lsloc: None,
3858 commit_count: None,
3859 last_commit_date: None,
3860 ownership: None,
3861 content_hash: hash,
3862 };
3863 let analyzed = vec![make_rec(111, "a.rs"), make_rec(222, "b.rs")];
3864 let groups = find_duplicate_groups(&analyzed);
3865 assert!(groups.is_empty());
3866 }
3867
3868 #[test]
3869 fn find_duplicate_groups_returns_group_for_same_hash() {
3870 use sloc_languages::{Language, ParseMode, RawLineCounts};
3871 let make_rec = |hash: u64, path: &str| FileRecord {
3872 path: path.into(),
3873 relative_path: path.into(),
3874 language: Some(Language::Rust),
3875 size_bytes: 10,
3876 detected_encoding: Some("utf-8".into()),
3877 raw_line_categories: RawLineCounts::default(),
3878 effective_counts: EffectiveCounts::default(),
3879 status: FileStatus::AnalyzedExact,
3880 warnings: vec![],
3881 generated: false,
3882 minified: false,
3883 vendor: false,
3884 parse_mode: Some(ParseMode::Lexical),
3885 submodule: None,
3886 coverage: None,
3887 style_analysis: None,
3888 cyclomatic_complexity: None,
3889 lsloc: None,
3890 commit_count: None,
3891 last_commit_date: None,
3892 ownership: None,
3893 content_hash: hash,
3894 };
3895 let analyzed = vec![
3896 make_rec(999, "a.rs"),
3897 make_rec(999, "b.rs"),
3898 make_rec(123, "c.rs"),
3899 ];
3900 let groups = find_duplicate_groups(&analyzed);
3901 assert_eq!(groups.len(), 1);
3902 assert_eq!(groups[0].len(), 2);
3903 }
3904
3905 #[test]
3906 fn find_duplicate_groups_ignores_zero_hash() {
3907 use sloc_languages::{Language, ParseMode, RawLineCounts};
3908 let make_rec = |hash: u64, path: &str| FileRecord {
3909 path: path.into(),
3910 relative_path: path.into(),
3911 language: Some(Language::Rust),
3912 size_bytes: 10,
3913 detected_encoding: Some("utf-8".into()),
3914 raw_line_categories: RawLineCounts::default(),
3915 effective_counts: EffectiveCounts::default(),
3916 status: FileStatus::AnalyzedExact,
3917 warnings: vec![],
3918 generated: false,
3919 minified: false,
3920 vendor: false,
3921 parse_mode: Some(ParseMode::Lexical),
3922 submodule: None,
3923 coverage: None,
3924 style_analysis: None,
3925 cyclomatic_complexity: None,
3926 lsloc: None,
3927 commit_count: None,
3928 last_commit_date: None,
3929 ownership: None,
3930 content_hash: hash,
3931 };
3932 let analyzed = vec![make_rec(0, "a.rs"), make_rec(0, "b.rs")];
3934 let groups = find_duplicate_groups(&analyzed);
3935 assert!(
3936 groups.is_empty(),
3937 "zero-hash files must not be grouped as duplicates"
3938 );
3939 }
3940
3941 #[test]
3944 fn detect_submodules_no_gitmodules_returns_empty() {
3945 let dir = tempfile::tempdir().unwrap();
3946 let result = detect_submodules(dir.path());
3947 assert!(result.is_empty());
3948 }
3949
3950 #[test]
3951 fn detect_submodules_parses_gitmodules_file() {
3952 let dir = tempfile::tempdir().unwrap();
3953 let content = "[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = https://github.com/example/lib.git\n";
3954 std::fs::write(dir.path().join(".gitmodules"), content).unwrap();
3955 let result = detect_submodules(dir.path());
3956 assert_eq!(result.len(), 1);
3957 assert_eq!(result[0].0, "vendor/lib");
3958 }
3959
3960 #[test]
3963 fn write_json_read_json_roundtrip() {
3964 use chrono::Utc;
3965 use sloc_config::AppConfig;
3966 use sloc_languages::{Language, ParseMode, RawLineCounts};
3967 let dir = tempfile::tempdir().unwrap();
3968 let run = AnalysisRun {
3969 tool: ToolMetadata {
3970 name: "sloc".into(),
3971 version: "0.0.1".into(),
3972 run_id: "test-roundtrip".into(),
3973 timestamp_utc: Utc::now(),
3974 },
3975 environment: EnvironmentMetadata {
3976 operating_system: "test".into(),
3977 architecture: "x86_64".into(),
3978 runtime_mode: "test".into(),
3979 initiator_username: "tester".into(),
3980 initiator_hostname: "testhost".into(),
3981 ci_name: None,
3982 },
3983 effective_configuration: AppConfig::default(),
3984 input_roots: vec!["/tmp/test".into()],
3985 summary_totals: SummaryTotals {
3986 files_analyzed: 1,
3987 code_lines: 5,
3988 ..SummaryTotals::default()
3989 },
3990 totals_by_language: vec![],
3991 per_file_records: vec![FileRecord {
3992 path: "a.rs".into(),
3993 relative_path: "a.rs".into(),
3994 language: Some(Language::Rust),
3995 size_bytes: 50,
3996 detected_encoding: Some("utf-8".into()),
3997 raw_line_categories: RawLineCounts {
3998 code_only_lines: 5,
3999 ..RawLineCounts::default()
4000 },
4001 effective_counts: EffectiveCounts {
4002 code_lines: 5,
4003 ..EffectiveCounts::default()
4004 },
4005 status: FileStatus::AnalyzedExact,
4006 warnings: vec![],
4007 generated: false,
4008 minified: false,
4009 vendor: false,
4010 parse_mode: Some(ParseMode::Lexical),
4011 submodule: None,
4012 coverage: None,
4013 style_analysis: None,
4014 cyclomatic_complexity: None,
4015 lsloc: None,
4016 commit_count: None,
4017 last_commit_date: None,
4018 ownership: None,
4019 content_hash: 0,
4020 }],
4021 skipped_file_records: vec![],
4022 warnings: vec![],
4023 submodule_summaries: vec![],
4024 git_commit_short: Some("abc1234".into()),
4025 git_branch: Some("main".into()),
4026 git_commit_long: None,
4027 git_commit_author: None,
4028 git_tags: None,
4029 git_nearest_tag: None,
4030 git_commit_date: None,
4031 git_remote_url: None,
4032 style_summary: None,
4033 cocomo: None,
4034 uloc: 0,
4035 dryness_pct: None,
4036 duplicate_groups: vec![],
4037 duplicates_excluded: 0,
4038 authors: Vec::new(),
4039 };
4040 let json_path = dir.path().join("test.json");
4041 write_json(&run, &json_path).unwrap();
4042 let loaded = read_json(&json_path).unwrap();
4043 assert_eq!(loaded.summary_totals.files_analyzed, 1);
4044 assert_eq!(loaded.summary_totals.code_lines, 5);
4045 assert_eq!(loaded.git_commit_short.as_deref(), Some("abc1234"));
4046 assert_eq!(loaded.git_branch.as_deref(), Some("main"));
4047 assert_eq!(loaded.per_file_records.len(), 1);
4048 }
4049
4050 #[test]
4053 fn detect_ci_system_returns_none_without_env_vars() {
4054 for var in &[
4056 "JENKINS_URL",
4057 "JENKINS_HOME",
4058 "BUILD_URL",
4059 "GITHUB_ACTIONS",
4060 "GITLAB_CI",
4061 "CIRCLECI",
4062 "TRAVIS",
4063 "TF_BUILD",
4064 "TEAMCITY_VERSION",
4065 ] {
4066 unsafe { std::env::remove_var(var) };
4068 }
4069 let _ = detect_ci_system();
4071 }
4072
4073 #[test]
4076 fn resolve_git_file_pointer_valid_absolute_gitdir() {
4077 let dir = tempfile::tempdir().unwrap();
4078 let real_git = dir.path().join("real.git");
4080 fs::create_dir_all(&real_git).unwrap();
4081 let git_file = dir.path().join(".git");
4083 fs::write(&git_file, format!("gitdir: {}\n", real_git.display())).unwrap();
4084
4085 let result = resolve_git_file_pointer(&git_file, dir.path());
4086 assert!(
4088 result.is_some(),
4089 "should resolve a valid absolute gitdir pointer"
4090 );
4091 assert!(result.unwrap().is_dir());
4092 }
4093
4094 #[test]
4095 fn resolve_git_file_pointer_missing_gitdir_prefix_returns_none() {
4096 let dir = tempfile::tempdir().unwrap();
4097 let git_file = dir.path().join(".git");
4098 fs::write(&git_file, "not a gitdir line\n").unwrap();
4099 assert!(resolve_git_file_pointer(&git_file, dir.path()).is_none());
4100 }
4101
4102 #[test]
4103 fn resolve_git_file_pointer_unreadable_path_returns_none() {
4104 assert!(
4105 resolve_git_file_pointer(
4106 Path::new("/nonexistent/__sloc_test_git_file__"),
4107 Path::new("/nonexistent")
4108 )
4109 .is_none()
4110 );
4111 }
4112
4113 #[test]
4114 fn resolve_git_file_pointer_nonexistent_target_returns_none() {
4115 let dir = tempfile::tempdir().unwrap();
4116 let git_file = dir.path().join(".git");
4117 fs::write(&git_file, "gitdir: /nonexistent/__sloc_fake_gitdir_xyz__\n").unwrap();
4118 assert!(resolve_git_file_pointer(&git_file, dir.path()).is_none());
4120 }
4121
4122 #[test]
4123 fn resolve_git_file_pointer_relative_path() {
4124 let dir = tempfile::tempdir().unwrap();
4125 let real_git = dir.path().join("real_git_dir");
4126 fs::create_dir_all(&real_git).unwrap();
4127 let git_file = dir.path().join(".git");
4128 fs::write(&git_file, "gitdir: real_git_dir\n").unwrap();
4130 let result = resolve_git_file_pointer(&git_file, dir.path());
4131 assert!(result.is_some());
4132 }
4133
4134 #[test]
4137 fn resolve_ref_from_loose_file() {
4138 let dir = tempfile::tempdir().unwrap();
4139 let git_dir = dir.path();
4140 fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4141 let sha = "abc1234567890abcdef1234567890abcdef123456";
4142 fs::write(git_dir.join("refs/heads/main"), format!("{sha}\n")).unwrap();
4143
4144 let result = resolve_ref(git_dir, "refs/heads/main");
4145 assert_eq!(result.as_deref(), Some(sha));
4146 }
4147
4148 #[test]
4149 fn resolve_ref_from_packed_refs() {
4150 let dir = tempfile::tempdir().unwrap();
4151 let git_dir = dir.path();
4152 let sha = "def5678def5678def5678def5678def5678def56";
4153 fs::write(
4154 git_dir.join("packed-refs"),
4155 format!("# pack-refs with: peeled fully-peeled sorted\n{sha} refs/heads/feature\n"),
4156 )
4157 .unwrap();
4158
4159 let result = resolve_ref(git_dir, "refs/heads/feature");
4160 assert_eq!(result.as_deref(), Some(sha));
4161 }
4162
4163 #[test]
4164 fn resolve_ref_not_found_returns_none() {
4165 let dir = tempfile::tempdir().unwrap();
4166 let result = resolve_ref(dir.path(), "refs/heads/nonexistent-branch-xyz");
4167 assert!(result.is_none());
4168 }
4169
4170 #[test]
4171 fn resolve_ref_packed_refs_skips_comment_and_peeled() {
4172 let dir = tempfile::tempdir().unwrap();
4173 let git_dir = dir.path();
4174 let sha = "aaa1111aaa1111aaa1111aaa1111aaa1111aaa11";
4175 fs::write(
4176 git_dir.join("packed-refs"),
4177 format!("# comment\n^peeled-object-sha\n{sha} refs/tags/v1.0\n"),
4178 )
4179 .unwrap();
4180
4181 let result = resolve_ref(git_dir, "refs/tags/v1.0");
4182 assert_eq!(result.as_deref(), Some(sha));
4183 }
4184
4185 #[test]
4186 fn resolve_ref_loose_sha_too_short_falls_through_to_packed() {
4187 let dir = tempfile::tempdir().unwrap();
4188 let git_dir = dir.path();
4189 fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4190 fs::write(git_dir.join("refs/heads/main"), "short\n").unwrap();
4192 let result = resolve_ref(git_dir, "refs/heads/main");
4194 assert!(result.is_none());
4195 }
4196
4197 #[test]
4200 fn read_git_remote_url_parses_origin_url() {
4201 let dir = tempfile::tempdir().unwrap();
4202 let git_dir = dir.path().join(".git");
4203 fs::create_dir_all(&git_dir).unwrap();
4204 fs::write(
4205 git_dir.join("config"),
4206 "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://github.com/org/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
4207 )
4208 .unwrap();
4209 let url = read_git_remote_url(&git_dir);
4210 assert_eq!(url.as_deref(), Some("https://github.com/org/repo.git"));
4211 }
4212
4213 #[test]
4214 fn read_git_remote_url_no_config_returns_none() {
4215 let dir = tempfile::tempdir().unwrap();
4216 let git_dir = dir.path().join(".git");
4217 fs::create_dir_all(&git_dir).unwrap();
4218 let url = read_git_remote_url(&git_dir);
4220 assert!(url.is_none());
4221 }
4222
4223 #[test]
4226 fn detect_git_for_run_no_git_dir_returns_default() {
4227 let dir = tempfile::tempdir().unwrap();
4228 let info = detect_git_for_run(dir.path());
4230 assert!(info.commit_long.is_none());
4231 }
4232
4233 #[test]
4234 fn detect_git_for_run_unreadable_head_returns_default() {
4235 let dir = tempfile::tempdir().unwrap();
4236 let git_dir = dir.path().join(".git");
4237 fs::create_dir_all(&git_dir).unwrap();
4238 let info = detect_git_for_run(dir.path());
4240 assert!(info.commit_long.is_none());
4241 }
4242
4243 #[test]
4244 fn detect_git_for_run_detached_head_with_sha() {
4245 let dir = tempfile::tempdir().unwrap();
4246 let git_dir = dir.path().join(".git");
4247 fs::create_dir_all(&git_dir).unwrap();
4248 let sha = "abc1234567890abcdef1234567890abcdef12345";
4250 fs::write(git_dir.join("HEAD"), sha).unwrap();
4251 let info = detect_git_for_run(dir.path());
4252 assert_eq!(info.commit_long.as_deref(), Some(sha));
4254 assert_eq!(info.commit_short.as_deref(), Some("abc1234"));
4255 }
4256
4257 #[test]
4258 fn detect_git_for_run_with_packed_ref() {
4259 let dir = tempfile::tempdir().unwrap();
4260 let git_dir = dir.path().join(".git");
4261 fs::create_dir_all(&git_dir).unwrap();
4262 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
4264 let sha = "deadbeef00000000000000000000000000000000";
4265 fs::write(
4266 git_dir.join("packed-refs"),
4267 format!("# pack-refs\n{sha} refs/heads/main\n"),
4268 )
4269 .unwrap();
4270 let info = detect_git_for_run(dir.path());
4271 assert_eq!(info.commit_long.as_deref(), Some(sha));
4272 assert_eq!(info.branch.as_deref(), Some("main"));
4273 }
4274
4275 #[test]
4276 fn detect_git_for_run_reads_origin_remote_url() {
4277 let dir = tempfile::tempdir().unwrap();
4280 let git_dir = dir.path().join(".git");
4281 fs::create_dir_all(&git_dir).unwrap();
4282 let sha = "deadbeef00000000000000000000000000000000";
4283 fs::write(git_dir.join("HEAD"), sha).unwrap();
4284 fs::write(
4285 git_dir.join("config"),
4286 "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = https://example.com/repo.git\n\tfetch = +refs/heads/*\n",
4287 )
4288 .unwrap();
4289 let info = detect_git_for_run(dir.path());
4290 assert_eq!(
4291 info.remote_url.as_deref(),
4292 Some("https://example.com/repo.git")
4293 );
4294 }
4295
4296 #[test]
4297 fn detect_git_for_run_follows_git_file_worktree_pointer() {
4298 let tmp = tempfile::tempdir().unwrap();
4302 let gitdata = tmp.path().join("gitdata");
4303 fs::create_dir_all(&gitdata).unwrap();
4304 let sha = "abc1234567890abcdef1234567890abcdef12345";
4305 fs::write(gitdata.join("HEAD"), sha).unwrap();
4306
4307 let project = tmp.path().join("project");
4308 fs::create_dir_all(&project).unwrap();
4309 let pointer = format!("gitdir: {}\n", gitdata.to_string_lossy().replace('\\', "/"));
4311 fs::write(project.join(".git"), pointer).unwrap();
4312
4313 let info = detect_git_for_run(&project);
4314 assert_eq!(info.commit_long.as_deref(), Some(sha));
4315 }
4316
4317 use std::sync::{Mutex, OnceLock};
4321 static CI_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4322 fn ci_env_lock() -> std::sync::MutexGuard<'static, ()> {
4323 CI_ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
4324 }
4325
4326 fn clear_branch_env_vars() {
4327 for v in &[
4328 "BRANCH_NAME",
4329 "GIT_BRANCH",
4330 "GITHUB_REF_NAME",
4331 "CI_COMMIT_BRANCH",
4332 "CIRCLE_BRANCH",
4333 "TRAVIS_BRANCH",
4334 "BUILD_SOURCEBRANCH",
4335 ] {
4336 unsafe { std::env::remove_var(v) };
4338 }
4339 }
4340
4341 #[test]
4342 fn ci_branch_from_env_strips_refs_heads_prefix() {
4343 let _lock = ci_env_lock();
4344 clear_branch_env_vars();
4345 unsafe { std::env::set_var("BUILD_SOURCEBRANCH", "refs/heads/my-branch") };
4348 let branch = ci_branch_from_env();
4349 clear_branch_env_vars();
4350 assert_eq!(branch.as_deref(), Some("my-branch"));
4351 }
4352
4353 #[test]
4354 fn ci_branch_from_env_strips_origin_prefix() {
4355 let _lock = ci_env_lock();
4356 clear_branch_env_vars();
4357 unsafe { std::env::set_var("GIT_BRANCH", "origin/develop") };
4359 let branch = ci_branch_from_env();
4360 clear_branch_env_vars();
4361 assert_eq!(branch.as_deref(), Some("develop"));
4362 }
4363
4364 #[test]
4365 fn ci_branch_from_env_returns_none_for_head() {
4366 let _lock = ci_env_lock();
4367 clear_branch_env_vars();
4368 unsafe { std::env::set_var("BRANCH_NAME", "HEAD") };
4371 let branch = ci_branch_from_env();
4372 clear_branch_env_vars();
4373 assert!(branch.is_none(), "HEAD should be filtered, got: {branch:?}");
4375 }
4376
4377 fn make_git_dir(dir: &Path) {
4381 fs::create_dir_all(dir.join(".git")).unwrap();
4382 }
4383
4384 #[test]
4385 fn multi_repo_dir_warns() {
4386 let tmp = tempfile::tempdir().unwrap();
4387 let root = tmp.path();
4388 for name in ["repo-a", "repo-b", "repo-c"] {
4389 make_git_dir(&root.join(name));
4390 }
4391 let layout = detect_repository_layout(root);
4392 assert!(!layout.root_is_repo);
4393 assert_eq!(layout.nested_repos.len(), 3);
4394 assert!(layout.has_multiple_repos());
4395 }
4396
4397 #[test]
4398 fn repo_with_submodules_does_not_warn() {
4399 let tmp = tempfile::tempdir().unwrap();
4400 let root = tmp.path();
4401 make_git_dir(root);
4402 fs::write(
4403 root.join(".gitmodules"),
4404 "[submodule \"vendor/json\"]\n\tpath = vendor/json\n\turl = https://example/json.git\n\
4405 [submodule \"vendor/gtest\"]\n\tpath = vendor/gtest\n\turl = https://example/gtest.git\n",
4406 )
4407 .unwrap();
4408 make_git_dir(&root.join("vendor/json"));
4411 make_git_dir(&root.join("vendor/gtest"));
4412 let layout = detect_repository_layout(root);
4413 assert!(layout.root_is_repo);
4414 assert!(layout.nested_repos.is_empty());
4415 assert!(!layout.has_multiple_repos());
4416 }
4417
4418 #[test]
4419 fn format_multi_repo_warning_root_repo_singular_and_truncated() {
4420 let one = RepositoryLayout {
4423 root: PathBuf::from("/proj"),
4424 root_is_repo: true,
4425 submodule_paths: vec![],
4426 nested_repos: vec![PathBuf::from("vendor/foreign")],
4427 };
4428 let msg = format_multi_repo_warning(&one);
4429 assert!(
4430 msg.contains("1 nested git repository"),
4431 "singular wording: {msg}"
4432 );
4433 assert!(!msg.contains("repositories"), "must not pluralise: {msg}");
4434
4435 let many = RepositoryLayout {
4438 root: PathBuf::from("/proj"),
4439 root_is_repo: true,
4440 submodule_paths: vec![],
4441 nested_repos: (0..7)
4442 .map(|i| PathBuf::from(format!("nested-{i}")))
4443 .collect(),
4444 };
4445 let msg = format_multi_repo_warning(&many);
4446 assert!(
4447 msg.contains("7 nested git repositories"),
4448 "plural wording: {msg}"
4449 );
4450 assert!(
4451 msg.contains("and 2 more"),
4452 "must truncate the listed set: {msg}"
4453 );
4454 }
4455
4456 #[test]
4457 fn repo_with_vendored_foreign_repo_warns() {
4458 let tmp = tempfile::tempdir().unwrap();
4459 let root = tmp.path();
4460 make_git_dir(root); make_git_dir(&root.join("vendor/foreign")); let layout = detect_repository_layout(root);
4463 assert!(layout.root_is_repo);
4464 assert_eq!(layout.nested_repos, vec![PathBuf::from("vendor/foreign")]);
4465 assert!(layout.has_multiple_repos());
4466 }
4467
4468 #[test]
4469 fn single_plain_dir_no_warn() {
4470 let tmp = tempfile::tempdir().unwrap();
4471 let root = tmp.path();
4472 fs::create_dir_all(root.join("src")).unwrap();
4473 fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
4474 let layout = detect_repository_layout(root);
4475 assert!(!layout.root_is_repo);
4476 assert!(layout.nested_repos.is_empty());
4477 assert!(!layout.has_multiple_repos());
4478 }
4479
4480 #[test]
4481 fn analyze_surfaces_multi_repo_warning() {
4482 let tmp = tempfile::tempdir().unwrap();
4483 let root = tmp.path();
4484 for name in ["repo-a", "repo-b"] {
4485 let repo = root.join(name);
4486 make_git_dir(&repo);
4487 fs::write(repo.join("main.rs"), "fn main() {}\n").unwrap();
4488 }
4489 let mut config = AppConfig::default();
4490 config.discovery.root_paths = vec![root.to_path_buf()];
4491 let run = analyze(&config, "analyze", None, None).unwrap();
4492 assert!(
4493 run.warnings
4494 .iter()
4495 .any(|w| w.contains("independent git repositories")),
4496 "expected multi-repo warning, got: {:?}",
4497 run.warnings
4498 );
4499 }
4500}