use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::projects::{ProjectGraph, Reach};
use crate::report_helpers;
const WORKSPACE: &str = "workspace";
#[derive(Debug, Clone, PartialEq)]
pub struct ReachRow {
pub project: String,
pub origin: String,
pub distance: usize,
pub via: Option<String>,
pub scope: &'static str,
}
#[derive(Debug, Default, PartialEq)]
pub struct ProjectRadius {
pub all: Vec<String>,
pub changed: Vec<String>,
pub reached: Vec<ReachRow>,
pub outside: Vec<PathBuf>,
pub unread: Vec<(PathBuf, usize)>,
pub inert: Vec<PathBuf>,
}
impl ProjectRadius {
pub fn total(&self) -> usize {
self.all.len()
}
pub fn direct(&self) -> usize {
self.reached.iter().filter(|r| r.distance == 1).count()
}
pub fn affected(&self) -> Vec<&str> {
if !self.outside.is_empty() {
return self.all.iter().map(String::as_str).collect();
}
let affected: BTreeSet<&str> = self
.changed
.iter()
.map(String::as_str)
.chain(self.reached.iter().map(|r| r.project.as_str()))
.collect();
affected.into_iter().collect()
}
}
fn display(dir: &Path) -> String {
if dir.as_os_str().is_empty() {
".".to_string()
} else {
dir.display().to_string()
}
}
#[derive(Default)]
struct Landing<'a> {
projects: BTreeSet<usize>,
workspaces: BTreeSet<&'a Path>,
outside: BTreeSet<PathBuf>,
}
impl<'a> Landing<'a> {
fn of(graph: &'a ProjectGraph, files: impl Iterator<Item = &'a Path>) -> Self {
let mut landing = Self::default();
for file in files {
let workspace = graph.workspace_of(file);
let project = graph.owner(file);
landing.workspaces.extend(workspace);
landing.projects.extend(project);
if workspace.is_none() && project.is_none() {
landing.outside.insert(file.to_path_buf());
}
}
landing
}
fn members(&self, graph: &ProjectGraph) -> BTreeMap<usize, &'a Path> {
let mut members = BTreeMap::new();
for &workspace in &self.workspaces {
for project in graph.members(workspace) {
if !self.projects.contains(&project) {
members.entry(project).or_insert(workspace);
}
}
}
members
}
}
pub fn compute<'a>(
graph: &'a ProjectGraph,
changed_files: impl Iterator<Item = &'a Path>,
) -> ProjectRadius {
let landing = Landing::of(graph, changed_files);
let members = landing.members(graph);
let root = |project: usize| graph.projects[project].display_root();
let seeds: BTreeSet<usize> = landing
.projects
.iter()
.chain(members.keys())
.copied()
.collect();
let through = |r: Reach| match members.get(&r.origin) {
Some(workspace) => ReachRow {
project: root(r.project),
origin: display(workspace),
distance: r.distance + 1,
via: Some(root(r.via)),
scope: r.scope.label(),
},
None => ReachRow {
project: root(r.project),
origin: root(r.origin),
distance: r.distance,
via: (r.via != r.origin).then(|| root(r.via)),
scope: r.scope.label(),
},
};
let mut reached: Vec<ReachRow> = members
.iter()
.map(|(&project, workspace)| ReachRow {
project: root(project),
origin: display(workspace),
distance: 1,
via: None,
scope: WORKSPACE,
})
.chain(graph.radius(&seeds).into_iter().map(through))
.collect();
reached.sort_by(|a, b| (a.distance, &a.project).cmp(&(b.distance, &b.project)));
let mut all: Vec<String> = (0..graph.projects.len()).map(root).collect();
all.sort();
ProjectRadius {
all,
changed: landing.projects.into_iter().map(root).collect(),
reached,
outside: landing.outside.into_iter().collect(),
unread: graph.unread.clone(),
inert: Vec::new(),
}
}
const TITLE: &str = "Blast radius — projects reached through their manifests";
const HEADERS: [&str; 5] = ["Changed", "Reaches", "Distance", "Scope", "Via"];
fn cells(row: &ReachRow) -> [String; 5] {
[
row.origin.clone(),
row.project.clone(),
row.distance.to_string(),
row.scope.to_string(),
row.via.clone().unwrap_or_default(),
]
}
fn table(rows: &[ReachRow]) -> Vec<String> {
let rows: Vec<[String; 5]> = std::iter::once(HEADERS.map(str::to_string))
.chain(rows.iter().map(cells))
.collect();
let width = |col: usize| {
rows.iter()
.map(|r| report_helpers::display_width(&r[col]))
.max()
.unwrap_or(0)
};
let widths = [width(0), width(1), width(2), width(3)];
rows.iter()
.map(|[origin, project, distance, scope, via]| {
format!(
" {} {} {distance:>dw$} {} {via}",
report_helpers::pad_to(origin, widths[0]),
report_helpers::pad_to(project, widths[1]),
report_helpers::pad_to(scope, widths[3]),
dw = widths[2],
)
.trim_end()
.to_string()
})
.collect()
}
fn changed_or_workspace(radius: &ProjectRadius) -> String {
if radius.changed.is_empty() {
return "no project (the workspace root)".to_string();
}
radius.changed.join(", ")
}
fn body(radius: &ProjectRadius) -> Vec<String> {
let total = radius.total();
let one_line = |line: String| vec![line];
if total == 0 {
return vec![
" No project manifests found (Cargo.toml, package.json, mix.exs, go.mod,".to_string(),
" pyproject.toml).".to_string(),
];
}
if total == 1 {
return one_line(format!(
" Single project ({}): no other project to reach; this level does not apply.",
radius.all[0]
));
}
if radius.reached.is_empty() && radius.changed.is_empty() {
return one_line(" No project changed.".to_string());
}
if radius.reached.is_empty() {
return one_line(format!(
" 0 of {total} projects reached: nothing depends on {}.",
radius.changed.join(", ")
));
}
let share = 100.0 * radius.reached.len() as f64 / total as f64;
let mut lines = vec![
format!(
" {} of {total} projects reached ({share:.0}%), {} direct",
radius.reached.len(),
radius.direct()
),
format!(" Changed: {}", changed_or_workspace(radius)),
String::new(),
];
lines.extend(table(&radius.reached));
lines
}
fn notes(radius: &ProjectRadius) -> Vec<String> {
let mut notes = Vec::new();
if !radius.outside.is_empty() {
let files: Vec<String> = radius
.outside
.iter()
.map(|p| p.display().to_string())
.collect();
notes.push(format!(
"Changed files outside every project (reach unknown): {}",
files.join(", ")
));
}
if !radius.inert.is_empty() {
notes.push(format!(
"Changed files that reach nothing (documentation, or configured as inert): {}",
radius.inert.len()
));
}
if !radius.unread.is_empty() {
let manifests: Vec<String> = radius
.unread
.iter()
.map(|(path, count)| format!("{} ({count})", path.display()))
.collect();
notes.push(format!(
"Local dependencies that could not be read: {}",
manifests.join(", ")
));
}
notes
}
pub fn render(radius: &ProjectRadius) -> Vec<String> {
let body = body(radius);
let width = body
.iter()
.map(|l| report_helpers::display_width(l))
.max()
.unwrap_or(0)
.max(78);
let sep = report_helpers::separator(width);
let mut lines = vec![TITLE.to_string(), sep.clone()];
lines.extend(body);
lines.push(sep);
lines.extend(notes(radius));
lines
}
#[derive(Serialize)]
struct JsonReach {
project: String,
origin: String,
distance: usize,
via: Option<String>,
scope: &'static str,
}
#[derive(Serialize)]
struct JsonUnread {
manifest: String,
dependencies: usize,
}
#[derive(Serialize)]
pub struct JsonProjects {
total: usize,
changed: Vec<String>,
reached: Vec<JsonReach>,
direct: usize,
affected: Vec<String>,
outside: Vec<String>,
inert: Vec<String>,
unread: Vec<JsonUnread>,
}
impl From<&ProjectRadius> for JsonProjects {
fn from(radius: &ProjectRadius) -> Self {
Self {
total: radius.total(),
changed: radius.changed.clone(),
reached: radius
.reached
.iter()
.map(|r| JsonReach {
project: r.project.clone(),
origin: r.origin.clone(),
distance: r.distance,
via: r.via.clone(),
scope: if r.scope.is_empty() {
"runtime"
} else {
r.scope
},
})
.collect(),
direct: radius.direct(),
affected: radius.affected().into_iter().map(str::to_string).collect(),
outside: radius
.outside
.iter()
.map(|p| p.display().to_string())
.collect(),
inert: radius
.inert
.iter()
.map(|p| p.display().to_string())
.collect(),
unread: radius
.unread
.iter()
.map(|(path, count)| JsonUnread {
manifest: path.display().to_string(),
dependencies: *count,
})
.collect(),
}
}
}
#[cfg(test)]
#[path = "projects_test.rs"]
mod tests;