use std::path::{Path, PathBuf};
use serde::Serialize;
use super::protection::Protection;
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"),
}
}
impl Dependent {
fn tests_cell(&self) -> String {
match self.protection {
Protection::Direct => self.tests.to_string(),
Protection::Named => "named".to_string(),
Protection::Users => "users".to_string(),
Protection::None => "none".to_string(),
}
}
fn exposed_by(&self) -> Option<String> {
match self.exposure {
Exposure::Refers => Some("refers to the module without calling it".to_string()),
_ if self.calls.is_empty() => None,
_ => Some(format!("calls {}", names(self.calls.iter(), 3))),
}
}
fn lines(&self) -> impl Iterator<Item = String> {
let path = format!(" {:>5} {}", self.tests_cell(), self.file.display());
let why = self.exposed_by().map(|why| format!(" {why}"));
std::iter::once(path).chain(why)
}
}
fn dependent_lines(radius: &Structural, top: usize) -> Vec<String> {
let exposed: Vec<&Dependent> = radius.exposed().collect();
let shown = &exposed[..exposed.len().min(top)];
let weaker = shown
.iter()
.any(|d| matches!(d.protection, Protection::Named | Protection::Users));
let mut lines = vec![" Tests Dependent".to_string()];
lines.extend(shown.iter().flat_map(|d| d.lines()));
lines.extend((exposed.len() > top).then(|| format!(" {} files ({top} shown).", exposed.len())));
lines.extend(weaker.then(|| {
" named: a test carries its name; users: only what uses it is tested".to_string()
}));
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)
}
impl Structural {
fn share(&self, files: usize) -> String {
match 100.0 * files as f64 / self.source_files.max(1) as f64 {
pct if pct > 0.0 && pct < 0.5 => "<1%".to_string(),
pct => format!("{pct:.0}%"),
}
}
fn count(&self, exposure: Exposure) -> usize {
self.direct
.iter()
.filter(|d| d.exposure == exposure)
.count()
}
fn changed_line(&self) -> String {
format!(" Changed: {}", names(self.origins.iter(), 5))
}
fn summary_of_every_use(&self) -> Vec<String> {
let reached = self.reached();
vec![
format!(
" {reached} of {} source files reached ({}), {} direct, {} of them with no test",
self.source_files,
self.share(reached),
self.direct.len(),
self.unprotected().count()
),
self.changed_line(),
format!(" Every use counts: {}", unnarrowed(self)),
format!(" Reach: {}", reach_line(self)),
]
}
fn radius_line(&self) -> String {
match self.reached() {
0 => format!(" Radius: 0 of {} source files", self.source_files),
reached => format!(
" Radius: {reached} of {} source files ({}): {}",
self.source_files,
self.share(reached),
reach_line(self)
),
}
}
fn summary_of_functions(&self, functions: &[String]) -> Vec<String> {
let calling = match self.count(Exposure::Calls) {
1 => "1 file calls what changed".to_string(),
n => format!("{n} files call what changed"),
};
let referring = self.count(Exposure::Refers);
let partly = self.narrowing.iter().any(|n| n.functions.is_none());
let mut lines = vec![
format!(
" {calling}, {} of them with no test",
self.unprotected().count()
),
self.changed_line(),
format!(" Functions: {}", names(functions.iter(), 8)),
];
lines.extend(partly.then(|| format!(" Every use counts for: {}", unnarrowed(self))));
lines.push(self.radius_line());
lines.extend((referring > 0).then(|| {
format!(" Not in the radius: {referring} that refer to the module without calling it")
}));
lines.push(format!(
" Upper bound, whatever the function: {} files ({})",
self.upper_bound,
self.share(self.upper_bound)
));
lines
}
}
pub(super) fn summary(radius: &Structural) -> Vec<String> {
match &radius.functions {
Some(functions) => radius.summary_of_functions(functions),
None => radius.summary_of_every_use(),
}
}
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 reaches {} 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.protection == Protection::None)
.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.protection == Protection::None)
.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,
}
}
}