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