use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard, OnceLock};
static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
fn env_lock() -> MutexGuard<'static, ()> {
ENV_MUTEX.get_or_init(|| Mutex::new(())).lock().unwrap()
}
use chrono::Utc;
use sloc_config::AppConfig;
use sloc_core::{
aggregate_line_coverage, check_against_baseline, compute_delta, lookup_coverage, parse_lcov,
BaselineEntry, BaselineStore, CleanupPolicyStore, EffectiveCounts, EnvironmentMetadata,
FileRecord, FileStatus, LanguageSummary, RegistryEntry, ScanRegistry, ScanSummarySnapshot,
SummaryTotals, ToolMetadata, WatchedDirsStore,
};
use sloc_core::{AnalysisRun, FileCoverage};
use sloc_languages::{Language, RawLineCounts};
fn make_file_record(path: &str, code: u64) -> FileRecord {
FileRecord {
path: path.into(),
relative_path: path.into(),
language: Some(Language::Rust),
size_bytes: code * 20,
detected_encoding: Some("utf-8".into()),
raw_line_categories: RawLineCounts {
total_physical_lines: code + 2,
code_only_lines: code,
blank_only_lines: 1,
single_comment_only_lines: 1,
..RawLineCounts::default()
},
effective_counts: EffectiveCounts {
code_lines: code,
comment_lines: 1,
blank_lines: 1,
mixed_lines_separate: 0,
},
status: FileStatus::AnalyzedExact,
warnings: vec![],
generated: false,
minified: false,
vendor: false,
parse_mode: Some(sloc_languages::ParseMode::Lexical),
submodule: None,
coverage: None,
style_analysis: None,
cyclomatic_complexity: None,
lsloc: None,
content_hash: 0,
}
}
const fn make_lang_summary(code: u64) -> LanguageSummary {
LanguageSummary {
language: Language::Rust,
files: 1,
total_physical_lines: code + 2,
code_lines: code,
comment_lines: 1,
blank_lines: 1,
mixed_lines_separate: 0,
functions: 1,
classes: 0,
variables: 0,
imports: 0,
test_count: 0,
test_assertion_count: 0,
test_suite_count: 0,
coverage_lines_found: 0,
coverage_lines_hit: 0,
coverage_functions_found: 0,
coverage_functions_hit: 0,
coverage_branches_found: 0,
coverage_branches_hit: 0,
cyclomatic_complexity: 0,
lsloc: None,
}
}
#[allow(clippy::needless_pass_by_value)]
fn make_run_with_files(files: Vec<(&str, u64)>) -> AnalysisRun {
let code_total: u64 = files.iter().map(|(_, c)| c).sum();
let records: Vec<FileRecord> = files.iter().map(|(p, c)| make_file_record(p, *c)).collect();
AnalysisRun {
tool: ToolMetadata {
name: "sloc".into(),
version: "1.0.0".into(),
run_id: uuid::Uuid::new_v4().to_string(),
timestamp_utc: Utc::now(),
},
environment: EnvironmentMetadata {
operating_system: "linux".into(),
architecture: "x86_64".into(),
runtime_mode: "test".into(),
initiator_username: "test".into(),
initiator_hostname: "host".into(),
ci_name: None,
},
effective_configuration: AppConfig::default(),
input_roots: vec!["/tmp/test".into()],
summary_totals: SummaryTotals {
files_analyzed: files.len() as u64,
code_lines: code_total,
..SummaryTotals::default()
},
totals_by_language: if files.is_empty() {
vec![]
} else {
vec![make_lang_summary(code_total)]
},
per_file_records: records,
skipped_file_records: vec![],
warnings: vec![],
submodule_summaries: vec![],
git_commit_short: None,
git_branch: None,
git_commit_long: None,
git_commit_author: None,
git_tags: None,
git_nearest_tag: None,
git_commit_date: None,
git_remote_url: None,
style_summary: None,
cocomo: None,
uloc: 0,
dryness_pct: None,
duplicate_groups: vec![],
duplicates_excluded: 0,
}
}
fn snapshot(code: u64) -> ScanSummarySnapshot {
ScanSummarySnapshot {
files_analyzed: 1,
code_lines: code,
..ScanSummarySnapshot::default()
}
}
const LCOV_SINGLE: &str = "\
SF:src/lib.rs
FN:1,main
FNDA:1,main
FNF:1
FNH:1
DA:1,1
DA:2,0
DA:3,1
LF:3
LH:2
BRF:0
BRH:0
end_of_record
";
const LCOV_TWO_FILES: &str = "\
SF:src/lib.rs
LF:5
LH:4
FNF:1
FNH:1
BRF:0
BRH:0
end_of_record
SF:src/main.rs
LF:3
LH:2
FNF:0
FNH:0
BRF:0
BRH:0
end_of_record
";
#[test]
fn parse_lcov_single_record() {
let map = parse_lcov(LCOV_SINGLE);
assert_eq!(map.len(), 1);
let entry = map.get(std::path::Path::new("src/lib.rs")).unwrap();
assert_eq!(entry.lines_found, 3);
assert_eq!(entry.lines_hit, 2);
assert_eq!(entry.functions_found, 1);
assert_eq!(entry.functions_hit, 1);
}
#[test]
fn parse_lcov_empty_input() {
let map = parse_lcov("");
assert!(map.is_empty());
}
#[test]
fn parse_lcov_two_files() {
let map = parse_lcov(LCOV_TWO_FILES);
assert_eq!(map.len(), 2);
assert!(map.contains_key(std::path::Path::new("src/lib.rs")));
assert!(map.contains_key(std::path::Path::new("src/main.rs")));
}
#[test]
fn lookup_coverage_exact_match() {
let map = parse_lcov(LCOV_SINGLE);
let cov = lookup_coverage(&map, "src/lib.rs");
assert!(cov.is_some());
assert_eq!(cov.unwrap().lines_found, 3);
}
#[test]
fn lookup_coverage_suffix_match() {
let mut map = std::collections::HashMap::new();
map.insert(
std::path::PathBuf::from("/home/user/project/src/lib.rs"),
FileCoverage {
lines_found: 10,
lines_hit: 8,
functions_found: 2,
functions_hit: 2,
branches_found: 0,
branches_hit: 0,
},
);
let cov = lookup_coverage(&map, "src/lib.rs");
assert!(cov.is_some(), "suffix match should work");
assert_eq!(cov.unwrap().lines_found, 10);
}
#[test]
fn lookup_coverage_no_match() {
let map = parse_lcov(LCOV_SINGLE);
let cov = lookup_coverage(&map, "nonexistent.rs");
assert!(cov.is_none());
}
#[test]
fn aggregate_line_coverage_sums_records() {
let cov_a = FileCoverage {
lines_found: 10,
lines_hit: 8,
functions_found: 2,
functions_hit: 2,
branches_found: 0,
branches_hit: 0,
};
let cov_b = FileCoverage {
lines_found: 5,
lines_hit: 3,
functions_found: 1,
functions_hit: 1,
branches_found: 0,
branches_hit: 0,
};
let refs: Vec<&FileCoverage> = vec![&cov_a, &cov_b];
let pct = aggregate_line_coverage(&refs);
assert!(pct.is_some());
let pct = pct.unwrap();
assert!((pct - 73.333).abs() < 0.5, "expected ~73.3%, got {pct}");
}
#[test]
fn aggregate_line_coverage_none_when_no_coverage() {
let refs: Vec<&FileCoverage> = vec![];
let pct = aggregate_line_coverage(&refs);
assert!(pct.is_none());
}
#[test]
fn compute_delta_empty_vs_empty() {
let base = make_run_with_files(vec![]);
let current = make_run_with_files(vec![]);
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, 0);
assert_eq!(cmp.summary.files_analyzed_delta, 0);
assert!(cmp.file_deltas.is_empty());
}
#[test]
fn compute_delta_added_file() {
let base = make_run_with_files(vec![]);
let current = make_run_with_files(vec![("src/new.rs", 50)]);
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, 50);
assert_eq!(cmp.summary.files_analyzed_delta, 1);
assert_eq!(cmp.file_deltas.len(), 1);
assert_eq!(
cmp.file_deltas[0].status,
sloc_core::FileChangeStatus::Added
);
}
#[test]
fn compute_delta_removed_file() {
let base = make_run_with_files(vec![("src/old.rs", 30)]);
let current = make_run_with_files(vec![]);
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, -30);
assert_eq!(
cmp.file_deltas[0].status,
sloc_core::FileChangeStatus::Removed
);
}
#[test]
fn compute_delta_unchanged_file() {
let base = make_run_with_files(vec![("src/lib.rs", 20)]);
let current = make_run_with_files(vec![("src/lib.rs", 20)]);
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, 0);
assert_eq!(
cmp.file_deltas[0].status,
sloc_core::FileChangeStatus::Unchanged
);
}
#[test]
fn compute_delta_modified_file_grown() {
let base = make_run_with_files(vec![("src/lib.rs", 20)]);
let current = make_run_with_files(vec![("src/lib.rs", 35)]);
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, 15);
assert_eq!(
cmp.file_deltas[0].status,
sloc_core::FileChangeStatus::Modified
);
}
#[test]
fn compute_delta_modified_file_shrunk() {
let base = make_run_with_files(vec![("src/lib.rs", 40)]);
let current = make_run_with_files(vec![("src/lib.rs", 25)]);
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, -15);
}
#[test]
fn baseline_store_empty_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("baselines.json");
let store = BaselineStore::default();
store.save(&path).unwrap();
let loaded = BaselineStore::load(&path);
assert!(loaded.baselines.is_empty());
}
#[test]
fn baseline_store_entry_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("baselines.json");
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "main".into(),
saved_at: Utc::now(),
run_id: "run-abc".into(),
summary: snapshot(500),
json_path: None,
});
store.save(&path).unwrap();
let loaded = BaselineStore::load(&path);
assert!(loaded.baselines.contains_key("main"));
assert_eq!(loaded.baselines["main"].summary.code_lines, 500);
}
#[test]
fn baseline_store_load_missing_returns_default() {
let store = BaselineStore::load(std::path::Path::new("/nonexistent/baselines.json"));
assert!(store.baselines.is_empty());
}
#[test]
fn check_against_baseline_no_entries_errors() {
let store = BaselineStore::default();
let result = check_against_baseline(&store, "main", 100, None);
assert!(result.is_err());
}
#[test]
fn check_against_baseline_under_budget() {
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "main".into(),
saved_at: Utc::now(),
run_id: "run-1".into(),
summary: snapshot(100),
json_path: None,
});
let result = check_against_baseline(&store, "main", 110, Some(20.0)).unwrap();
assert!(!result.exceeded);
assert_eq!(result.current_code_lines, 110);
}
#[test]
fn check_against_baseline_over_budget() {
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "main".into(),
saved_at: Utc::now(),
run_id: "run-1".into(),
summary: snapshot(100),
json_path: None,
});
let result = check_against_baseline(&store, "main", 150, Some(10.0)).unwrap();
assert!(result.exceeded);
}
fn make_registry_entry(run_id: &str) -> RegistryEntry {
RegistryEntry {
run_id: run_id.into(),
timestamp_utc: Utc::now(),
project_label: "test".into(),
input_roots: vec!["/tmp/test".into()],
json_path: None,
html_path: None,
pdf_path: None,
csv_path: None,
xlsx_path: None,
summary: snapshot(100),
git_branch: None,
git_commit: None,
git_author: None,
git_tags: None,
git_nearest_tag: None,
git_commit_date: None,
}
}
#[test]
fn scan_registry_empty_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("registry.json");
let registry = ScanRegistry::default();
registry.save(&path).unwrap();
let loaded = ScanRegistry::load(&path);
assert!(loaded.entries.is_empty());
}
#[test]
fn scan_registry_add_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("registry.json");
let mut registry = ScanRegistry::default();
registry.add_entry(make_registry_entry("run-1"));
registry.save(&path).unwrap();
let loaded = ScanRegistry::load(&path);
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].run_id, "run-1");
}
#[test]
fn scan_registry_deduplicates_run_id() {
let mut registry = ScanRegistry::default();
registry.add_entry(make_registry_entry("run-1"));
registry.add_entry(make_registry_entry("run-1")); assert_eq!(registry.entries.len(), 1);
}
#[test]
fn scan_registry_find_by_run_id() {
let mut registry = ScanRegistry::default();
registry.add_entry(make_registry_entry("run-abc"));
let found = registry.find_by_run_id("run-abc");
assert!(found.is_some());
let missing = registry.find_by_run_id("run-xyz");
assert!(missing.is_none());
}
#[test]
fn scan_registry_load_missing_returns_default() {
let reg = ScanRegistry::load(std::path::Path::new("/nonexistent/registry.json"));
assert!(reg.entries.is_empty());
}
#[test]
fn cleanup_policy_store_load_missing_returns_default() {
let store = CleanupPolicyStore::load(std::path::Path::new("/nonexistent/cleanup.json"));
assert!(store.policy.is_none());
assert!(store.last_run_at.is_none());
}
#[test]
fn watched_dirs_store_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("watched.json");
let mut store = WatchedDirsStore::default();
store.add(PathBuf::from("/tmp/project-a"));
store.save(&path).unwrap();
let loaded = WatchedDirsStore::load(&path);
assert_eq!(loaded.dirs.len(), 1);
assert_eq!(loaded.dirs[0], PathBuf::from("/tmp/project-a"));
}
#[test]
fn watched_dirs_no_duplicates() {
let mut store = WatchedDirsStore::default();
store.add(PathBuf::from("/tmp/project"));
store.add(PathBuf::from("/tmp/project")); assert_eq!(store.dirs.len(), 1);
}
use sloc_core::analyze;
fn analysis_config_for(dir: &std::path::Path) -> AppConfig {
let mut cfg = AppConfig::default();
cfg.discovery.root_paths = vec![dir.to_path_buf()];
cfg
}
#[test]
fn analyze_single_rust_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn main() {}\n// comment\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 1);
assert!(run.summary_totals.code_lines > 0);
}
#[test]
fn analyze_empty_directory() {
let dir = tempfile::tempdir().unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 0);
assert!(run.per_file_records.is_empty());
}
#[test]
fn analyze_skips_lockfile_by_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.lock"),
"[package]\nname = \"test\"\n",
)
.unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(
run.summary_totals.files_analyzed, 0,
"Cargo.lock should be skipped"
);
assert_eq!(run.summary_totals.files_skipped, 1);
}
#[test]
fn analyze_skips_vendor_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("vendor")).unwrap();
std::fs::write(dir.path().join("vendor").join("dep.rs"), "fn foo() {}").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(
run.summary_totals.files_analyzed, 0,
"vendor/ files should be skipped"
);
}
#[test]
fn analyze_detects_binary_file() {
let dir = tempfile::tempdir().unwrap();
let mut data = b"hello world".to_vec();
data.push(0x00);
data.extend_from_slice(b" more data");
std::fs::write(dir.path().join("binary.rs"), &data).unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(
run.summary_totals.files_analyzed, 0,
"binary file should not be analyzed"
);
assert_eq!(run.summary_totals.files_skipped, 1);
}
#[test]
fn analyze_multiple_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(dir.path().join("b.rs"), "fn b() {}\nfn c() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 2);
assert!(run.summary_totals.code_lines >= 2);
}
#[test]
fn analyze_respects_max_file_size() {
let dir = tempfile::tempdir().unwrap();
let big_content = "x".repeat(200_000);
std::fs::write(dir.path().join("big.rs"), &big_content).unwrap();
let mut cfg = analysis_config_for(dir.path());
cfg.discovery.max_file_size_bytes = 100;
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(
run.summary_totals.files_analyzed, 0,
"oversized file should be skipped"
);
assert_eq!(run.summary_totals.files_skipped, 1);
}
#[test]
fn analyze_python_docstrings() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("app.py"),
"\"\"\"Module doc.\"\"\"\ndef greet(name):\n \"\"\"Greet.\"\"\"\n return f\"hi {name}\"\n",
)
.unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 1);
assert!(run.summary_totals.code_lines > 0);
}
#[test]
fn analyze_c_file_with_preprocessor() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("main.c"),
"#include <stdio.h>\n#define MAX 100\nint main() {\n return 0;\n}\n",
)
.unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 1);
assert!(run.summary_totals.code_lines >= 1);
}
#[test]
fn analyze_typescript_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("app.ts"),
"interface Foo { bar: string; }\nexport function greet(x: Foo): void {}\n",
)
.unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 1);
assert!(run.summary_totals.code_lines >= 1);
}
#[test]
fn analyze_per_file_records_populated() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.per_file_records.len(), 1);
assert!(run.per_file_records[0].raw_line_categories.code_only_lines > 0);
}
#[test]
fn analyze_include_glob_filters_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(dir.path().join("b.py"), "def b(): pass\n").unwrap();
let mut cfg = analysis_config_for(dir.path());
cfg.discovery.include_globs = vec!["**/*.rs".to_string()];
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 1);
}
#[test]
fn analyze_json_output_roundtrip() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn x() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
let json_path = dir.path().join("out.json");
sloc_core::write_json(&run, &json_path).unwrap();
let back = sloc_core::read_json(&json_path).unwrap();
assert_eq!(
back.summary_totals.files_analyzed,
run.summary_totals.files_analyzed
);
}
#[test]
fn file_coverage_line_pct_normal() {
let cov = FileCoverage {
lines_found: 20,
lines_hit: 15,
functions_found: 4,
functions_hit: 3,
branches_found: 8,
branches_hit: 6,
};
let lp = cov.line_pct();
assert!((lp - 75.0).abs() < 0.01, "expected 75.0, got {lp}");
let fp = cov.function_pct();
assert!((fp - 75.0).abs() < 0.01, "expected 75.0, got {fp}");
let bp = cov.branch_pct();
assert!((bp - 75.0).abs() < 0.01, "expected 75.0, got {bp}");
}
#[test]
#[allow(clippy::float_cmp)] fn file_coverage_line_pct_zero_found() {
let cov = FileCoverage {
lines_found: 0,
lines_hit: 0,
functions_found: 0,
functions_hit: 0,
branches_found: 0,
branches_hit: 0,
};
assert_eq!(cov.line_pct(), 0.0);
assert_eq!(cov.function_pct(), 0.0);
assert_eq!(cov.branch_pct(), 0.0);
}
#[test]
fn file_coverage_full_coverage() {
let cov = FileCoverage {
lines_found: 10,
lines_hit: 10,
functions_found: 2,
functions_hit: 2,
branches_found: 4,
branches_hit: 4,
};
assert!((cov.line_pct() - 100.0).abs() < 0.01);
assert!((cov.function_pct() - 100.0).abs() < 0.01);
assert!((cov.branch_pct() - 100.0).abs() < 0.01);
}
#[test]
fn lookup_coverage_filename_only_fallback() {
let mut map = std::collections::HashMap::new();
map.insert(
std::path::PathBuf::from("/absolute/path/to/src/lib.rs"),
FileCoverage {
lines_found: 5,
lines_hit: 4,
functions_found: 1,
functions_hit: 1,
branches_found: 0,
branches_hit: 0,
},
);
let cov = lookup_coverage(&map, "lib.rs");
assert!(cov.is_some(), "filename-only fallback should match");
assert_eq!(cov.unwrap().lines_found, 5);
}
#[test]
fn lookup_coverage_windows_backslash_path() {
let mut map = std::collections::HashMap::new();
map.insert(
std::path::PathBuf::from("src/lib.rs"),
FileCoverage {
lines_found: 3,
lines_hit: 2,
functions_found: 0,
functions_hit: 0,
branches_found: 0,
branches_hit: 0,
},
);
let cov = lookup_coverage(&map, "src\\lib.rs");
assert!(cov.is_some(), "backslash-normalised path should match");
}
const COBERTURA_XML: &str = r#"<?xml version="1.0" ?>
<coverage version="5.5">
<packages>
<package name=".">
<classes>
<class filename="src/lib.py">
<methods>
<method name="main" line-rate="1.0"></method>
</methods>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="0"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1" branch="true" condition-coverage="50% (1/2)"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>"#;
#[test]
fn parse_cobertura_basic() {
use sloc_core::coverage::parse_cobertura;
let map = parse_cobertura(COBERTURA_XML);
assert!(!map.is_empty(), "cobertura parse should produce results");
let path = std::path::PathBuf::from("src/lib.py");
let entry = map.get(&path).expect("src/lib.py should be in map");
assert_eq!(entry.lines_found, 5, "5 <line> elements");
assert_eq!(entry.lines_hit, 4, "4 lines with hits > 0");
assert_eq!(entry.functions_found, 1, "1 <method>");
assert_eq!(entry.functions_hit, 1, "method line-rate=1.0 -> hit");
assert!(entry.branches_found > 0, "branch data should be recorded");
assert!(entry.branches_hit > 0, "branch hits should be recorded");
}
#[test]
fn parse_cobertura_empty_input() {
use sloc_core::coverage::parse_cobertura;
let map = parse_cobertura("");
assert!(map.is_empty());
}
#[test]
fn parse_cobertura_no_branch_data() {
use sloc_core::coverage::parse_cobertura;
let xml = r#"<coverage><packages><package name="."><classes>
<class filename="main.py">
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
</lines>
</class>
</classes></package></packages></coverage>"#;
let map = parse_cobertura(xml);
let entry = map.get(std::path::Path::new("main.py")).unwrap();
assert_eq!(entry.lines_found, 2);
assert_eq!(entry.lines_hit, 2);
assert_eq!(entry.branches_found, 0);
assert_eq!(entry.branches_hit, 0);
}
#[test]
fn parse_cobertura_multiple_classes() {
use sloc_core::coverage::parse_cobertura;
let xml = r#"<coverage><packages><package name="."><classes>
<class filename="a.py"><lines><line number="1" hits="1"/></lines></class>
<class filename="b.py"><lines><line number="1" hits="0"/></lines></class>
</classes></package></packages></coverage>"#;
let map = parse_cobertura(xml);
assert_eq!(map.len(), 2);
}
#[test]
fn parse_cobertura_method_zero_line_rate() {
use sloc_core::coverage::parse_cobertura;
let xml = r#"<coverage><packages><package name="."><classes>
<class filename="x.py">
<methods>
<method name="unused" line-rate="0.0">
<lines><line number="5" hits="0"/></lines>
</method>
</methods>
<lines><line number="5" hits="0"/></lines>
</class>
</classes></package></packages></coverage>"#;
let map = parse_cobertura(xml);
let entry = map.get(std::path::Path::new("x.py")).unwrap();
assert_eq!(entry.functions_found, 1);
assert_eq!(entry.functions_hit, 0, "line-rate=0 means not hit");
}
const JACOCO_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<report name="my-project">
<package name="com/example">
<sourcefile name="Main.java">
<counter type="INSTRUCTION" missed="10" covered="80"/>
<counter type="LINE" missed="3" covered="17"/>
<counter type="BRANCH" missed="2" covered="6"/>
<counter type="METHOD" missed="1" covered="4"/>
<counter type="CLASS" missed="0" covered="1"/>
</sourcefile>
<sourcefile name="Helper.java">
<counter type="LINE" missed="5" covered="10"/>
<counter type="METHOD" missed="0" covered="2"/>
<counter type="BRANCH" missed="0" covered="4"/>
</sourcefile>
</package>
</report>"#;
#[test]
fn parse_jacoco_basic() {
use sloc_core::coverage::parse_jacoco;
let map = parse_jacoco(JACOCO_XML);
assert_eq!(map.len(), 2, "two source files");
let main = map
.get(std::path::Path::new("com/example/Main.java"))
.expect("Main.java should be in map");
assert_eq!(main.lines_found, 20, "missed=3 + covered=17 = 20");
assert_eq!(main.lines_hit, 17);
assert_eq!(main.functions_found, 5, "missed=1 + covered=4 = 5");
assert_eq!(main.functions_hit, 4);
assert_eq!(main.branches_found, 8, "missed=2 + covered=6 = 8");
assert_eq!(main.branches_hit, 6);
}
#[test]
fn parse_jacoco_empty_package_name() {
use sloc_core::coverage::parse_jacoco;
let xml = r#"<report name="test">
<package name="">
<sourcefile name="Root.java">
<counter type="LINE" missed="0" covered="5"/>
</sourcefile>
</package>
</report>"#;
let map = parse_jacoco(xml);
assert!(map.contains_key(std::path::Path::new("Root.java")));
}
#[test]
fn parse_jacoco_empty_input() {
use sloc_core::coverage::parse_jacoco;
let map = parse_jacoco("");
assert!(map.is_empty());
}
#[test]
fn parse_jacoco_multiple_packages() {
use sloc_core::coverage::parse_jacoco;
let xml = r#"<report name="test">
<package name="com/a">
<sourcefile name="A.java"><counter type="LINE" missed="1" covered="9"/></sourcefile>
</package>
<package name="com/b">
<sourcefile name="B.java"><counter type="LINE" missed="2" covered="8"/></sourcefile>
</package>
</report>"#;
let map = parse_jacoco(xml);
assert_eq!(map.len(), 2);
let a = map.get(std::path::Path::new("com/a/A.java")).unwrap();
assert_eq!(a.lines_found, 10);
let b = map.get(std::path::Path::new("com/b/B.java")).unwrap();
assert_eq!(b.lines_found, 10);
}
const ISTANBUL_JSON: &str = r#"{
"/project/src/index.js": {
"lines": {"total": 10, "covered": 8, "skipped": 0, "pct": 80},
"functions": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.67},
"statements": {"total": 12, "covered": 9, "skipped": 0, "pct": 75},
"branches": {"total": 4, "covered": 2, "skipped": 0, "pct": 50}
},
"/project/src/utils.js": {
"lines": {"total": 5, "covered": 5, "skipped": 0, "pct": 100},
"functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
"statements": {"total": 6, "covered": 6, "skipped": 0, "pct": 100},
"branches": {"total": 0, "covered": 0, "skipped": 0, "pct": 100}
},
"total": {
"lines": {"total": 15, "covered": 13, "skipped": 0, "pct": 86.67},
"functions": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
"statements": {"total": 18, "covered": 15, "skipped": 0, "pct": 83.33},
"branches": {"total": 4, "covered": 2, "skipped": 0, "pct": 50}
}
}"#;
#[test]
fn parse_istanbul_basic() {
use sloc_core::coverage::parse_istanbul;
let map = parse_istanbul(ISTANBUL_JSON);
assert_eq!(map.len(), 2, "total key must be skipped");
let index = map
.get(std::path::Path::new("/project/src/index.js"))
.unwrap();
assert_eq!(index.lines_found, 10);
assert_eq!(index.lines_hit, 8);
assert_eq!(index.functions_found, 3);
assert_eq!(index.functions_hit, 2);
assert_eq!(index.branches_found, 4);
assert_eq!(index.branches_hit, 2);
}
#[test]
fn parse_istanbul_empty_json() {
use sloc_core::coverage::parse_istanbul;
let map = parse_istanbul("{}");
assert!(map.is_empty());
}
#[test]
fn parse_istanbul_invalid_json() {
use sloc_core::coverage::parse_istanbul;
let map = parse_istanbul("not valid json {{{");
assert!(map.is_empty());
}
#[test]
fn parse_istanbul_total_key_skipped() {
use sloc_core::coverage::parse_istanbul;
let json = r#"{
"total": {"lines": {"total": 5, "covered": 3},"functions": {"total": 1, "covered": 1},"branches": {"total": 0, "covered": 0}},
"/src/a.js": {"lines": {"total": 5, "covered": 3},"functions": {"total": 1, "covered": 1},"branches": {"total": 0, "covered": 0}}
}"#;
let map = parse_istanbul(json);
assert_eq!(map.len(), 1, "only /src/a.js, not total");
}
#[test]
fn parse_coverage_auto_dispatches_to_lcov() {
use sloc_core::coverage::parse_coverage_auto;
let path = std::path::Path::new("coverage.info");
let content = "SF:src/lib.rs\nLF:5\nLH:4\nFNF:0\nFNH:0\nBRF:0\nBRH:0\nend_of_record\n";
let map = parse_coverage_auto(path, content);
assert_eq!(map.len(), 1);
}
#[test]
fn parse_coverage_auto_dispatches_cobertura_xml() {
use sloc_core::coverage::parse_coverage_auto;
let path = std::path::Path::new("coverage.xml");
let xml = r#"<?xml version="1.0"?>
<coverage>
<packages><package name="."><classes>
<class filename="src/lib.py"><lines><line number="1" hits="1"/></lines></class>
</classes></package></packages>
</coverage>"#;
let map = parse_coverage_auto(path, xml);
assert!(!map.is_empty(), "cobertura XML should be parsed");
}
#[test]
fn parse_coverage_auto_dispatches_jacoco_xml() {
use sloc_core::coverage::parse_coverage_auto;
let path = std::path::Path::new("jacoco.xml");
let xml = r#"<report name="test">
<package name="com/example">
<sourcefile name="A.java"><counter type="LINE" missed="1" covered="9"/></sourcefile>
</package>
</report>"#;
let map = parse_coverage_auto(path, xml);
assert!(!map.is_empty(), "jacoco XML should be parsed");
}
#[test]
fn parse_coverage_auto_dispatches_istanbul_json() {
use sloc_core::coverage::parse_coverage_auto;
let path = std::path::Path::new("coverage-summary.json");
let json = r#"{"/src/a.js": {"lines": {"total": 5, "covered": 3},"functions": {"total": 1, "covered": 1},"branches": {"total": 0, "covered": 0}}}"#;
let map = parse_coverage_auto(path, json);
assert!(!map.is_empty(), "istanbul JSON should be parsed");
}
#[test]
fn parse_coverage_auto_unknown_xml_returns_empty() {
use sloc_core::coverage::parse_coverage_auto;
let path = std::path::Path::new("other.xml");
let xml = r#"<?xml version="1.0"?><data><item>foo</item></data>"#;
let map = parse_coverage_auto(path, xml);
assert!(
map.is_empty(),
"unrecognised XML root should return empty map"
);
}
#[test]
fn baseline_store_get_returns_entry_after_set() {
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "v2".into(),
saved_at: Utc::now(),
run_id: "run-xyz".into(),
summary: snapshot(250),
json_path: None,
});
let entry = store.get("v2");
assert!(entry.is_some());
assert_eq!(entry.unwrap().summary.code_lines, 250);
}
#[test]
fn baseline_store_get_returns_none_for_missing_key() {
let store = BaselineStore::default();
assert!(store.get("nonexistent").is_none());
}
#[test]
fn baseline_store_remove_existing_entry_returns_true() {
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "to-remove".into(),
saved_at: Utc::now(),
run_id: "run-rm".into(),
summary: snapshot(10),
json_path: None,
});
assert!(
store.remove("to-remove"),
"remove must return true for existing key"
);
assert!(
store.get("to-remove").is_none(),
"entry must be gone after remove"
);
}
#[test]
fn baseline_store_remove_missing_entry_returns_false() {
let mut store = BaselineStore::default();
assert!(!store.remove("does-not-exist"));
}
#[test]
fn check_against_baseline_zero_baseline_lines_gives_zero_pct() {
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "empty".into(),
saved_at: Utc::now(),
run_id: "run-0".into(),
summary: snapshot(0),
json_path: None,
});
let result = check_against_baseline(&store, "empty", 100, None).unwrap();
assert_eq!(result.baseline_code_lines, 0);
assert!(
(result.delta_pct - 0.0).abs() < f64::EPSILON,
"delta_pct must be 0.0 when baseline is 0"
);
assert!(!result.exceeded);
}
#[test]
fn check_against_baseline_no_limit_never_exceeds() {
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "main".into(),
saved_at: Utc::now(),
run_id: "run-1".into(),
summary: snapshot(100),
json_path: None,
});
let result = check_against_baseline(&store, "main", 999, None).unwrap();
assert!(
!result.exceeded,
"no max_delta_pct means exceeded is never true"
);
}
#[test]
fn check_against_baseline_negative_delta_never_exceeds() {
let mut store = BaselineStore::default();
store.set(BaselineEntry {
name: "main".into(),
saved_at: Utc::now(),
run_id: "run-1".into(),
summary: snapshot(200),
json_path: None,
});
let result = check_against_baseline(&store, "main", 100, Some(5.0)).unwrap();
assert!(result.delta < 0, "delta must be negative");
assert!(
!result.exceeded,
"shrinking code must not exceed positive threshold"
);
}
#[test]
fn baseline_check_result_print_summary_does_not_panic() {
let result = sloc_core::baseline::BaselineCheckResult {
baseline_name: "main".into(),
baseline_code_lines: 100,
current_code_lines: 120,
delta: 20,
delta_pct: 20.0,
exceeded: false,
max_delta_pct: Some(30.0),
};
result.print_summary();
}
#[test]
fn baseline_check_result_print_summary_exceeded_does_not_panic() {
let result = sloc_core::baseline::BaselineCheckResult {
baseline_name: "release".into(),
baseline_code_lines: 100,
current_code_lines: 200,
delta: 100,
delta_pct: 100.0,
exceeded: true,
max_delta_pct: Some(10.0),
};
result.print_summary();
}
#[test]
fn resolve_baselines_path_uses_env_var() {
let _guard = env_lock();
let dir = tempfile::tempdir().unwrap();
let custom = dir.path().join("my_baselines.json");
std::env::set_var("SLOC_BASELINES_PATH", custom.to_str().unwrap());
let resolved = sloc_core::baseline::resolve_baselines_path();
std::env::remove_var("SLOC_BASELINES_PATH");
assert_eq!(resolved, custom);
}
#[test]
fn resolve_baselines_path_default_when_env_unset() {
let _guard = env_lock();
std::env::remove_var("SLOC_BASELINES_PATH");
let resolved = sloc_core::baseline::resolve_baselines_path();
assert!(
resolved.to_string_lossy().contains("baselines.json"),
"default path must end with baselines.json, got: {}",
resolved.display()
);
}
#[test]
fn resolve_coverage_file_from_config_path() {
use sloc_core::coverage::resolve_coverage_file;
std::env::remove_var("SLOC_COVERAGE_FILE");
let path = std::path::Path::new("/tmp/coverage.info");
let result = resolve_coverage_file(Some(path));
assert_eq!(result, Some(std::path::PathBuf::from("/tmp/coverage.info")));
}
#[test]
fn resolve_coverage_file_none_when_no_config_and_no_env() {
use sloc_core::coverage::resolve_coverage_file;
std::env::remove_var("SLOC_COVERAGE_FILE");
let result = resolve_coverage_file(None);
assert!(result.is_none());
}
#[test]
fn parse_lcov_round_trip_returns_correct_counts() {
let lcov = "\
SF:src/lib.rs\n\
FN:1,main\n\
FNDA:3,main\n\
FNF:1\n\
FNH:1\n\
DA:1,3\n\
DA:2,2\n\
DA:3,0\n\
LH:2\n\
LF:3\n\
BRDA:1,0,0,1\n\
BRDA:1,0,1,0\n\
BRF:2\n\
BRH:1\n\
end_of_record\n";
let map = parse_lcov(lcov);
let key = std::path::PathBuf::from("src/lib.rs");
let cov = map.get(&key).expect("should have coverage for src/lib.rs");
assert_eq!(cov.lines_found, 3);
assert_eq!(cov.lines_hit, 2);
assert_eq!(cov.functions_found, 1);
assert_eq!(cov.functions_hit, 1);
assert_eq!(cov.branches_found, 2);
assert_eq!(cov.branches_hit, 1);
}
#[test]
fn parse_lcov_multiple_files() {
let lcov = "\
SF:src/a.rs\n\
LH:5\n\
LF:10\n\
FNF:2\n\
FNH:2\n\
BRF:0\n\
BRH:0\n\
end_of_record\n\
SF:src/b.rs\n\
LH:8\n\
LF:8\n\
FNF:3\n\
FNH:3\n\
BRF:4\n\
BRH:4\n\
end_of_record\n";
let map = parse_lcov(lcov);
assert_eq!(map.len(), 2);
let a = map.get(&std::path::PathBuf::from("src/a.rs")).unwrap();
assert_eq!(a.lines_found, 10);
assert_eq!(a.lines_hit, 5);
let b = map.get(&std::path::PathBuf::from("src/b.rs")).unwrap();
assert_eq!(b.lines_hit, 8);
}
#[test]
fn parse_lcov_empty_input_returns_empty_map() {
let map = parse_lcov("");
assert!(map.is_empty());
}
#[test]
fn aggregate_line_coverage_returns_none_for_empty_slice() {
let result = aggregate_line_coverage(&[]);
assert!(result.is_none());
}
#[test]
fn aggregate_line_coverage_returns_percentage() {
let cov = FileCoverage {
lines_found: 100,
lines_hit: 80,
functions_found: 10,
functions_hit: 8,
branches_found: 20,
branches_hit: 16,
};
let result = aggregate_line_coverage(&[&cov]);
assert!(result.is_some());
let pct = result.unwrap();
assert!((pct - 80.0).abs() < 0.01, "expected 80.0%, got {pct}");
}
#[test]
fn aggregate_line_coverage_multiple_records() {
let c1 = FileCoverage {
lines_found: 50,
lines_hit: 40,
functions_found: 5,
functions_hit: 4,
branches_found: 0,
branches_hit: 0,
};
let c2 = FileCoverage {
lines_found: 50,
lines_hit: 25,
functions_found: 5,
functions_hit: 3,
branches_found: 0,
branches_hit: 0,
};
let result = aggregate_line_coverage(&[&c1, &c2]).unwrap();
assert!((result - 65.0).abs() < 0.01, "expected 65.0%, got {result}");
}
#[test]
fn analyze_skips_high_entropy_file_that_looks_binary() {
let dir = tempfile::tempdir().unwrap();
let binary_like = "A".repeat(2000);
std::fs::write(dir.path().join("data.bin"), binary_like).unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn foo() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(
!run.per_file_records
.iter()
.any(|r| r.relative_path == "data.bin"),
"binary-like file should not be analyzed as a language file"
);
}
#[test]
fn analyze_walks_deeply_nested_directories() {
let dir = tempfile::tempdir().unwrap();
let deep = dir.path().join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("deep.rs"), "fn deep() {}\n").unwrap();
std::fs::write(dir.path().join("top.rs"), "fn top() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(
run.summary_totals.files_analyzed, 2,
"should find both top and deep files"
);
}
#[test]
fn analyze_ci_name_set_when_github_actions_env_present() {
let _guard = env_lock();
let saved: Vec<(&str, Option<String>)> = [
"JENKINS_URL",
"JENKINS_HOME",
"BUILD_URL",
"GITLAB_CI",
"CI",
"GITHUB_ACTIONS",
]
.iter()
.map(|k| (*k, std::env::var(k).ok()))
.collect();
for (k, _) in &saved {
std::env::remove_var(k);
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn f() {}\n").unwrap();
std::env::set_var("GITHUB_ACTIONS", "true");
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
for (k, v) in saved {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
assert_eq!(
run.environment.ci_name.as_deref(),
Some("GitHub Actions"),
"CI name should be set from GITHUB_ACTIONS env var"
);
}
#[test]
fn analyze_sets_git_branch_from_github_ref_when_no_git_dir() {
let _guard = env_lock();
let saved: Vec<(&str, Option<String>)> =
["GITHUB_REF", "GITHUB_REF_NAME", "BRANCH_NAME", "GIT_BRANCH"]
.iter()
.map(|k| (*k, std::env::var(k).ok()))
.collect();
for (k, _) in &saved {
std::env::remove_var(k);
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn g() {}\n").unwrap();
std::env::set_var("GITHUB_REF", "refs/heads/feature-branch");
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
for (k, v) in saved {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
if run.git_branch.is_some() {
assert!(
run.git_branch.as_deref() == Some("feature-branch")
|| run.git_branch.as_deref().is_some(),
"git_branch should be set"
);
}
}
#[test]
fn analyze_with_lcov_file_populates_coverage_on_records() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"fn foo() {}\nfn bar() {}\n// comment\n",
)
.unwrap();
let lcov_content = format!(
"SF:{}\nLH:2\nLF:3\nFNF:2\nFNH:2\nBRF:0\nBRH:0\nend_of_record\n",
dir.path().join("lib.rs").display()
);
let lcov_path = dir.path().join("lcov.info");
std::fs::write(&lcov_path, lcov_content).unwrap();
let mut cfg = analysis_config_for(dir.path());
cfg.analysis.coverage_file = Some(lcov_path);
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 1);
assert!(!run.warnings.iter().any(|w| w.contains("panic")));
}
#[test]
fn analyze_multiple_languages_in_same_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.py"), "def foo():\n pass\n").unwrap();
std::fs::write(dir.path().join("app.js"), "function bar() {}\n").unwrap();
std::fs::write(dir.path().join("style.css"), ".a { color: red; }\n").unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn rust_fn() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 4);
assert!(
run.totals_by_language.len() >= 4,
"should have at least 4 language entries"
);
}
#[test]
fn analyze_real_git_repo_populates_metadata() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let run_git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(root)
.env("GIT_AUTHOR_NAME", "Tester")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_COMMITTER_NAME", "Tester")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.output()
};
if run_git(&["init", "-q"]).is_err() {
return; }
let _ = run_git(&[
"remote",
"add",
"origin",
"https://github.com/test/repo.git",
]);
std::fs::write(root.join("main.rs"), "fn main() {\n let _ = 1;\n}\n").unwrap();
std::fs::write(root.join("util.py"), "def f():\n return 1\n").unwrap();
let _ = run_git(&["add", "."]);
let committed = run_git(&["-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"]);
if committed.map_or(true, |o| !o.status.success()) {
return; }
let cfg = analysis_config_for(root);
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(run.summary_totals.files_analyzed >= 2);
assert!(
run.git_commit_short.is_some(),
"expected git commit metadata from a real repo"
);
assert_eq!(
run.git_remote_url.as_deref(),
Some("https://github.com/test/repo.git")
);
}
#[test]
fn analyze_handles_diverse_file_contents() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.rs"), "pub fn a() -> i32 { 42 }\n").unwrap();
std::fs::write(
root.join("gen.rs"),
"// @generated by tool — do not edit\npub const X: u8 = 1;\n",
)
.unwrap();
let mut bin = vec![0u8, 1, 2, 3, 0, 7, 9];
bin.extend(std::iter::repeat_n(b'A', 5000));
std::fs::write(root.join("blob.bin"), &bin).unwrap();
let mut u16f = vec![0xFF, 0xFE];
for ch in "let x = 1;\n".encode_utf16() {
u16f.extend_from_slice(&ch.to_le_bytes());
}
std::fs::write(root.join("utf16.js"), &u16f).unwrap();
let cfg = analysis_config_for(root);
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(run.summary_totals.files_considered >= 3);
}
#[test]
fn analyze_nonempty_project_produces_cocomo_estimate() {
let dir = tempfile::tempdir().unwrap();
let code = "fn foo() {}\nfn bar() {}\nfn baz() -> i32 { 42 }\n".repeat(10);
std::fs::write(dir.path().join("lib.rs"), code).unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(
run.cocomo.is_some(),
"non-empty project must produce a COCOMO estimate"
);
let cocomo = run.cocomo.unwrap();
assert!(
cocomo.ksloc > 0.0,
"KSLOC must be positive, got {}",
cocomo.ksloc
);
assert!(cocomo.effort_person_months > 0.0, "effort must be positive");
assert!(cocomo.duration_months > 0.0, "duration must be positive");
}
#[test]
fn analyze_empty_project_has_no_cocomo() {
let dir = tempfile::tempdir().unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(
run.cocomo.is_none() || run.summary_totals.code_lines == 0,
"empty project should have no COCOMO or zero code lines"
);
}
#[test]
fn analyze_produces_uloc_for_nonempty_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
std::fs::write(dir.path().join("b.rs"), "fn baz() {}\nfn qux() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(run.uloc > 0, "ULOC must be positive for non-empty project");
}
#[test]
fn analyze_detects_duplicate_files_same_content() {
let dir = tempfile::tempdir().unwrap();
let content = "fn shared() { let x = 42; }\nfn another() { let y = 7; }\n".repeat(5);
std::fs::write(dir.path().join("a.rs"), &content).unwrap();
std::fs::write(dir.path().join("b.rs"), &content).unwrap(); std::fs::write(
dir.path().join("unique.rs"),
"fn unique_only() { let z = 99; }\n",
)
.unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 3);
assert!(
!run.duplicate_groups.is_empty(),
"duplicate groups should be non-empty when files have identical content"
);
let all_paths: Vec<&str> = run
.duplicate_groups
.iter()
.flat_map(|g| g.iter().map(String::as_str))
.collect();
let has_dup = all_paths.iter().any(|p| p.contains("a.rs"))
&& all_paths.iter().any(|p| p.contains("b.rs"));
assert!(
has_dup,
"a.rs and b.rs should be in the same duplicate group"
);
}
#[test]
fn analyze_with_identical_small_files_finds_duplicates() {
let dir = tempfile::tempdir().unwrap();
let content = "pub fn helper(x: i32) -> i32 { x * 2 }\n";
for name in ["x.rs", "y.rs", "z.rs"] {
std::fs::write(dir.path().join(name), content).unwrap();
}
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(run.summary_totals.files_analyzed, 3);
let total_in_groups: usize = run.duplicate_groups.iter().map(Vec::len).sum();
assert!(
total_in_groups >= 2,
"at least 2 files should be in duplicate groups"
);
}
#[test]
fn analyze_dryness_pct_is_none_for_empty_project() {
let dir = tempfile::tempdir().unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(
run.dryness_pct.is_none() || run.summary_totals.code_lines == 0,
"empty project should have no dryness percentage"
);
}
#[test]
fn analyze_dryness_pct_is_some_for_nonempty_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn foo() { let x = 1; }\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
if run.summary_totals.code_lines > 0 {
assert!(
run.dryness_pct.is_some(),
"non-empty project should have dryness_pct"
);
let pct = run.dryness_pct.unwrap();
assert!(
(0.0..=100.0).contains(&pct),
"dryness_pct must be 0-100, got {pct}"
);
}
}
#[test]
fn analyze_style_summary_populated_for_consistent_code() {
let dir = tempfile::tempdir().unwrap();
let code = r#"fn main() {
let x = 1;
let y = 2;
println!("{}", x + y);
}
fn helper(n: i32) -> i32 {
n * 2
}
"#;
std::fs::write(dir.path().join("main.rs"), code).unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(run.summary_totals.files_analyzed >= 1);
let _ = run.style_summary;
}
#[test]
fn compute_delta_with_coverage_data() {
let mut base = make_run_with_files(vec![("src/lib.rs", 100)]);
let mut current = make_run_with_files(vec![("src/lib.rs", 120)]);
base.per_file_records[0].coverage = Some(sloc_core::FileCoverage {
lines_found: 100,
lines_hit: 75,
functions_found: 10,
functions_hit: 8,
branches_found: 0,
branches_hit: 0,
});
base.summary_totals.coverage_lines_found = 100;
base.summary_totals.coverage_lines_hit = 75;
current.per_file_records[0].coverage = Some(sloc_core::FileCoverage {
lines_found: 120,
lines_hit: 100,
functions_found: 12,
functions_hit: 11,
branches_found: 0,
branches_hit: 0,
});
current.summary_totals.coverage_lines_found = 120;
current.summary_totals.coverage_lines_hit = 100;
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, 20);
assert!(
cmp.summary.coverage_lines_hit_delta.is_some(),
"coverage delta must be Some when both runs have coverage data"
);
}
#[test]
fn compute_delta_without_coverage_on_baseline() {
let base = make_run_with_files(vec![("src/lib.rs", 50)]);
let mut current = make_run_with_files(vec![("src/lib.rs", 60)]);
current.per_file_records[0].coverage = Some(sloc_core::FileCoverage {
lines_found: 60,
lines_hit: 50,
functions_found: 5,
functions_hit: 4,
branches_found: 0,
branches_hit: 0,
});
current.summary_totals.coverage_lines_found = 60;
current.summary_totals.coverage_lines_hit = 50;
let cmp = compute_delta(&base, ¤t);
assert_eq!(cmp.summary.code_lines_delta, 10);
let _ = cmp.summary.coverage_lines_hit_delta;
}
#[test]
fn scan_registry_preserves_order_of_insertion() {
let mut reg = ScanRegistry::default();
for i in 0..5 {
let mut e = make_registry_entry(&format!("run-{i}"));
e.timestamp_utc = Utc::now() + chrono::Duration::seconds(i64::from(i));
reg.add_entry(e);
}
assert_eq!(reg.entries.len(), 5);
for i in 0..5 {
assert!(reg.find_by_run_id(&format!("run-{i}")).is_some());
}
}
#[test]
fn scan_registry_multiple_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("registry.json");
let mut reg = ScanRegistry::default();
for i in 0..3 {
reg.add_entry(make_registry_entry(&format!("run-{i}")));
}
reg.save(&path).unwrap();
let loaded = ScanRegistry::load(&path);
assert_eq!(loaded.entries.len(), 3);
let mut loaded = loaded;
loaded.add_entry(make_registry_entry("run-3"));
loaded.save(&path).unwrap();
let loaded2 = ScanRegistry::load(&path);
assert_eq!(loaded2.entries.len(), 4);
}
#[test]
fn watched_dirs_store_remove_entry() {
let mut store = WatchedDirsStore::default();
let p = PathBuf::from("/tmp/test-dir");
store.add(p.clone());
assert_eq!(store.dirs.len(), 1);
store.remove(&p);
assert!(
store.dirs.is_empty(),
"removing added dir should leave empty store"
);
}
#[test]
fn watched_dirs_store_remove_nonexistent_is_noop() {
let mut store = WatchedDirsStore::default();
store.add(PathBuf::from("/tmp/a"));
store.remove(&PathBuf::from("/tmp/does-not-exist"));
assert_eq!(
store.dirs.len(),
1,
"removing nonexistent dir must not affect store"
);
}
#[test]
fn compute_multi_delta_with_three_runs() {
use sloc_core::compute_multi_delta;
let r0 = make_run_with_files(vec![("src/a.rs", 100)]);
let r1 = make_run_with_files(vec![("src/a.rs", 120), ("src/b.rs", 40)]);
let r2 = make_run_with_files(vec![("src/a.rs", 130), ("src/b.rs", 50), ("src/c.rs", 20)]);
let refs = [&r0, &r1, &r2];
let multi = compute_multi_delta(&refs);
assert_eq!(multi.points.len(), 3);
assert_eq!(
multi.sequential_deltas.len(),
2,
"2 consecutive deltas for 3 runs"
);
}
#[test]
fn analyze_with_enabled_languages_filter() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn rust_fn() {}\n").unwrap();
std::fs::write(dir.path().join("b.py"), "def py_fn(): pass\n").unwrap();
let mut cfg = analysis_config_for(dir.path());
cfg.analysis.enabled_languages = vec!["rust".to_string()];
let run = analyze(&cfg, "test", None, None).unwrap();
assert_eq!(
run.summary_totals.files_analyzed, 1,
"only Rust file should be analyzed"
);
assert_eq!(
run.totals_by_language[0].language,
sloc_languages::Language::Rust
);
}
#[test]
fn analyze_with_exclude_globs_filters_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
std::fs::write(dir.path().join("test_helpers.rs"), "fn helper() {}\n").unwrap();
let mut cfg = analysis_config_for(dir.path());
cfg.discovery.exclude_globs = vec!["**/test_*.rs".to_string()];
let run = analyze(&cfg, "test", None, None).unwrap();
let paths: Vec<&str> = run
.per_file_records
.iter()
.map(|r| r.relative_path.as_str())
.collect();
assert!(
!paths.iter().any(|p| p.contains("test_helpers")),
"test_helpers.rs should be excluded"
);
}
#[test]
fn analysis_run_with_cocomo_roundtrips_json() {
let dir = tempfile::tempdir().unwrap();
let code = "fn f() { let x = 1 + 2; }\n".repeat(100);
std::fs::write(dir.path().join("lib.rs"), code).unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(
run.cocomo.is_some(),
"analyze should produce COCOMO estimate"
);
let json = serde_json::to_string(&run).unwrap();
let back: sloc_core::AnalysisRun = serde_json::from_str(&json).unwrap();
assert!(back.cocomo.is_some(), "COCOMO must survive JSON roundtrip");
let orig = run.cocomo.unwrap();
let restored = back.cocomo.unwrap();
assert!(
(orig.ksloc - restored.ksloc).abs() < 0.001,
"KSLOC must survive roundtrip"
);
assert!(
(orig.effort_person_months - restored.effort_person_months).abs() < 0.001,
"effort must survive roundtrip"
);
}
#[test]
fn analyze_marks_generated_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("gen_code.rs"),
"// @generated by build_tool — do not edit\nfn generated() { let _ = 1; }\n",
)
.unwrap();
std::fs::write(dir.path().join("normal.rs"), "fn normal() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let run = analyze(&cfg, "test", None, None).unwrap();
assert!(
run.summary_totals.files_considered >= 1,
"at least the normal file should be considered"
);
}
#[test]
fn cleanup_policy_store_save_and_load() {
use sloc_core::CleanupPolicy;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cleanup.json");
let store = CleanupPolicyStore {
policy: Some(CleanupPolicy {
enabled: true,
max_age_days: Some(30),
max_run_count: Some(50),
interval_hours: 24,
}),
..CleanupPolicyStore::default()
};
store.save(&path).unwrap();
let loaded = CleanupPolicyStore::load(&path);
assert!(loaded.policy.is_some(), "policy must survive save/load");
let p = loaded.policy.unwrap();
assert!(p.enabled);
assert_eq!(p.max_age_days, Some(30));
assert_eq!(p.max_run_count, Some(50));
}
#[test]
fn cleanup_policy_store_default_has_no_policy() {
let store = CleanupPolicyStore::default();
assert!(store.policy.is_none());
assert!(store.last_run_at.is_none());
}
#[test]
fn analyze_respects_cancel_signal() {
use std::sync::atomic::{AtomicBool, Ordering};
let dir = tempfile::tempdir().unwrap();
for i in 0..5 {
std::fs::write(dir.path().join(format!("f{i}.rs")), "fn f() {}\n").unwrap();
}
let cancel = AtomicBool::new(false);
cancel.store(true, Ordering::SeqCst);
let cfg = analysis_config_for(dir.path());
let result = analyze(&cfg, "test", Some(&cancel), None);
let _ = result;
}
#[test]
fn analysis_run_environment_ci_name_travis() {
let _lock = env_lock();
for v in &[
"JENKINS_URL",
"GITHUB_ACTIONS",
"GITLAB_CI",
"CIRCLECI",
"TF_BUILD",
"TEAMCITY_VERSION",
] {
std::env::remove_var(v);
}
std::env::set_var("TRAVIS", "true");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn f() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let result = sloc_core::analyze(&cfg, "ci-travis-test", None, None);
std::env::remove_var("TRAVIS");
if let Ok(run) = result {
assert_eq!(run.environment.ci_name.as_deref(), Some("Travis CI"));
}
}
#[test]
fn analysis_run_environment_ci_name_azure_devops() {
let _lock = env_lock();
for v in &[
"JENKINS_URL",
"GITHUB_ACTIONS",
"GITLAB_CI",
"CIRCLECI",
"TRAVIS",
"TEAMCITY_VERSION",
] {
std::env::remove_var(v);
}
std::env::set_var("TF_BUILD", "true");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("b.rs"), "fn g() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let result = sloc_core::analyze(&cfg, "ci-azure-test", None, None);
std::env::remove_var("TF_BUILD");
if let Ok(run) = result {
assert_eq!(run.environment.ci_name.as_deref(), Some("Azure DevOps"));
}
}
#[test]
fn analysis_run_environment_ci_name_teamcity() {
let _lock = env_lock();
for v in &[
"JENKINS_URL",
"GITHUB_ACTIONS",
"GITLAB_CI",
"CIRCLECI",
"TRAVIS",
"TF_BUILD",
] {
std::env::remove_var(v);
}
std::env::set_var("TEAMCITY_VERSION", "2024.1");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("c.rs"), "fn h() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let result = sloc_core::analyze(&cfg, "ci-teamcity-test", None, None);
std::env::remove_var("TEAMCITY_VERSION");
if let Ok(run) = result {
assert_eq!(run.environment.ci_name.as_deref(), Some("TeamCity"));
}
}
#[test]
fn analysis_run_git_branch_from_github_ref_name() {
let _lock = env_lock();
std::env::set_var("GITHUB_REF_NAME", "my-feature-branch");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("d.rs"), "fn i() {}\n").unwrap();
let cfg = analysis_config_for(dir.path());
let result = sloc_core::analyze(&cfg, "ci-branch-test", None, None);
std::env::remove_var("GITHUB_REF_NAME");
if let Ok(run) = result {
assert!(run.summary_totals.files_analyzed <= run.summary_totals.files_considered);
}
}