use std::path::{Path, PathBuf};
use serde::Serialize;
use super::structural::{Dependent, Exposure, Narrowing, Structural};
const TITLE: &str = "Structural radius — source files that use what changed";
fn names(paths: impl Iterator<Item = impl AsRef<Path>>, limit: usize) -> String {
let all: Vec<String> = paths.map(|p| p.as_ref().display().to_string()).collect();
let shown = all[..all.len().min(limit)].join(", ");
match all.len().saturating_sub(limit) {
0 => shown,
more => format!("{shown} and {more} more"),
}
}
fn dependent_lines(radius: &Structural, top: usize) -> Vec<String> {
let exposed: Vec<&Dependent> = radius.exposed().collect();
let mut lines = vec![" Tests Dependent".to_string()];
for dependent in exposed.iter().take(top) {
let tests = match dependent.tests {
0 => "none".to_string(),
n => n.to_string(),
};
lines.push(format!(" {tests:>5} {}", dependent.file.display()));
if dependent.exposure == Exposure::Refers {
lines.push(" refers to the module without calling it".to_string());
} else if !dependent.calls.is_empty() {
lines.push(format!(
" calls {}",
names(dependent.calls.iter(), 3)
));
}
}
if exposed.len() > top {
lines.push(format!(" {} files ({top} shown).", exposed.len()));
}
lines
}
pub(super) fn reach_line(radius: &Structural) -> String {
let near: Vec<String> = radius
.by_distance
.iter()
.take(3)
.enumerate()
.map(|(i, files)| format!("{} at distance {}", files, i + 1))
.collect();
let further: usize = radius.by_distance.iter().skip(3).sum();
match further {
0 => near.join(", "),
_ => format!(
"{}, {further} further (up to distance {})",
near.join(", "),
radius.by_distance.len()
),
}
}
fn unnarrowed(radius: &Structural) -> String {
let why: Vec<String> = radius
.narrowing
.iter()
.filter_map(|n| Some(format!("{} ({})", n.file.display(), n.reason.as_ref()?)))
.collect();
names(why.iter(), 3)
}
pub(super) fn summary(radius: &Structural) -> Vec<String> {
let total = radius.source_files.max(1) as f64;
let share = |files: usize| match 100.0 * files as f64 / total {
pct if pct > 0.0 && pct < 0.5 => "<1%".to_string(),
pct => format!("{pct:.0}%"),
};
let reached = radius.reached();
let changed = format!(" Changed: {}", names(radius.origins.iter(), 5));
let unprotected = radius.unprotected().count();
let Some(functions) = &radius.functions else {
return vec![
format!(
" {reached} of {} source files reached ({}), {} direct, {unprotected} of them with no test",
radius.source_files,
share(reached),
radius.direct.len()
),
changed,
format!(" Every use counts: {}", unnarrowed(radius)),
format!(" Reach: {}", reach_line(radius)),
];
};
let calling = radius
.direct
.iter()
.filter(|d| d.exposure == Exposure::Calls)
.count();
let radius_line = match reached {
0 => format!(" Radius: 0 of {} source files", radius.source_files),
_ => format!(
" Radius: {reached} of {} source files ({}): {}",
radius.source_files,
share(reached),
reach_line(radius)
),
};
vec![
format!(
" {}, {unprotected} of them with no test",
match calling {
1 => "1 file calls what changed".to_string(),
n => format!("{n} files call what changed"),
}
),
changed,
format!(" Functions: {}", names(functions.iter(), 8)),
]
.into_iter()
.chain(
radius
.narrowing
.iter()
.any(|n| n.functions.is_none())
.then(|| format!(" Every use counts for: {}", unnarrowed(radius))),
)
.chain([
radius_line,
format!(
" Upper bound, whatever the function: {} files ({})",
radius.upper_bound,
share(radius.upper_bound)
),
])
.collect()
}
fn body(radius: &Structural, top: usize) -> Vec<String> {
if radius.origins.is_empty() {
return vec![
" No changed source file in a language with a reliable graph".to_string(),
" (Elixir, JavaScript/TypeScript, Kaikai).".to_string(),
];
}
if radius.direct.is_empty() {
return vec![
format!(" 0 of {} source files reached.", radius.source_files),
format!(" Changed: {}", names(radius.origins.iter(), 5)),
" No source file measured uses what changed.".to_string(),
];
}
let mut lines = summary(radius);
if radius.exposed().next().is_none() {
lines.push(" No other file calls the functions that changed.".to_string());
return lines;
}
lines.push(String::new());
lines.extend(dependent_lines(radius, top));
lines
}
fn warnings(radius: &Structural) -> Vec<String> {
let mut lines = Vec::new();
let unprotected: Vec<&Path> = radius.unprotected().map(|d| d.file.as_path()).collect();
if !unprotected.is_empty() {
lines.push(format!(
"No test refers to {} of the files that call what changed; an integration test is probably missing:",
unprotected.len()
));
lines.extend(
unprotected
.iter()
.take(5)
.map(|p| format!(" {}", p.display())),
);
if unprotected.len() > 5 {
lines.push(format!(" and {} more", unprotected.len() - 5));
}
}
let exercised = radius.exposed().any(|d| d.tests_in_diff > 0);
if radius.exposed().next().is_some() && !exercised {
lines.push("No test in the change exercises a file that uses what changed.".to_string());
}
let unknown = radius
.direct
.iter()
.filter(|d| d.exposure == Exposure::Refers && d.tests == 0)
.count();
if unknown > 0 {
lines.push(format!(
"{unknown} more with no test use the module without a call that tells whether the change concerns them."
));
}
lines.extend(radius.unavailable.iter().map(|(language, files)| {
format!(
"Not measured for {language} ({files} changed): its graph does not reflect usage yet."
)
}));
if radius.ambiguous > 0 {
lines.push(format!(
"Module references left out for naming more than one file: {}",
radius.ambiguous
));
}
lines
}
pub fn render(radius: &Structural, top: usize) -> Vec<String> {
let sep = crate::report_helpers::separator(78);
let mut lines = vec![TITLE.to_string(), sep.clone()];
lines.extend(body(radius, top));
lines.push(sep);
lines.extend(warnings(radius));
lines
}
#[derive(Serialize)]
struct JsonUnavailable<'a> {
language: &'a str,
files: usize,
}
#[derive(Serialize)]
struct JsonRadius {
files: usize,
source_files: usize,
share: f64,
}
#[derive(Serialize)]
struct JsonReach<'a> {
distance: usize,
files: &'a [PathBuf],
}
#[derive(Serialize)]
pub struct JsonStructural<'a> {
source_files: usize,
origins: Vec<String>,
functions: Option<&'a [String]>,
narrowing: &'a [Narrowing],
radius: JsonRadius,
reached: usize,
by_distance: &'a [usize],
reach: Vec<JsonReach<'a>>,
upper_bound: usize,
direct: &'a [Dependent],
unprotected: Vec<String>,
unknown_without_tests: Vec<String>,
change_tests_a_dependent: bool,
unavailable: Vec<JsonUnavailable<'a>>,
ambiguous: usize,
}
impl<'a> From<&'a Structural> for JsonStructural<'a> {
fn from(radius: &'a Structural) -> Self {
let text = |p: &PathBuf| p.display().to_string();
let reached = radius.reached();
let share = reached as f64 / radius.source_files.max(1) as f64;
Self {
source_files: radius.source_files,
origins: radius.origins.iter().map(text).collect(),
functions: radius.functions.as_deref(),
narrowing: &radius.narrowing,
radius: JsonRadius {
files: reached,
source_files: radius.source_files,
share: (share * 10_000.0).round() / 10_000.0,
},
reached,
by_distance: &radius.by_distance,
reach: radius
.reached_files
.iter()
.enumerate()
.map(|(i, files)| JsonReach {
distance: i + 1,
files,
})
.collect(),
upper_bound: radius.upper_bound,
direct: &radius.direct,
unprotected: radius.unprotected().map(|d| text(&d.file)).collect(),
unknown_without_tests: radius
.direct
.iter()
.filter(|d| d.exposure == Exposure::Refers && d.tests == 0)
.map(|d| text(&d.file))
.collect(),
change_tests_a_dependent: radius.exposed().any(|d| d.tests_in_diff > 0),
unavailable: radius
.unavailable
.iter()
.map(|(language, files)| JsonUnavailable {
language,
files: *files,
})
.collect(),
ambiguous: radius.ambiguous,
}
}
}