use std::collections::{BTreeMap, BTreeSet};
use crate::graph::{
AnalysisResult, AnalysisTarget, GraphNode, NodeKind, RootImpact, Workspace, compute_tier,
package_key,
};
use super::theme::{RiskTier, Theme};
mod cascade;
use cascade::{format_mode, is_leaf, render_cascade};
pub(super) fn render_tree(result: &AnalysisResult, verbose: bool) -> String {
let theme = Theme::detect();
let assessment = assess(result);
let mut lines = Vec::new();
for line in theme.banner() {
lines.push(line);
}
lines.push(format!(
" {}",
theme.muted(&format!(
"impact analysis · {} mode",
format_mode(&result.mode)
))
));
lines.push(String::new());
let multi = result.roots.len() > 1;
if multi {
let header = format!(
" {}",
theme.subject(&format!("{} input files", result.roots.len()))
);
lines.push(header);
} else {
lines.push(format!(
" {} {}",
theme.subject(&format_subject(&result.target)),
theme.path(&relative_target(result))
));
}
lines.push(String::new());
if assessment.affected == 0 {
lines.push(format!(
" {} {}",
theme.risk_pill(RiskTier::Minor),
theme.subject("nothing depends on this — safe to change")
));
} else {
let aggregate = if multi {
format!(" (across all {} inputs)", result.roots.len())
} else {
String::new()
};
lines.push(format!(
" {} {} {}{}",
theme.risk_pill(assessment.tier),
theme.meter(assessment.tier),
theme.subject(&format!(
"{} impacted file{} · {} package{}",
assessment.affected,
plural(assessment.affected),
assessment.packages,
plural(assessment.packages)
)),
theme.muted(&aggregate)
));
lines.push(format!(
" {}",
theme.muted(&format!(
"{} direct, {} indirect · depth {} · {} endpoint{}",
result.summary.directly_affected_files,
result.summary.transitively_affected_files,
assessment.max_depth,
assessment.leaves,
plural(assessment.leaves),
))
));
}
if multi {
lines.push(String::new());
lines.push(theme.rule(&format!("impact by input file · {}", result.roots.len())));
for (index, root) in result.roots.iter().enumerate() {
if index > 0 {
lines.push(String::new());
}
render_root_block(root, &result.workspaces, &theme, &mut lines);
}
} else {
let groups = group_by_package(result);
if !groups.is_empty() {
lines.push(String::new());
lines.push(theme.rule(&format!(
"impacted files · {} in {} package{}",
assessment.affected,
assessment.packages,
plural(assessment.packages)
)));
render_package_groups(&groups, 2, &theme, &mut lines);
}
}
if !result.warnings.is_empty() {
lines.push(String::new());
lines.push(theme.rule("warnings"));
for warning in &result.warnings {
lines.push(format!(" {} {}", theme.warn("!"), theme.warn(warning)));
}
}
if verbose {
render_cascade(result, &theme, &mut lines);
}
lines.push(String::new());
let mut footer = format!(
"{} · {} scanned",
confidence_tag(&assessment, &theme),
theme.muted(&format!("{} files", result.source_file_count)),
);
if !verbose && assessment.affected > 0 {
footer.push_str(&format!(" · {}", theme.muted("-v for full cascade")));
}
lines.push(format!(" {footer}"));
lines.join("\n")
}
struct Assessment {
tier: RiskTier,
affected: usize,
packages: usize,
leaves: usize,
max_depth: usize,
ambiguous: usize,
unresolved: usize,
parse_failures: usize,
}
fn assess(result: &AnalysisResult) -> Assessment {
let affected_nodes: Vec<&GraphNode> = result
.nodes
.iter()
.filter(|node| node.kind == NodeKind::File && node.depth >= 1)
.collect();
let affected = affected_nodes.len();
let max_depth = affected_nodes
.iter()
.map(|node| node.depth)
.max()
.unwrap_or(0);
let leaves = affected_nodes
.iter()
.filter(|node| is_leaf(&node.id, result))
.count();
let mut package_keys = BTreeSet::new();
for node in &affected_nodes {
package_keys.insert(package_key(&node.label, &result.workspaces));
}
let packages = package_keys.len();
let ambiguous = result.edges.iter().filter(|edge| edge.is_ambiguous).count();
Assessment {
tier: result.summary.risk_tier,
affected,
packages,
leaves,
max_depth,
ambiguous,
unresolved: result.summary.unresolved_imports,
parse_failures: result.summary.parse_failures,
}
}
fn relative_target(result: &AnalysisResult) -> String {
let file = match &result.target {
AnalysisTarget::Export { file, .. } => Some(file),
AnalysisTarget::File { file } => Some(file),
AnalysisTarget::Files { files } => files.first(),
};
file.map(|file| {
file.strip_prefix(&result.repo_root)
.unwrap_or(file)
.display()
.to_string()
})
.unwrap_or_default()
}
fn format_subject(target: &AnalysisTarget) -> String {
match target {
AnalysisTarget::Export { export_name, .. } => export_name.clone(),
AnalysisTarget::File { file } => file
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("this file")
.to_string(),
AnalysisTarget::Files { files } => match files.split_first() {
Some((only, [])) => only
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("this file")
.to_string(),
_ => format!("{} files", files.len()),
},
}
}
struct ImpactedFile {
path: String,
endpoint: bool,
}
fn render_root_block(
root: &RootImpact,
workspaces: &[Workspace],
theme: &Theme,
lines: &mut Vec<String>,
) {
let tier = compute_tier(root.affected, root.packages);
let reach = if root.affected == 0 {
"no dependents — safe to change".to_string()
} else {
format!(
"{} file{} impacted · depth {}",
root.affected,
plural(root.affected),
root.max_depth
)
};
lines.push(format!(
" {} {} {}",
theme.tier_dot(tier),
theme.subject(&root.file),
theme.muted(&format!("— {reach}")),
));
let groups = group_files(
root.files.iter().map(|file| ImpactedFile {
path: file.path.clone(),
endpoint: file.endpoint,
}),
workspaces,
);
render_package_groups(&groups, 4, theme, lines);
}
fn render_package_groups(
groups: &[PackageGroup],
indent: usize,
theme: &Theme,
lines: &mut Vec<String>,
) {
let pad = " ".repeat(indent);
for group in groups {
lines.push(format!(
"{pad}{} {}",
theme.pkg(&group.label),
theme.count(&format!("({})", group.files.len()))
));
for file in group.files.iter().take(FILES_PER_PACKAGE) {
let marker = if file.endpoint {
format!(" {}", theme.endpoint("◎ endpoint"))
} else {
String::new()
};
lines.push(format!("{pad} {}{}", theme.path(&file.path), marker));
}
if group.files.len() > FILES_PER_PACKAGE {
lines.push(format!(
"{pad} {}",
theme.muted(&format!("+{} more", group.files.len() - FILES_PER_PACKAGE))
));
}
}
}
struct PackageGroup {
label: String,
files: Vec<ImpactedFile>,
}
const FILES_PER_PACKAGE: usize = 12;
fn group_by_package(result: &AnalysisResult) -> Vec<PackageGroup> {
let files = result
.nodes
.iter()
.filter(|node| node.kind == NodeKind::File && node.depth >= 1)
.map(|node| ImpactedFile {
path: node.label.clone(),
endpoint: is_leaf(&node.id, result),
});
group_files(files, &result.workspaces)
}
fn group_files(
files: impl IntoIterator<Item = ImpactedFile>,
workspaces: &[Workspace],
) -> Vec<PackageGroup> {
let mut buckets: BTreeMap<String, Vec<ImpactedFile>> = BTreeMap::new();
for file in files {
let label = package_key(&file.path, workspaces);
buckets.entry(label).or_default().push(file);
}
let mut groups: Vec<PackageGroup> = buckets
.into_iter()
.map(|(label, mut files)| {
files.sort_by(|a, b| a.path.cmp(&b.path));
files.dedup_by(|a, b| a.path == b.path);
PackageGroup { label, files }
})
.collect();
groups.sort_by(|a, b| {
b.files
.len()
.cmp(&a.files.len())
.then(a.label.cmp(&b.label))
});
groups
}
fn confidence_tag(assessment: &Assessment, theme: &Theme) -> String {
let on_path_clean = assessment.affected == 0 || assessment.ambiguous == 0;
let mut tag = if on_path_clean {
format!("{} {}", theme.ok("●"), theme.muted("confidence: high"))
} else {
format!(
"{} {}",
theme.warn("●"),
theme.warn(&format!(
"confidence: partial · {} ambiguous edge{} on these paths",
assessment.ambiguous,
plural(assessment.ambiguous)
))
)
};
if assessment.affected > 0 && assessment.unresolved > 0 {
tag.push_str(&theme.muted(&format!(
" · {} unresolved import{} repo-wide may hide consumers",
assessment.unresolved,
plural(assessment.unresolved)
)));
}
if assessment.parse_failures > 0 {
tag.push_str(&theme.muted(&format!(
" · {} parse failure{} caused skipped file{} repo-wide and may hide consumers",
assessment.parse_failures,
plural(assessment.parse_failures),
plural(assessment.parse_failures)
)));
}
tag
}
fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}