use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::rules::complexity;
use crate::ingest::{CrateInfo, EntryPointKind, Workspace};
pub const SCHEMA_VERSION: u32 = 3;
#[derive(Debug, Serialize)]
pub struct RefactorMap {
pub schema_version: u32,
pub includes_tests: bool,
pub crates: Vec<CrateSummary>,
pub files: Vec<FileSummary>,
pub duplication: crate::rules::duplication::RefactoringSummary,
pub analysis_errors: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct CrateSummary {
pub name: String,
pub source_files: usize,
pub authored_files: usize,
pub production: ComplexitySummary,
pub tests: ComplexitySummary,
}
#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
pub struct ComplexitySummary {
pub functions: usize,
pub total_cyclomatic: u32,
pub max_cyclomatic: u32,
}
impl ComplexitySummary {
fn add_function(&mut self, cyclomatic: u32) {
self.functions += 1;
self.total_cyclomatic += cyclomatic;
self.max_cyclomatic = self.max_cyclomatic.max(cyclomatic);
}
fn combined(self, other: Self) -> Self {
Self {
functions: self.functions + other.functions,
total_cyclomatic: self.total_cyclomatic + other.total_cyclomatic,
max_cyclomatic: self.max_cyclomatic.max(other.max_cyclomatic),
}
}
}
#[derive(Debug, Serialize)]
pub struct FileSummary {
pub crate_name: String,
pub file: PathBuf,
pub source_kind: &'static str,
pub production: ComplexitySummary,
pub tests: ComplexitySummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub complexity_rank: Option<usize>,
}
impl FileSummary {
pub fn complexity_in_scope(&self, include_tests: bool) -> ComplexitySummary {
if include_tests {
self.production.combined(self.tests)
} else {
self.production
}
}
}
#[derive(Debug, Default)]
struct FileMetrics {
production: ComplexitySummary,
tests: ComplexitySummary,
}
pub fn analyze(workspace: &Workspace, include_tests: bool) -> RefactorMap {
let source_files = workspace
.crates
.iter()
.flat_map(|krate| krate.source_files.iter());
let complexity = complexity::analyze_workspace(source_files, false);
let duplication_source_files = workspace
.crates
.iter()
.flat_map(|krate| krate.source_files.iter());
let duplication = crate::rules::duplication::analyze_workspace_with_options(
duplication_source_files,
crate::rules::duplication::DupeMode::Mild,
crate::rules::duplication::DEFAULT_MIN_TOKENS,
false,
include_tests,
);
let duplication_summary = duplication.refactoring_summary(&workspace.root, 5);
let mut analysis_errors: Vec<String> =
complexity.errors.iter().map(ToString::to_string).collect();
analysis_errors.extend(duplication.errors.iter().map(ToString::to_string));
let test_source_files = workspace
.crates
.iter()
.flat_map(|krate| {
krate
.source_files
.iter()
.filter(|source_file| is_test_source_file(krate, &source_file.path))
.map(|source_file| source_file.path.clone())
})
.collect::<HashSet<_>>();
let mut metrics = HashMap::<PathBuf, FileMetrics>::new();
for function in complexity.functions {
let entry = metrics.entry(function.file.clone()).or_default();
if function.is_test_context || test_source_files.contains(&function.file) {
entry.tests.add_function(function.cyclomatic);
} else {
entry.production.add_function(function.cyclomatic);
}
}
let mut files = Vec::new();
let mut crates = Vec::new();
for krate in &workspace.crates {
let mut production = ComplexitySummary::default();
let mut tests = ComplexitySummary::default();
let mut authored_files = 0;
for source_file in &krate.source_files {
let metrics = metrics.remove(&source_file.path).unwrap_or_default();
if source_file.kind.is_locally_reportable() {
authored_files += 1;
}
production = production.combined(metrics.production);
tests = tests.combined(metrics.tests);
files.push(FileSummary {
crate_name: krate.name.clone(),
file: relative_path(&workspace.root, &source_file.path),
source_kind: source_file.kind.label(),
production: metrics.production,
tests: metrics.tests,
complexity_rank: None,
});
}
crates.push(CrateSummary {
name: krate.name.clone(),
source_files: krate.source_files.len(),
authored_files,
production,
tests,
});
}
files.sort_by(|left, right| {
right
.complexity_in_scope(include_tests)
.total_cyclomatic
.cmp(&left.complexity_in_scope(include_tests).total_cyclomatic)
.then_with(|| left.file.cmp(&right.file))
});
for (index, file) in files
.iter_mut()
.filter(|file| {
file.source_kind == "authored" && file.complexity_in_scope(include_tests).functions > 0
})
.enumerate()
{
file.complexity_rank = Some(index + 1);
}
crates.sort_by(|left, right| left.name.cmp(&right.name));
RefactorMap {
schema_version: SCHEMA_VERSION,
includes_tests: include_tests,
crates,
files,
duplication: duplication_summary,
analysis_errors,
}
}
fn is_test_source_file(krate: &CrateInfo, path: &Path) -> bool {
krate.entry_points.iter().any(|entry| {
entry.path == path && matches!(entry.kind, EntryPointKind::Test | EntryPointKind::Bench)
}) || matches!(
path.strip_prefix(&krate.root)
.ok()
.and_then(|relative| relative.components().next()),
Some(std::path::Component::Normal(name)) if name == "tests" || name == "benches"
)
}
fn relative_path(root: &Path, path: &Path) -> PathBuf {
path.strip_prefix(root).unwrap_or(path).to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ingest::{CrateInfo, SourceFile, SourceKind};
fn workspace(root: &Path) -> Workspace {
Workspace {
root: root.to_path_buf(),
crates: vec![CrateInfo {
name: "fixture".to_string(),
version: "0.1.0".to_string(),
manifest_path: root.join("Cargo.toml"),
root: root.to_path_buf(),
source_files: vec![
SourceFile {
path: root.join("src/high.rs"),
kind: SourceKind::Authored,
},
SourceFile {
path: root.join("src/low.rs"),
kind: SourceKind::Authored,
},
SourceFile {
path: root.join("src/generated.rs"),
kind: SourceKind::Generated,
},
],
entry_points: Vec::new(),
dependencies: Vec::new(),
}],
}
}
#[test]
fn separates_test_context_from_production_ranking() {
let root = crate::test_util::TempDir::new("refactor-map");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(
root.join("src/high.rs"),
r#"
fn production(x: bool, y: bool) { if x { if y {} } }
#[cfg(test)]
mod tests {
fn expensive_helper(x: bool, y: bool, z: bool) {
if x { if y { if z {} } }
}
}
"#,
)
.unwrap();
std::fs::write(root.join("src/low.rs"), "fn low() {}").unwrap();
std::fs::write(root.join("src/generated.rs"), "fn generated() {}").unwrap();
let production_map = analyze(&workspace(&root), false);
assert!(production_map.analysis_errors.is_empty());
assert!(!production_map.includes_tests);
assert_eq!(production_map.duplication.clone_families, 0);
assert_eq!(production_map.crates[0].production.functions, 2);
assert_eq!(production_map.crates[0].tests.functions, 1);
assert_eq!(production_map.files[0].file, PathBuf::from("src/high.rs"));
assert_eq!(production_map.files[0].complexity_rank, Some(1));
assert_eq!(production_map.files[0].production.total_cyclomatic, 3);
assert_eq!(production_map.files[0].tests.total_cyclomatic, 4);
let generated = production_map
.files
.iter()
.find(|file| file.file == Path::new("src/generated.rs"))
.unwrap();
assert_eq!(generated.complexity_rank, None);
assert_eq!(generated.production.functions, 0);
let test_aware_map = analyze(&workspace(&root), true);
assert!(test_aware_map.includes_tests);
assert_eq!(test_aware_map.files[0].complexity_rank, Some(1));
assert_eq!(
test_aware_map.files[0]
.complexity_in_scope(true)
.total_cyclomatic,
7
);
}
#[test]
fn includes_compact_production_duplication_facts() {
let root = crate::test_util::TempDir::new("refactor-map-duplication");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(
root.join("src/high.rs"),
r#"
fn first() {
let mut total = 0;
for value in 0..10 { total += value; }
if total > 0 { total -= 1; }
let _ = total;
}
fn second() {
let mut total = 0;
for value in 0..10 { total += value; }
if total > 0 { total -= 1; }
let _ = total;
}
"#,
)
.unwrap();
std::fs::write(root.join("src/low.rs"), "fn low() {}\n").unwrap();
std::fs::write(root.join("src/generated.rs"), "fn generated() {}\n").unwrap();
let map = analyze(&workspace(&root), false);
assert_eq!(map.schema_version, SCHEMA_VERSION);
assert_eq!(map.duplication.schema_version, 1);
assert_eq!(map.duplication.clone_families, 1);
assert_eq!(map.duplication.clone_members, 2);
assert_eq!(
map.duplication.top_families[0].files,
[PathBuf::from("src/high.rs")]
);
}
}