use std::path::Path;
use std::process::ExitCode;
use fallow_config::OutputFormat;
use fallow_output::{EntryWeightDelta, EntryWeightListing, EntryWeightRegression};
use rustc_hash::{FxHashMap, FxHashSet};
use super::counts::{REGRESSION_SCHEMA_VERSION, RegressionBaseline};
use super::tolerance::Tolerance;
use crate::error::emit_error;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct EntryWeightCounts {
#[serde(default)]
pub entries: Vec<EntryWeightBaselineEntry>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EntryWeightBaselineEntry {
pub path: String,
pub eager_bytes: u64,
pub eager_modules: usize,
#[serde(default)]
pub eager_packages: Vec<String>,
}
impl EntryWeightCounts {
#[must_use]
pub fn from_listing(listing: &EntryWeightListing) -> Self {
let mut entries: Vec<EntryWeightBaselineEntry> = listing
.entries
.iter()
.map(|entry| EntryWeightBaselineEntry {
path: entry.path.clone(),
eager_bytes: entry.eager_bytes,
eager_modules: entry.eager_modules,
eager_packages: entry
.eager_packages
.iter()
.map(|package| package.name.clone())
.collect(),
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
Self { entries }
}
}
pub struct EntryWeightGate<'a> {
pub fail_on_regression: bool,
pub tolerance: Tolerance,
pub baseline_file: Option<&'a Path>,
pub save_file: Option<&'a Path>,
pub save_to_config: bool,
}
impl EntryWeightGate<'_> {
pub fn validate(&self, output: OutputFormat) -> Result<(), ExitCode> {
if self.save_to_config {
return Err(emit_error(
"list --entry-weight saves its regression baseline to a file: pass \
--save-regression-baseline <PATH>",
2,
output,
));
}
if self.fail_on_regression && self.baseline_file.is_none() {
return Err(emit_error(
"list --entry-weight --fail-on-regression needs --regression-baseline <PATH>; \
create one with list --entry-weight --save-regression-baseline <PATH>",
2,
output,
));
}
Ok(())
}
}
pub fn load_entry_weight_baseline(
path: &Path,
output: OutputFormat,
) -> Result<EntryWeightCounts, ExitCode> {
let baseline = super::load_regression_baseline(path, output)?;
baseline.entry_weight.ok_or_else(|| {
emit_error(
&format!(
"regression baseline '{}' has no entry weight data; save one with \
fallow list --entry-weight --save-regression-baseline {}",
path.display(),
path.display()
),
2,
output,
)
})
}
#[must_use]
pub fn compare_entry_weight(
listing: &EntryWeightListing,
baseline: &EntryWeightCounts,
tolerance: Tolerance,
enforced: bool,
in_scope: impl Fn(&str) -> bool,
) -> EntryWeightRegression {
let saved: FxHashMap<&str, &EntryWeightBaselineEntry> = baseline
.entries
.iter()
.filter(|entry| in_scope(&entry.path))
.map(|entry| (entry.path.as_str(), entry))
.collect();
let mut entries: Vec<EntryWeightDelta> = listing
.entries
.iter()
.map(|current| {
let before = saved.get(current.path.as_str()).copied();
let exceeded = before.is_some_and(|before| {
tolerance.exceeded(to_usize(before.eager_bytes), to_usize(current.eager_bytes))
});
let known: FxHashSet<&str> = before
.map(|before| before.eager_packages.iter().map(String::as_str).collect())
.unwrap_or_default();
let new_eager_packages = if before.is_some() {
current
.eager_packages
.iter()
.filter(|package| !known.contains(package.name.as_str()))
.map(|package| package.name.clone())
.collect()
} else {
Vec::new()
};
EntryWeightDelta {
path: current.path.clone(),
baseline_eager_bytes: before.map(|before| before.eager_bytes),
current_eager_bytes: Some(current.eager_bytes),
baseline_eager_modules: before.map(|before| before.eager_modules),
current_eager_modules: Some(current.eager_modules),
new_eager_packages,
exceeded,
}
})
.collect();
let current_paths: FxHashSet<&str> = listing
.entries
.iter()
.map(|entry| entry.path.as_str())
.collect();
entries.extend(
saved
.values()
.filter(|before| !current_paths.contains(before.path.as_str()))
.map(|before| EntryWeightDelta {
path: before.path.clone(),
baseline_eager_bytes: Some(before.eager_bytes),
current_eager_bytes: None,
baseline_eager_modules: Some(before.eager_modules),
current_eager_modules: None,
new_eager_packages: Vec::new(),
exceeded: false,
}),
);
entries.sort_by(|a, b| a.path.cmp(&b.path));
EntryWeightRegression {
tolerance: tolerance.label(),
enforced,
exceeded: entries.iter().any(|entry| entry.exceeded),
entries,
}
}
fn to_usize(bytes: u64) -> usize {
usize::try_from(bytes).unwrap_or(usize::MAX)
}
pub fn save_entry_weight_baseline(
path: &Path,
root: &Path,
counts: EntryWeightCounts,
output: OutputFormat,
) -> Result<(), ExitCode> {
let existing = super::baseline::read_existing_baseline(path);
let baseline = RegressionBaseline {
schema_version: REGRESSION_SCHEMA_VERSION,
fallow_version: env!("CARGO_PKG_VERSION").to_string(),
timestamp: super::baseline::chrono_now(),
git_sha: super::baseline::current_git_sha(root),
analysis_identity: existing
.as_ref()
.map(|existing| existing.analysis_identity.clone())
.unwrap_or_default(),
check: existing
.as_ref()
.and_then(|existing| existing.check.clone()),
dupes: existing
.as_ref()
.and_then(|existing| existing.dupes.clone()),
entry_weight: Some(counts),
flags: existing.and_then(|existing| existing.flags),
};
super::baseline::write_regression_baseline(path, root, &baseline, output)
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_output::{EagerPackageOutput, EntryWeightOutput, EntryWeightUnit};
fn listing(rows: &[(&str, u64, &[&str])]) -> EntryWeightListing {
let entries: Vec<EntryWeightOutput> = rows
.iter()
.map(|&(path, eager_bytes, packages)| EntryWeightOutput {
path: path.to_string(),
source: "test".to_string(),
eager_modules: 1,
eager_bytes,
eager_css_bytes: 0,
deferred_modules: 0,
deferred_bytes: 0,
out_of_thread_modules: 0,
out_of_thread_bytes: 0,
eager_package_count: packages.len(),
eager_packages: packages
.iter()
.map(|name| EagerPackageOutput {
name: (*name).to_string(),
specifiers: vec![(*name).to_string()],
})
.collect(),
dominating_imports: Vec::new(),
})
.collect();
EntryWeightListing {
unit: EntryWeightUnit::SourceBytes,
entry_count: entries.len(),
entries,
regression: None,
}
}
#[test]
fn a_new_entry_is_reported_but_never_exceeds_and_a_gone_entry_is_listed() {
let before =
EntryWeightCounts::from_listing(&listing(&[("a.ts", 100, &[]), ("gone.ts", 5, &[])]));
let now = listing(&[("a.ts", 100, &[]), ("new.ts", 9_000, &["react"])]);
let regression =
compare_entry_weight(&now, &before, Tolerance::Absolute(0), true, |_| true);
assert!(!regression.exceeded);
let paths: Vec<&str> = regression.entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, ["a.ts", "gone.ts", "new.ts"]);
assert_eq!(regression.entries[1].current_eager_bytes, None);
assert_eq!(regression.entries[2].baseline_eager_bytes, None);
assert!(regression.entries[2].new_eager_packages.is_empty());
}
#[test]
fn saved_entries_outside_the_scope_are_not_reported() {
let before =
EntryWeightCounts::from_listing(&listing(&[("a.ts", 100, &[]), ("b/x.ts", 5, &[])]));
let now = listing(&[("a.ts", 101, &[])]);
let regression =
compare_entry_weight(&now, &before, Tolerance::Absolute(0), false, |path| {
path == "a.ts"
});
assert_eq!(regression.entries.len(), 1);
assert!(
regression.exceeded,
"one byte of growth exceeds a zero tolerance"
);
assert!(!regression.enforced);
}
}