#![allow(unused)]
#![cfg_attr(coverage_nightly, coverage(off))]
use crate::services::git_analysis::GitAnalysisService;
use crate::services::service_registry::ServiceRegistry;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub const HIGH_RISK_PROBABILITY: f32 = 0.6;
const CHURN_WINDOW_DAYS: u32 = 90;
const COMMITS_AT_FULL_SCALE: f32 = 20.0;
const CHANGED_LINES_AT_FULL_SCALE: f32 = 1000.0;
const IMPORTS_AT_FULL_SCALE: f32 = 20.0;
const FREQUENT_CHANGE_SCORE: f32 = 0.5;
#[derive(Debug, Clone)]
pub struct DefectPredictionRequest {
pub project_path: PathBuf,
pub confidence_threshold: f32,
pub min_lines: usize,
pub include_low_confidence: bool,
pub high_risk_only: bool,
pub include_recommendations: bool,
pub include: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
pub top_files: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefectPredictionResult {
pub total_files_discovered: usize,
pub total_files_analyzed: usize,
pub files_matching_filters: usize,
pub high_risk_files: usize,
pub medium_risk_files: usize,
pub low_risk_files: usize,
pub predictions_reported: usize,
pub predictions_truncated: bool,
pub churn_source: ChurnSource,
pub coupling_source: CouplingSource,
pub duplication_source: DuplicationSource,
pub predictions: Vec<FilePrediction>,
pub summary: String,
pub recommendations: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ChurnSource {
GitHistory {
window_days: u32,
files_with_churn: usize,
commits_at_full_scale: u32,
changed_lines_at_full_scale: u32,
},
NotMeasured { reason: String },
}
impl ChurnSource {
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::GitHistory {
window_days,
files_with_churn,
commits_at_full_scale,
..
} => format!(
"git history, last {window_days} days ({files_with_churn} files with commits; \
{commits_at_full_scale} commits = 1.0)"
),
Self::NotMeasured { reason } => format!("not measured ({reason})"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CouplingSource {
EfferentImports { imports_at_full_scale: u32 },
NotMeasured { reason: String },
}
impl CouplingSource {
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::EfferentImports {
imports_at_full_scale,
} => format!(
"distinct import statements per file ({imports_at_full_scale} imports = 1.0)"
),
Self::NotMeasured { reason } => format!("not measured ({reason})"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DuplicationSource {
NotMeasured { reason: String },
}
impl DuplicationSource {
#[must_use]
pub fn not_run() -> Self {
Self::NotMeasured {
reason: "defect-prediction does not run clone detection; \
run `pmat analyze duplicates` for measured duplication"
.to_string(),
}
}
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::NotMeasured { reason } => format!("not measured ({reason})"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilePrediction {
pub file_path: String,
pub defect_probability: f32,
pub risk_level: RiskLevel,
pub confidence: f32,
pub metrics: FileRiskMetrics,
pub contributing_factors: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum RiskLevel {
Critical,
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileRiskMetrics {
pub complexity_score: f32,
pub churn_score: Option<f32>,
pub coupling_score: Option<f32>,
pub size_score: f32,
pub duplication_score: Option<f32>,
pub lines: usize,
pub imports: Option<usize>,
pub commits_in_window: Option<usize>,
pub changed_lines_in_window: Option<usize>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ChurnObservation {
pub commits: usize,
pub changed_lines: usize,
}
impl ChurnObservation {
#[must_use]
pub fn score(self) -> f32 {
#[allow(clippy::cast_precision_loss)]
let commit_factor = (self.commits as f32 / COMMITS_AT_FULL_SCALE).min(1.0);
#[allow(clippy::cast_precision_loss)]
let change_factor = (self.changed_lines as f32 / CHANGED_LINES_AT_FULL_SCALE).min(1.0);
(commit_factor * 0.6 + change_factor * 0.4).clamp(0.0, 1.0)
}
}
struct ChurnIndex {
by_path: HashMap<PathBuf, ChurnObservation>,
tracked: Option<std::collections::HashSet<PathBuf>>,
source: ChurnSource,
}
impl ChurnIndex {
fn measure(path: &Path) -> Self {
match GitAnalysisService::analyze_code_churn(path, CHURN_WINDOW_DAYS) {
Ok(analysis) => {
let mut by_path = HashMap::with_capacity(analysis.files.len());
for file in &analysis.files {
let key = file
.path
.canonicalize()
.unwrap_or_else(|_| file.path.clone());
by_path.insert(
key,
ChurnObservation {
commits: file.commit_count,
changed_lines: file.additions + file.deletions,
},
);
}
let files_with_churn = by_path.len();
Self {
by_path,
tracked: tracked_files(path),
source: ChurnSource::GitHistory {
window_days: CHURN_WINDOW_DAYS,
files_with_churn,
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
commits_at_full_scale: COMMITS_AT_FULL_SCALE as u32,
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
changed_lines_at_full_scale: CHANGED_LINES_AT_FULL_SCALE as u32,
},
}
}
Err(e) => {
tracing::warn!(
"churn not measured for {}: {e}; churn_score will be null and is \
excluded from the defect probability",
path.display()
);
Self {
by_path: HashMap::new(),
tracked: None,
source: ChurnSource::NotMeasured {
reason: e.to_string(),
},
}
}
}
}
fn observation_for(&self, file: &Path) -> Option<ChurnObservation> {
if matches!(self.source, ChurnSource::NotMeasured { .. }) {
return None;
}
let key = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if let Some(observed) = self.by_path.get(&key) {
return Some(*observed);
}
match &self.tracked {
Some(tracked) if !tracked.contains(&key) => None,
_ => Some(ChurnObservation::default()),
}
}
}
fn tracked_files(path: &Path) -> Option<std::collections::HashSet<PathBuf>> {
let root = GitAnalysisService::discover_repository_root(path)?;
let output = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["ls-files", "-z"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let listing = String::from_utf8_lossy(&output.stdout);
Some(
listing
.split('\0')
.filter(|entry| !entry.is_empty())
.map(|entry| {
let joined = root.join(entry);
joined.canonicalize().unwrap_or(joined)
})
.collect(),
)
}
fn count_imports(path: &Path, content: &str) -> Option<usize> {
let prefixes: &[&str] = match path.extension().and_then(|e| e.to_str())? {
"rs" => &["use ", "extern crate "],
"py" => &["import ", "from "],
"js" | "ts" => &["import ", "const {", "require(", "export * from"],
"java" => &["import "],
"c" | "cpp" => &["#include"],
_ => return None,
};
let distinct: std::collections::BTreeSet<&str> = content
.lines()
.map(str::trim)
.filter(|line| prefixes.iter().any(|p| line.starts_with(p)))
.collect();
Some(distinct.len())
}
const W_COMPLEXITY: f32 = 0.30;
const W_CHURN: f32 = 0.25;
const W_COUPLING: f32 = 0.20;
const W_SIZE: f32 = 0.15;
const W_DUPLICATION: f32 = 0.10;
fn measured_weight(metrics: &FileRiskMetrics) -> f32 {
let mut weight = W_COMPLEXITY + W_SIZE;
if metrics.churn_score.is_some() {
weight += W_CHURN;
}
if metrics.coupling_score.is_some() {
weight += W_COUPLING;
}
if metrics.duplication_score.is_some() {
weight += W_DUPLICATION;
}
weight
}
fn combine_probability(metrics: &FileRiskMetrics) -> f32 {
let mut weighted = metrics.complexity_score * W_COMPLEXITY + metrics.size_score * W_SIZE;
if let Some(churn) = metrics.churn_score {
weighted += churn * W_CHURN;
}
if let Some(coupling) = metrics.coupling_score {
weighted += coupling * W_COUPLING;
}
if let Some(duplication) = metrics.duplication_score {
weighted += duplication * W_DUPLICATION;
}
let total_weight = measured_weight(metrics);
if total_weight <= 0.0 {
return 0.0;
}
(weighted / total_weight).clamp(0.0, 1.0)
}
fn classify(probability: f32) -> RiskLevel {
match probability {
p if p >= 0.8 => RiskLevel::Critical,
p if p >= HIGH_RISK_PROBABILITY => RiskLevel::High,
p if p >= 0.4 => RiskLevel::Medium,
_ => RiskLevel::Low,
}
}
#[derive(Clone)]
pub struct DefectPredictionFacade {
registry: Arc<ServiceRegistry>,
}
impl DefectPredictionFacade {
#[must_use]
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn new(registry: Arc<ServiceRegistry>) -> Self {
Self { registry }
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub async fn analyze_project(
&self,
request: DefectPredictionRequest,
) -> Result<DefectPredictionResult> {
let files = self.discover_files(&request).await?;
let churn = ChurnIndex::measure(&request.project_path);
let mut analyzed = 0usize;
let mut matching = Vec::new();
for file_path in &files {
let Ok(prediction) = self.analyze_file(file_path, &request, &churn).await else {
continue;
};
analyzed += 1;
if passes_filters(&prediction, &request) {
matching.push(prediction);
}
}
matching.sort_by(|a, b| {
b.defect_probability
.partial_cmp(&a.defect_probability)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file_path.cmp(&b.file_path))
});
let coupling_source = if matching.iter().any(|p| p.metrics.coupling_score.is_some()) {
CouplingSource::EfferentImports {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
imports_at_full_scale: IMPORTS_AT_FULL_SCALE as u32,
}
} else {
CouplingSource::NotMeasured {
reason: "no analyzed file used an import form this counts".to_string(),
}
};
Ok(build_result(
files.len(),
analyzed,
matching,
MetricSources {
churn: churn.source,
coupling: coupling_source,
duplication: DuplicationSource::not_run(),
},
&request,
))
}
async fn discover_files(&self, request: &DefectPredictionRequest) -> Result<Vec<PathBuf>> {
use walkdir::WalkDir;
let mut files = Vec::new();
for entry in WalkDir::new(&request.project_path)
.follow_links(false)
.sort_by_file_name()
.into_iter()
.filter_map(std::result::Result::ok)
{
let path = entry.path();
if path.is_file() && Self::matches_filters(path, request) {
files.push(path.to_path_buf());
}
}
Ok(files)
}
fn matches_filters(path: &Path, request: &DefectPredictionRequest) -> bool {
let path_str = path.to_string_lossy();
if let Some(ref excludes) = request.exclude {
if excludes.iter().any(|pattern| path_str.contains(pattern)) {
return false;
}
}
if let Some(ref includes) = request.include {
if !includes.iter().any(|pattern| path_str.contains(pattern)) {
return false;
}
}
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches!(ext, "rs" | "py" | "js" | "ts" | "cpp" | "c" | "java"))
}
async fn analyze_file(
&self,
file_path: &PathBuf,
request: &DefectPredictionRequest,
churn: &ChurnIndex,
) -> Result<FilePrediction> {
let content = tokio::fs::read_to_string(file_path).await?;
let lines = content.lines().count();
if lines < request.min_lines {
return Err(anyhow::anyhow!("File too small"));
}
let observation = churn.observation_for(file_path);
let imports = count_imports(file_path, &content);
#[allow(clippy::cast_precision_loss)]
let metrics = FileRiskMetrics {
complexity_score: (lines as f32 / 100.0).min(1.0),
churn_score: observation.map(ChurnObservation::score),
coupling_score: imports.map(|count| (count as f32 / IMPORTS_AT_FULL_SCALE).min(1.0)),
size_score: (lines as f32 / 1000.0).min(1.0),
duplication_score: None,
lines,
imports,
commits_in_window: observation.map(|o| o.commits),
changed_lines_in_window: observation.map(|o| o.changed_lines),
};
let defect_probability = combine_probability(&metrics);
Ok(FilePrediction {
file_path: file_path.display().to_string(),
defect_probability,
risk_level: classify(defect_probability),
confidence: confidence_for(&metrics),
contributing_factors: contributing_factors(&metrics),
metrics,
})
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn quick_analysis(&self, project_path: PathBuf) -> Result<DefectPredictionResult> {
let request = DefectPredictionRequest {
project_path,
confidence_threshold: 0.5,
min_lines: 50,
include_low_confidence: false,
high_risk_only: false,
include_recommendations: true,
include: None,
exclude: Some(vec!["test".to_string(), "vendor".to_string()]),
top_files: 10,
};
self.analyze_project(request).await
}
}
fn confidence_for(metrics: &FileRiskMetrics) -> f32 {
measured_weight(metrics).clamp(0.0, 1.0)
}
fn contributing_factors(metrics: &FileRiskMetrics) -> Vec<String> {
let mut factors = Vec::new();
if metrics.complexity_score > 0.7 || metrics.size_score > 0.7 {
factors.push(format!("Long file ({} lines)", metrics.lines));
}
if metrics
.churn_score
.is_some_and(|c| c > FREQUENT_CHANGE_SCORE)
{
let commits = metrics.commits_in_window.unwrap_or(0);
factors.push(format!(
"Frequent changes ({commits} commits in the last {CHURN_WINDOW_DAYS} days)"
));
}
if metrics.coupling_score.is_some_and(|c| c > 0.7) {
let imports = metrics.imports.unwrap_or(0);
factors.push(format!("High coupling ({imports} distinct imports)"));
}
factors
}
fn passes_filters(prediction: &FilePrediction, request: &DefectPredictionRequest) -> bool {
if request.high_risk_only && matches!(prediction.risk_level, RiskLevel::Low | RiskLevel::Medium)
{
return false;
}
if !request.include_low_confidence && prediction.confidence < request.confidence_threshold {
return false;
}
true
}
pub struct MetricSources {
pub churn: ChurnSource,
pub coupling: CouplingSource,
pub duplication: DuplicationSource,
}
fn build_result(
total_files_discovered: usize,
total_files_analyzed: usize,
matching: Vec<FilePrediction>,
sources: MetricSources,
request: &DefectPredictionRequest,
) -> DefectPredictionResult {
let files_matching_filters = matching.len();
let high_risk_files = matching
.iter()
.filter(|p| matches!(p.risk_level, RiskLevel::Critical | RiskLevel::High))
.count();
let medium_risk_files = matching
.iter()
.filter(|p| matches!(p.risk_level, RiskLevel::Medium))
.count();
let low_risk_files = matching
.iter()
.filter(|p| matches!(p.risk_level, RiskLevel::Low))
.count();
let mut predictions = matching;
let predictions_truncated = request.top_files > 0 && predictions.len() > request.top_files;
if predictions_truncated {
predictions.truncate(request.top_files);
}
let predictions_reported = predictions.len();
let mut summary = format!(
"Analyzed {total_files_analyzed} of {total_files_discovered} discovered files: \
{high_risk_files} high risk, {medium_risk_files} medium risk, {low_risk_files} low risk. \
Churn: {}. Coupling: {}. Duplication: {}.",
sources.churn.describe(),
sources.coupling.describe(),
sources.duplication.describe()
);
if predictions_truncated {
summary.push_str(&format!(
" Showing top {predictions_reported} of {files_matching_filters} matching files \
(--top-files {}).",
request.top_files
));
}
let mut recommendations = Vec::new();
if high_risk_files > 0 {
recommendations.push("Focus testing and review efforts on high-risk files".to_string());
}
if request.include_recommendations {
for prediction in predictions.iter().take(3) {
if !prediction.contributing_factors.is_empty() {
recommendations.push(format!(
"{}: Address {}",
prediction.file_path,
prediction.contributing_factors.join(", ")
));
}
}
}
DefectPredictionResult {
total_files_discovered,
total_files_analyzed,
files_matching_filters,
high_risk_files,
medium_risk_files,
low_risk_files,
predictions_reported,
predictions_truncated,
churn_source: sources.churn,
coupling_source: sources.coupling,
duplication_source: sources.duplication,
predictions,
summary,
recommendations,
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
use crate::services::service_registry::ServiceRegistry;
use tempfile::TempDir;
fn facade() -> DefectPredictionFacade {
DefectPredictionFacade::new(Arc::new(ServiceRegistry::new()))
}
fn request(path: &Path, top_files: usize) -> DefectPredictionRequest {
DefectPredictionRequest {
project_path: path.to_path_buf(),
confidence_threshold: 0.0,
min_lines: 0,
include_low_confidence: true,
high_risk_only: false,
include_recommendations: false,
include: None,
exclude: None,
top_files,
}
}
fn write_sources(dir: &Path, count: usize) {
std::fs::create_dir_all(dir).unwrap();
for i in 0..count {
let body: String = (0..(10 + i)).map(|n| format!("// line {n}\n")).collect();
std::fs::write(dir.join(format!("file_{i:03}.rs")), body).unwrap();
}
}
fn init_repo(root: &Path) {
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("git must be available");
assert!(out.status.success(), "git {args:?}: {:?}", out.stderr);
};
git(&["init", "--initial-branch=main"]);
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "T"]);
}
#[tokio::test]
async fn test_defect_prediction_facade_creation() {
let _facade = facade();
}
#[test]
fn test_risk_level_classification() {
assert_eq!(RiskLevel::Critical, RiskLevel::Critical);
}
#[tokio::test]
async fn test_totals_are_totals_not_the_top_files_cap() {
let temp = TempDir::new().unwrap();
write_sources(temp.path(), 25);
let result = facade()
.analyze_project(request(temp.path(), 10))
.await
.unwrap();
assert_eq!(result.total_files_discovered, 25);
assert_eq!(
result.total_files_analyzed, 25,
"25 files were analyzed; reporting the --top-files cap (10) as the total is the bug"
);
assert_eq!(result.predictions_reported, 10);
assert!(result.predictions_truncated);
assert!(
result.summary.contains("25"),
"summary must state the real total: {}",
result.summary
);
}
#[tokio::test]
async fn test_no_truncation_flag_when_under_cap() {
let temp = TempDir::new().unwrap();
write_sources(temp.path(), 4);
let result = facade()
.analyze_project(request(temp.path(), 10))
.await
.unwrap();
assert_eq!(result.total_files_analyzed, 4);
assert_eq!(result.predictions_reported, 4);
assert!(!result.predictions_truncated);
}
#[tokio::test]
async fn test_top_files_zero_means_all() {
let temp = TempDir::new().unwrap();
write_sources(temp.path(), 7);
let result = facade()
.analyze_project(request(temp.path(), 0))
.await
.unwrap();
assert_eq!(result.predictions_reported, 7);
assert!(!result.predictions_truncated);
}
#[tokio::test]
async fn test_churn_is_absent_not_defaulted_outside_a_repo() {
let temp = TempDir::new().unwrap();
write_sources(temp.path(), 3);
let result = facade()
.analyze_project(request(temp.path(), 10))
.await
.unwrap();
assert!(matches!(
result.churn_source,
ChurnSource::NotMeasured { .. }
));
for p in &result.predictions {
assert_eq!(
p.metrics.churn_score, None,
"unmeasurable churn must be null, never 0.0 and never a default"
);
}
}
#[tokio::test]
async fn test_churn_is_measured_from_a_subdirectory_of_a_repo() {
let temp = TempDir::new().unwrap();
let root = temp.path();
init_repo(root);
let subdir = root.join("src/utils");
write_sources(&subdir, 3);
for i in 0..2 {
std::fs::write(
subdir.join("file_000.rs"),
format!("// revision {i}\nfn f() {{}}\n"),
)
.unwrap();
let out = std::process::Command::new("git")
.args(["add", "."])
.current_dir(root)
.output()
.unwrap();
assert!(out.status.success());
let out = std::process::Command::new("git")
.args(["commit", "-m", "c"])
.current_dir(root)
.output()
.unwrap();
assert!(out.status.success());
}
let result = facade()
.analyze_project(request(&subdir, 10))
.await
.unwrap();
assert!(
matches!(result.churn_source, ChurnSource::GitHistory { .. }),
"churn must be measured for a subdirectory of a repo, got {:?}",
result.churn_source
);
assert!(
result
.predictions
.iter()
.any(|p| p.metrics.churn_score.is_some_and(|c| c > 0.0)),
"at least the committed file must have non-zero measured churn"
);
}
#[tokio::test]
async fn test_analysis_is_identical_across_5_runs() {
let temp = TempDir::new().unwrap();
write_sources(temp.path(), 30);
let first = serde_json::to_string(
&facade()
.analyze_project(request(temp.path(), 10))
.await
.unwrap(),
)
.unwrap();
for i in 1..5 {
let again = serde_json::to_string(
&facade()
.analyze_project(request(temp.path(), 10))
.await
.unwrap(),
)
.unwrap();
assert_eq!(first, again, "defect prediction differed on run {i}");
}
}
fn metrics_all_measured(churn: Option<f32>, coupling: Option<f32>) -> FileRiskMetrics {
FileRiskMetrics {
complexity_score: 1.0,
churn_score: churn,
coupling_score: coupling,
size_score: 1.0,
duplication_score: None,
lines: 1000,
imports: coupling.map(|_| 20),
commits_in_window: churn.map(|_| 4),
changed_lines_in_window: churn.map(|_| 80),
}
}
#[test]
fn test_probability_renormalizes_when_churn_absent() {
let base = metrics_all_measured(None, Some(1.0));
assert!((combine_probability(&base) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_probability_varies_with_measured_churn() {
let metrics = |churn: f32| FileRiskMetrics {
complexity_score: 0.5,
churn_score: Some(churn),
coupling_score: Some(0.2),
size_score: 0.1,
duplication_score: None,
lines: 50,
imports: Some(4),
commits_in_window: Some(2),
changed_lines_in_window: Some(20),
};
assert!(combine_probability(&metrics(0.9)) > combine_probability(&metrics(0.1)));
}
#[test]
fn test_confidence_drops_when_churn_unmeasured() {
let measured = metrics_all_measured(Some(0.4), Some(0.2));
let mut absent = measured.clone();
absent.churn_score = None;
assert!(confidence_for(&absent) < confidence_for(&measured));
}
#[test]
fn test_churn_source_describe_says_not_measured() {
let src = ChurnSource::NotMeasured {
reason: "No git repository found".to_string(),
};
assert!(src.describe().contains("not measured"));
}
#[tokio::test]
async fn test_coupling_varies_per_file_and_is_derived_from_imports() {
let temp = TempDir::new().unwrap();
std::fs::write(
temp.path().join("bare.rs"),
"fn a() {}\nfn b() {}\nfn c() {}\n",
)
.unwrap();
let many: String = (0..30)
.map(|i| format!("use std::collections::x{i};\n"))
.collect();
std::fs::write(
temp.path().join("coupled.rs"),
format!("{many}fn a() {{}}\n"),
)
.unwrap();
let result = facade()
.analyze_project(request(temp.path(), 0))
.await
.unwrap();
let score = |name: &str| {
result
.predictions
.iter()
.find(|p| p.file_path.ends_with(name))
.unwrap_or_else(|| panic!("{name} missing"))
.metrics
.clone()
};
let bare = score("bare.rs");
let coupled = score("coupled.rs");
assert_eq!(bare.imports, Some(0));
assert_eq!(coupled.imports, Some(30));
assert_eq!(bare.coupling_score, Some(0.0));
assert_eq!(coupled.coupling_score, Some(1.0));
assert!(
bare.coupling_score != coupled.coupling_score,
"coupling must not be one constant for every file"
);
assert!(matches!(
result.coupling_source,
CouplingSource::EfferentImports { .. }
));
}
#[tokio::test]
async fn test_duplication_is_absent_with_a_reason() {
let temp = TempDir::new().unwrap();
write_sources(temp.path(), 3);
let result = facade()
.analyze_project(request(temp.path(), 0))
.await
.unwrap();
for p in &result.predictions {
assert_eq!(
p.metrics.duplication_score, None,
"unmeasured duplication must be null, never the old 0.1 and never 0.0"
);
}
let DuplicationSource::NotMeasured { reason } = &result.duplication_source;
assert!(
reason.contains("clone detection"),
"the reason must say what was not run: {reason}"
);
assert!(result.summary.contains("Duplication: not measured"));
}
#[test]
fn test_confidence_is_the_measured_share_not_a_constant() {
let full = metrics_all_measured(Some(0.4), Some(0.5));
let no_churn = metrics_all_measured(None, Some(0.5));
let no_coupling = metrics_all_measured(Some(0.4), None);
assert!((confidence_for(&full) - 0.90).abs() < 1e-5);
assert!((confidence_for(&no_churn) - 0.65).abs() < 1e-5);
assert!((confidence_for(&no_coupling) - 0.70).abs() < 1e-5);
assert!(confidence_for(&no_churn) < confidence_for(&full));
}
#[tokio::test]
async fn test_one_commit_is_not_frequent_changes() {
let temp = TempDir::new().unwrap();
let root = temp.path();
init_repo(root);
std::fs::write(root.join("x.rs"), "fn x() {}\n").unwrap();
std::fs::write(root.join("y.rs"), "fn y() {}\n").unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap();
assert!(out.status.success());
};
git(&["add", "."]);
git(&["commit", "-m", "one"]);
let result = facade().analyze_project(request(root, 0)).await.unwrap();
assert_eq!(result.predictions.len(), 2);
for p in &result.predictions {
assert_eq!(p.metrics.commits_in_window, Some(1));
assert!(
p.metrics
.churn_score
.is_some_and(|c| c < FREQUENT_CHANGE_SCORE),
"1 commit must not score as high churn: {:?}",
p.metrics.churn_score
);
assert!(
!p.contributing_factors
.iter()
.any(|f| f.contains("Frequent")),
"a file changed once is not 'Frequent changes': {:?}",
p.contributing_factors
);
}
}
#[tokio::test]
async fn test_churn_is_a_property_of_the_file_not_of_the_scope() {
let temp = TempDir::new().unwrap();
let root = temp.path();
init_repo(root);
let sub = root.join("sub/deep");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("cold.rs"), "fn cold() {}\n").unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap();
assert!(out.status.success());
};
git(&["add", "."]);
git(&["commit", "-m", "one"]);
for i in 0..8 {
std::fs::write(root.join("hot.rs"), format!("// rev {i}\nfn hot() {{}}\n")).unwrap();
git(&["add", "."]);
git(&["commit", "-m", "hot"]);
}
let from_root = facade().analyze_project(request(root, 0)).await.unwrap();
let from_sub = facade().analyze_project(request(&sub, 0)).await.unwrap();
let cold_at = |r: &DefectPredictionResult| {
r.predictions
.iter()
.find(|p| p.file_path.ends_with("cold.rs"))
.expect("cold.rs must be predicted")
.clone()
};
let a = cold_at(&from_root);
let b = cold_at(&from_sub);
assert_eq!(
a.metrics.churn_score, b.metrics.churn_score,
"the same file must score the same churn whatever -p is used"
);
assert!(
(a.defect_probability - b.defect_probability).abs() < f32::EPSILON,
"defect probability moved with the analysis scope: {} vs {}",
a.defect_probability,
b.defect_probability
);
}
#[test]
fn test_churn_observation_score_is_absolute() {
let one = ChurnObservation {
commits: 1,
changed_lines: 10,
};
let many = ChurnObservation {
commits: 20,
changed_lines: 1000,
};
assert!(one.score() < 0.1, "1 commit scored {}", one.score());
assert!((many.score() - 1.0).abs() < f32::EPSILON);
let absurd = ChurnObservation {
commits: 10_000,
changed_lines: 10_000_000,
};
assert!((absurd.score() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_count_imports_is_none_for_unknown_languages() {
assert_eq!(count_imports(Path::new("a.txt"), "import os\n"), None);
assert_eq!(
count_imports(Path::new("a.py"), "import os\nimport os\n"),
Some(1)
);
assert_eq!(
count_imports(Path::new("a.rs"), "use a;\nuse b;\nfn f() {}\n"),
Some(2)
);
}
}