use std::process::ExitCode;
use fallow_config::OutputFormat;
use crate::report::format_display_path;
use crate::runtime_support::{LoadConfigArgs, load_config};
use fallow_api::{BoundaryData, ListJsonEnvelope, ListJsonOutputInput};
use fallow_output::WorkspaceInfo;
pub struct ListOptions<'a> {
pub root: &'a std::path::Path,
pub config_path: &'a Option<std::path::PathBuf>,
pub output: OutputFormat,
pub json_style: crate::json_style::JsonStyle,
pub threads: usize,
pub no_cache: bool,
pub entry_points: bool,
pub files: bool,
pub plugins: bool,
pub boundaries: bool,
pub workspaces: bool,
pub entry_weight: bool,
pub entry_weight_gate: Option<crate::regression::EntryWeightGate<'a>>,
pub production: bool,
pub allow_remote_extends: bool,
pub scope: Option<std::path::PathBuf>,
}
struct ListData {
show_all: bool,
plugin_result: Option<fallow_engine::plugins::AggregatedPluginResult>,
discovered: Option<Vec<fallow_engine::discover::DiscoveredFile>>,
entry_points: Option<Vec<fallow_engine::discover::EntryPoint>>,
boundary_data: Option<BoundaryData>,
entry_weight: Option<fallow_output::EntryWeightListing>,
workspace_data: Option<WorkspaceData>,
}
pub fn run_list(opts: &ListOptions<'_>) -> ExitCode {
let config = match load_config(
opts.root,
opts.config_path,
LoadConfigArgs {
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
production: opts.production,
quiet: true, allow_remote_extends: opts.allow_remote_extends,
},
) {
Ok(c) => c,
Err(code) => return code,
};
let gate = opts
.entry_weight_gate
.as_ref()
.filter(|_| opts.entry_weight);
if let Some(gate) = gate
&& let Err(code) = validate_entry_weight_gate(opts, gate)
{
return code;
}
let mut data = match collect_list_data(opts, &config) {
Ok(data) => data,
Err(code) => return code,
};
let gate_failed = match (gate, data.entry_weight.as_mut()) {
(Some(gate), Some(listing)) => match apply_entry_weight_gate(opts, gate, listing) {
Ok(failed) => failed,
Err(code) => return code,
},
_ => false,
};
match opts.output {
OutputFormat::Json => print_list_json(&ListJsonInput {
opts,
show_all: data.show_all,
plugin_result: data.plugin_result.as_ref(),
discovered: data.discovered.as_deref(),
entry_points: data.entry_points.as_deref(),
boundary_data: data.boundary_data.as_ref(),
entry_weight: data.entry_weight.as_ref(),
workspace_data: data.workspace_data.as_ref(),
})
.map_or_else(|code| code, |()| gate_exit(gate_failed)),
_ => {
print_list_human(&ListHumanInput {
opts,
show_all: data.show_all,
plugin_result: data.plugin_result.as_ref(),
discovered: data.discovered.as_deref(),
entry_points: data.entry_points.as_deref(),
boundary_data: data.boundary_data.as_ref(),
entry_weight: data.entry_weight.as_ref(),
workspace_data: data.workspace_data.as_ref(),
});
gate_exit(gate_failed)
}
}
}
#[doc(hidden)]
pub fn benchmark_list_json(
root: &std::path::Path,
threads: usize,
) -> Result<(usize, usize, usize, usize), ExitCode> {
let config_path = None;
let opts = ListOptions {
root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
threads,
no_cache: true,
entry_points: false,
files: false,
plugins: false,
boundaries: false,
workspaces: false,
entry_weight: false,
entry_weight_gate: None,
production: false,
allow_remote_extends: false,
scope: None,
};
let (data, rendered_bytes) = benchmark_list_data_json(&opts)?;
let file_count = data.discovered.as_deref().map_or(0, <[_]>::len);
let entry_point_count = data.entry_points.as_deref().map_or(0, <[_]>::len);
let workspace_count = data
.workspace_data
.as_ref()
.map_or(0, |data| data.workspaces.len());
Ok((
file_count,
entry_point_count,
workspace_count,
rendered_bytes,
))
}
#[doc(hidden)]
pub fn benchmark_list_boundaries_json(
root: &std::path::Path,
threads: usize,
) -> Result<(usize, usize, usize, usize), ExitCode> {
let config_path = None;
let opts = ListOptions {
root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
threads,
no_cache: true,
entry_points: false,
files: false,
plugins: false,
boundaries: true,
workspaces: false,
entry_weight: false,
entry_weight_gate: None,
production: false,
allow_remote_extends: false,
scope: None,
};
let (data, rendered_bytes) = benchmark_list_data_json(&opts)?;
let Some(boundary_data) = data.boundary_data else {
return Err(ExitCode::from(2));
};
let matched_file_count = boundary_data.zones.iter().map(|zone| zone.file_count).sum();
Ok((
boundary_data.zones.len(),
boundary_data.rules.len(),
matched_file_count,
rendered_bytes,
))
}
fn benchmark_list_data_json(opts: &ListOptions<'_>) -> Result<(ListData, usize), ExitCode> {
let config = load_config(
opts.root,
opts.config_path,
LoadConfigArgs {
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
production: opts.production,
quiet: true,
allow_remote_extends: opts.allow_remote_extends,
},
)?;
let data = collect_list_data(opts, &config)?;
let rendered = render_list_json(&ListJsonInput {
opts,
show_all: data.show_all,
plugin_result: data.plugin_result.as_ref(),
discovered: data.discovered.as_deref(),
entry_points: data.entry_points.as_deref(),
boundary_data: data.boundary_data.as_ref(),
entry_weight: data.entry_weight.as_ref(),
workspace_data: data.workspace_data.as_ref(),
})
.map_err(|err| {
crate::error::emit_error(
&format!("failed to serialize list output: {err}"),
2,
OutputFormat::Json,
)
})?;
Ok((data, rendered.len()))
}
fn collect_list_data(
opts: &ListOptions<'_>,
config: &fallow_config::ResolvedConfig,
) -> Result<ListData, ExitCode> {
let show_all = should_show_all(opts);
let need_plugin_result = opts.plugins || opts.entry_points || opts.entry_weight || show_all;
let need_files = needs_file_discovery(opts.files, show_all, opts.entry_points, opts.boundaries)
|| opts.entry_weight;
let session = if need_files || need_plugin_result {
match fallow_engine::session::AnalysisSession::from_resolved_config(config.clone()) {
Ok(session) => Some(session),
Err(err) => return Err(crate::error::emit_error(err.message(), 2, opts.output)),
}
} else {
None
};
let mut discovered = session.as_ref().map(|session| session.files().to_vec());
if let Some(scope) = opts.scope.as_deref() {
discovered = discovered.map(|files| {
files
.into_iter()
.filter(|file| crate::scope_path::scope_covers(scope, &file.path))
.collect()
});
}
let session_workspaces = session.as_ref().map(|session| session.workspaces());
let session_workspace_diagnostics = session
.as_ref()
.map(|session| session.workspace_diagnostics().to_vec());
let inventory = collect_inventory(opts, show_all, session.as_ref())?;
let (plugin_result, all_entry_points) = match inventory {
Some(inventory) => (Some(inventory.plugins), inventory.entry_points),
None => (None, None),
};
let entry_weight = match (opts.entry_weight, session.as_ref()) {
(true, Some(session)) => Some(collect_entry_weight(
opts,
session,
all_entry_points.as_deref().unwrap_or_default(),
)?),
_ => None,
};
let entry_points = if opts.entry_points || show_all {
all_entry_points.map(|entries| scoped_entry_points(entries, opts.scope.as_deref()))
} else {
None
};
let boundary_data = if opts.boundaries {
Some(fallow_api::compute_boundary_data(
config,
discovered.as_deref(),
))
} else {
None
};
let workspace_data = collect_list_workspace_data(
opts,
config,
show_all,
session_workspaces,
session_workspace_diagnostics.as_deref(),
)?;
Ok(ListData {
show_all,
plugin_result,
discovered,
entry_points,
boundary_data,
entry_weight,
workspace_data,
})
}
fn collect_entry_weight(
opts: &ListOptions<'_>,
session: &fallow_engine::session::AnalysisSession,
entry_points: &[fallow_engine::discover::EntryPoint],
) -> Result<fallow_output::EntryWeightListing, ExitCode> {
let mut listing = fallow_engine::entry_weight::compute_entry_weight(session, entry_points)
.map_err(|err| {
crate::error::emit_error(&format!("Analysis error: {err}"), 2, opts.output)
})?;
if let Some(scope) = opts.scope.as_deref() {
listing
.entries
.retain(|entry| crate::scope_path::scope_covers(scope, &opts.root.join(&entry.path)));
listing.entry_count = listing.entries.len();
}
Ok(listing)
}
fn scoped_entry_points(
entry_points: Vec<fallow_engine::discover::EntryPoint>,
scope: Option<&std::path::Path>,
) -> Vec<fallow_engine::discover::EntryPoint> {
let Some(scope) = scope else {
return entry_points;
};
entry_points
.into_iter()
.filter(|entry| crate::scope_path::scope_covers(scope, &entry.path))
.collect()
}
fn collect_list_workspace_data(
opts: &ListOptions<'_>,
config: &fallow_config::ResolvedConfig,
show_all: bool,
workspaces: Option<&[fallow_config::WorkspaceInfo]>,
workspace_diagnostics: Option<&[fallow_config::WorkspaceDiagnostic]>,
) -> Result<Option<WorkspaceData>, ExitCode> {
if !(opts.workspaces || show_all) {
return Ok(None);
}
if let Some(workspaces) = workspaces {
return Ok(Some(WorkspaceData {
workspaces: workspaces.to_vec(),
diagnostics: workspace_diagnostics.unwrap_or(&[]).to_vec(),
}));
}
match fallow_engine::discover::discover_workspace_packages_with_diagnostics(
opts.root,
&config.ignore_patterns,
) {
Ok((workspaces, mut diagnostics)) => {
append_undeclared_workspace_diagnostics(
opts.root,
config,
&workspaces,
&mut diagnostics,
);
Ok(Some(WorkspaceData {
workspaces,
diagnostics,
}))
}
Err(err) => Err(crate::error::emit_error(err.message(), 2, opts.output)),
}
}
fn append_undeclared_workspace_diagnostics(
root: &std::path::Path,
config: &fallow_config::ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
diagnostics: &mut Vec<fallow_config::WorkspaceDiagnostic>,
) {
let undeclared = fallow_config::find_undeclared_workspaces_with_ignores(
root,
workspaces,
&config.ignore_patterns,
);
let already_flagged: rustc_hash::FxHashSet<std::path::PathBuf> = diagnostics
.iter()
.map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
.collect();
for diag in undeclared {
let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
if !already_flagged.contains(&canonical) {
diagnostics.push(diag);
}
}
}
const fn should_show_all(opts: &ListOptions<'_>) -> bool {
!opts.entry_points
&& !opts.files
&& !opts.plugins
&& !opts.boundaries
&& !opts.workspaces
&& !opts.entry_weight
}
const fn needs_file_discovery(
files: bool,
show_all: bool,
entry_points: bool,
boundaries: bool,
) -> bool {
files || show_all || entry_points || boundaries
}
fn collect_inventory(
opts: &ListOptions<'_>,
show_all: bool,
session: Option<&fallow_engine::session::AnalysisSession>,
) -> Result<Option<fallow_engine::list_inventory::ListingInventory>, ExitCode> {
if !(opts.plugins || opts.entry_points || opts.entry_weight || show_all) {
return Ok(None);
}
let Some(session) = session else {
return Ok(None);
};
fallow_engine::list_inventory::collect_listing_inventory(
session,
opts.entry_points || opts.entry_weight || show_all,
)
.map(Some)
.map_err(|err| crate::error::emit_error(err.message(), 2, opts.output))
}
struct ListJsonInput<'a> {
opts: &'a ListOptions<'a>,
show_all: bool,
plugin_result: Option<&'a fallow_engine::plugins::AggregatedPluginResult>,
discovered: Option<&'a [fallow_engine::discover::DiscoveredFile]>,
entry_points: Option<&'a [fallow_engine::discover::EntryPoint]>,
boundary_data: Option<&'a BoundaryData>,
entry_weight: Option<&'a fallow_output::EntryWeightListing>,
workspace_data: Option<&'a WorkspaceData>,
}
fn print_list_json(input: &ListJsonInput<'_>) -> Result<(), ExitCode> {
match render_list_json(input) {
Ok(json) => {
println!("{json}");
Ok(())
}
Err(err) => {
eprintln!("Error: failed to serialize list output: {err}");
Err(ExitCode::from(2))
}
}
}
fn gate_exit(gate_failed: bool) -> ExitCode {
if gate_failed {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn validate_entry_weight_gate(
opts: &ListOptions<'_>,
gate: &crate::regression::EntryWeightGate<'_>,
) -> Result<(), ExitCode> {
gate.validate(opts.output)?;
if gate.save_file.is_some() && opts.scope.is_some() {
return Err(crate::error::emit_error(
"list --entry-weight --save-regression-baseline saves every entry; drop the PATH \
scope to save a baseline",
2,
opts.output,
));
}
Ok(())
}
fn apply_entry_weight_gate(
opts: &ListOptions<'_>,
gate: &crate::regression::EntryWeightGate<'_>,
listing: &mut fallow_output::EntryWeightListing,
) -> Result<bool, ExitCode> {
let mut failed = false;
if let Some(path) = gate.baseline_file {
let baseline = crate::regression::load_entry_weight_baseline(path, opts.output)?;
let regression = crate::regression::compare_entry_weight(
listing,
&baseline,
gate.tolerance,
gate.fail_on_regression,
|entry_path| {
opts.scope.as_deref().is_none_or(|scope| {
crate::scope_path::scope_covers(scope, &opts.root.join(entry_path))
})
},
);
failed = regression.enforced && regression.exceeded;
listing.regression = Some(regression);
}
if let Some(path) = gate.save_file {
crate::regression::save_entry_weight_baseline(
path,
opts.root,
crate::regression::EntryWeightCounts::from_listing(listing),
opts.output,
)?;
}
Ok(failed)
}
fn render_list_json(input: &ListJsonInput<'_>) -> Result<String, String> {
let has_boundaries = input.boundary_data.is_some();
let workspace_only = input.opts.workspaces
&& !input.opts.plugins
&& !input.opts.files
&& !input.opts.entry_points
&& !input.opts.boundaries;
let envelope = if has_boundaries {
ListJsonEnvelope::Boundaries
} else if workspace_only {
ListJsonEnvelope::Workspaces
} else {
ListJsonEnvelope::Plain
};
let output =
fallow_api::serialize_list_json_output(build_list_json_output_input(input), envelope)
.map_err(|err| err.to_string())?;
input
.opts
.json_style
.serialize(&output)
.map_err(|err| err.to_string())
}
fn build_list_json_output_input(
input: &ListJsonInput<'_>,
) -> ListJsonOutputInput<fallow_api::BoundariesListing, fallow_config::WorkspaceDiagnostic> {
let opts = input.opts;
let show_all = input.show_all;
let plugins = if opts.plugins || show_all {
input
.plugin_result
.map(|plugin_result| plugin_result.active_plugins().to_vec())
} else {
None
};
let files = if opts.files || show_all {
input.discovered.map(|discovered| {
discovered
.iter()
.map(|file| format_display_path(&file.path, opts.root))
.collect()
})
} else {
None
};
let entry_points = input.entry_points.map(|entries| {
entries
.iter()
.map(|entry| fallow_api::ListEntryPointOutput {
path: format_display_path(&entry.path, opts.root),
source: entry.source.to_string(),
})
.collect()
});
let plugin_diagnostics = input
.plugin_result
.map(|plugin_result| {
plugin_result
.plugin_diagnostics(opts.root)
.into_iter()
.map(|diagnostic| diagnostic.into_root_relative(opts.root))
.collect()
})
.unwrap_or_default();
ListJsonOutputInput {
plugins,
files,
entry_points,
boundaries: input.boundary_data.map(fallow_api::boundary_data_to_output),
entry_weight: input.entry_weight.cloned(),
workspaces: input
.workspace_data
.map(|workspaces| workspace_data_to_output(opts.root, workspaces)),
plugin_diagnostics,
}
}
fn workspace_data_to_output(
root: &std::path::Path,
ws: &WorkspaceData,
) -> fallow_api::WorkspacesOutput {
let workspaces = ws
.workspaces
.iter()
.map(|w| {
let relative = w.root.strip_prefix(root).unwrap_or(&w.root);
WorkspaceInfo {
name: w.name.clone(),
path: relative.display().to_string().replace('\\', "/"),
is_internal_dependency: w.is_internal_dependency,
}
})
.collect::<Vec<_>>();
fallow_api::WorkspacesOutput {
workspace_count: workspaces.len(),
workspaces,
workspace_diagnostics: ws
.diagnostics
.iter()
.map(|diagnostic| diagnostic.clone().into_root_relative(root))
.collect(),
}
}
struct ListHumanInput<'a> {
opts: &'a ListOptions<'a>,
show_all: bool,
plugin_result: Option<&'a fallow_engine::plugins::AggregatedPluginResult>,
discovered: Option<&'a [fallow_engine::discover::DiscoveredFile]>,
entry_points: Option<&'a [fallow_engine::discover::EntryPoint]>,
boundary_data: Option<&'a BoundaryData>,
entry_weight: Option<&'a fallow_output::EntryWeightListing>,
workspace_data: Option<&'a WorkspaceData>,
}
fn print_list_human(input: &ListHumanInput<'_>) {
let opts = input.opts;
let show_all = input.show_all;
let plugin_result = input.plugin_result;
let discovered = input.discovered;
let entry_points = input.entry_points;
let boundary_data = input.boundary_data;
let workspace_data = input.workspace_data;
if (opts.plugins || show_all)
&& let Some(pr) = plugin_result
{
eprintln!("Active plugins:");
for name in pr.active_plugins() {
eprintln!(" - {name}");
}
}
if (opts.files || show_all)
&& let Some(disc) = discovered
{
eprintln!("Discovered {} files", disc.len());
for file in disc {
println!("{}", format_display_path(&file.path, opts.root));
}
}
if let Some(entries) = entry_points {
eprintln!("Found {} entry points", entries.len());
for ep in entries {
println!(
"{} ({})",
format_display_path(&ep.path, opts.root),
ep.source
);
}
}
if let Some(bd) = boundary_data {
print_boundary_data_human(bd);
}
if let Some(weight) = input.entry_weight {
print_entry_weight_human(weight);
}
if let Some(ws) = workspace_data {
print_workspace_data_human(opts.root, ws, opts.workspaces);
}
}
fn print_entry_weight_human(weight: &fallow_output::EntryWeightListing) {
eprintln!(
"Startup import weight of {} runtime {} (source bytes on disk, not bundle size)",
weight.entry_count,
if weight.entry_count == 1 {
"entry"
} else {
"entries"
}
);
for entry in &weight.entries {
println!("{} ({})", entry.path, entry.source);
println!(
" eager {} {}, {}{}",
entry.eager_modules,
pluralize("module", entry.eager_modules),
format_bytes(entry.eager_bytes),
if entry.eager_css_bytes > 0 {
format!(" ({} CSS)", format_bytes(entry.eager_css_bytes))
} else {
String::new()
}
);
println!(
" deferred {} {}, {}",
entry.deferred_modules,
pluralize("module", entry.deferred_modules),
format_bytes(entry.deferred_bytes)
);
println!(
" out of thread {} {}, {}",
entry.out_of_thread_modules,
pluralize("module", entry.out_of_thread_modules),
format_bytes(entry.out_of_thread_bytes)
);
if !entry.eager_packages.is_empty() {
let names: Vec<&str> = entry
.eager_packages
.iter()
.map(|package| package.name.as_str())
.collect();
println!(
" eager packages {}: {}",
entry.eager_package_count,
names.join(", ")
);
}
if !entry.dominating_imports.is_empty() {
println!(" imports that keep the most bytes eager:");
for import in &entry.dominating_imports {
let location = import.line.map_or_else(
|| import.importer.clone(),
|line| format!("{}:{line}", import.importer),
);
println!(
" {location} -> {} {}, {} {}",
import.target,
format_bytes(import.exclusive_bytes),
import.exclusive_modules,
pluralize("module", import.exclusive_modules)
);
}
}
}
if let Some(regression) = &weight.regression {
print_entry_weight_regression_human(regression);
}
}
fn print_entry_weight_regression_human(regression: &fallow_output::EntryWeightRegression) {
let verdict = match (regression.exceeded, regression.enforced) {
(false, _) => "passed",
(true, true) => "failed",
(true, false) => "exceeded (report-only; add --fail-on-regression to enforce)",
};
eprintln!(
"Entry weight regression check {verdict} (tolerance {})",
regression.tolerance
);
for delta in ®ression.entries {
let line = match (delta.baseline_eager_bytes, delta.current_eager_bytes) {
(Some(before), Some(now)) if before == now && delta.new_eager_packages.is_empty() => {
continue;
}
(Some(before), Some(now)) => {
let change = i128::from(now) - i128::from(before);
format!(
"eager {} -> {} ({}{change} B)",
format_bytes(before),
format_bytes(now),
if change >= 0 { "+" } else { "" }
)
}
(None, Some(now)) => format!("new entry, eager {}", format_bytes(now)),
(Some(before), None) => format!("gone, was eager {}", format_bytes(before)),
(None, None) => continue,
};
let packages = if delta.new_eager_packages.is_empty() {
String::new()
} else {
format!(
", new eager packages: {}",
delta.new_eager_packages.join(", ")
)
};
let mark = if delta.exceeded { " [exceeded]" } else { "" };
eprintln!(" {} {line}{packages}{mark}", delta.path);
}
}
fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
#[expect(
clippy::cast_precision_loss,
reason = "a display value with one decimal place"
)]
let value = bytes as f64;
if bytes >= MIB {
format!("{:.1} MiB", value / MIB as f64)
} else if bytes >= KIB {
format!("{:.1} KiB", value / KIB as f64)
} else {
format!("{bytes} B")
}
}
fn print_workspace_data_human(root: &std::path::Path, ws: &WorkspaceData, explicit: bool) {
if ws.workspaces.is_empty() && ws.diagnostics.is_empty() {
if explicit {
eprintln!("No workspaces declared (single-package project).");
}
return;
}
if ws.workspaces.is_empty() {
eprintln!("No workspaces discovered.");
} else {
eprintln!("Discovered {} workspaces", ws.workspaces.len());
for w in &ws.workspaces {
let relative = w.root.strip_prefix(root).unwrap_or(&w.root);
let path_str = relative.display().to_string().replace('\\', "/");
let suffix = if w.is_internal_dependency {
" (internal dep)"
} else {
""
};
println!(" {} -> {path_str}{suffix}", w.name);
}
}
if !ws.diagnostics.is_empty() {
eprintln!(
"{} workspace discovery diagnostic{}:",
ws.diagnostics.len(),
if ws.diagnostics.len() == 1 { "" } else { "s" }
);
for d in &ws.diagnostics {
eprintln!(" - {}", d.message);
}
}
}
struct WorkspaceData {
workspaces: Vec<fallow_config::WorkspaceInfo>,
diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
}
#[cfg(test)]
fn boundary_data_to_json(bd: &BoundaryData) -> serde_json::Value {
match serde_json::to_value(fallow_api::boundary_data_to_output(bd)) {
Ok(value) => value,
Err(error) => panic!("boundary list output should serialize: {error}"),
}
}
fn print_boundary_data_human(bd: &BoundaryData) {
if bd.is_empty {
eprintln!("Boundaries: not configured");
return;
}
print_boundary_header(bd);
print_boundary_zones(&bd.zones);
print_boundary_rules(&bd.rules);
print_boundary_logical_groups(&bd.logical_groups);
}
fn print_boundary_header(bd: &BoundaryData) {
let mut header_parts = vec![
format!("{} {}", bd.zones.len(), pluralize("zone", bd.zones.len())),
format!("{} {}", bd.rules.len(), pluralize("rule", bd.rules.len())),
];
if !bd.logical_groups.is_empty() {
header_parts.push(format!(
"{} logical {}",
bd.logical_groups.len(),
pluralize("group", bd.logical_groups.len())
));
}
eprintln!("Boundaries: {}", header_parts.join(", "));
}
fn print_boundary_zones(zones: &[fallow_api::ZoneInfo]) {
if zones.is_empty() {
return;
}
eprintln!("\nZones:");
for zone in zones {
eprintln!(
" {:<20} {} {} {}",
zone.name,
zone.file_count,
pluralize("file", zone.file_count),
zone.patterns.join(", ")
);
}
}
fn print_boundary_rules(rules: &[fallow_api::RuleInfo]) {
if rules.is_empty() {
return;
}
eprintln!("\nRules:");
for rule in rules {
if rule.allow.is_empty() {
eprintln!(" {:<20} (isolated, no imports allowed)", rule.from);
} else {
eprintln!(" {:<20} → {}", rule.from, rule.allow.join(", "));
}
}
}
fn print_boundary_logical_groups(logical_groups: &[fallow_api::LogicalGroupInfo]) {
if logical_groups.is_empty() {
return;
}
eprintln!("\nLogical groups:");
let mut ordered: Vec<&fallow_api::LogicalGroupInfo> = logical_groups.iter().collect();
ordered.sort_by_key(|g| match g.status {
fallow_config::LogicalGroupStatus::InvalidPath => 0,
fallow_config::LogicalGroupStatus::Empty => 1,
fallow_config::LogicalGroupStatus::Ok => 2,
});
for g in ordered {
print_logical_group_row(g);
}
}
fn print_logical_group_row(g: &fallow_api::LogicalGroupInfo) {
let status_suffix = match g.status {
fallow_config::LogicalGroupStatus::Ok => String::new(),
fallow_config::LogicalGroupStatus::Empty => " (empty)".to_owned(),
fallow_config::LogicalGroupStatus::InvalidPath => " (invalid path)".to_owned(),
};
let file_count_render = if g.fallback_zone.is_some() {
format!(
"{} {} ({} children + {} fallback)",
g.file_count,
pluralize("file", g.file_count),
g.child_file_count,
g.fallback_file_count
)
} else {
format!("{} {}", g.file_count, pluralize("file", g.file_count))
};
eprintln!(
" {:<20} {} autoDiscover: {}{}",
g.name,
file_count_render,
g.auto_discover.join(", "),
status_suffix
);
if !g.children.is_empty() {
eprintln!(" children: {}", g.children.join(", "));
}
}
fn pluralize(noun: &str, count: usize) -> String {
if count == 1 {
noun.to_owned()
} else {
format!("{noun}s")
}
}
#[cfg(test)]
mod tests {
use fallow_api::{LogicalGroupInfo, ZoneInfo};
use super::*;
fn make_opts(
entry_points: bool,
files: bool,
plugins: bool,
boundaries: bool,
) -> ListOptions<'static> {
ListOptions {
root: std::path::Path::new("/project"),
config_path: &None,
output: OutputFormat::Human,
json_style: crate::json_style::JsonStyle::Compact,
threads: 4,
no_cache: false,
entry_points,
files,
plugins,
boundaries,
workspaces: false,
entry_weight: false,
entry_weight_gate: None,
production: false,
allow_remote_extends: false,
scope: None,
}
}
#[test]
fn show_all_when_no_flags_set() {
assert!(should_show_all(&make_opts(false, false, false, false)));
}
#[test]
fn not_show_all_when_entry_weight_set() {
let opts = ListOptions {
entry_weight: true,
..make_opts(false, false, false, false)
};
assert!(!should_show_all(&opts));
}
#[test]
fn not_show_all_when_entry_points_set() {
assert!(!should_show_all(&make_opts(true, false, false, false)));
}
#[test]
fn not_show_all_when_files_set() {
assert!(!should_show_all(&make_opts(false, true, false, false)));
}
#[test]
fn not_show_all_when_plugins_set() {
assert!(!should_show_all(&make_opts(false, false, true, false)));
}
#[test]
fn not_show_all_when_boundaries_set() {
assert!(!should_show_all(&make_opts(false, false, false, true)));
}
#[test]
fn not_show_all_when_all_flags_set() {
assert!(!should_show_all(&make_opts(true, true, true, true)));
}
#[test]
fn not_show_all_when_two_flags_set() {
assert!(!should_show_all(&make_opts(true, true, false, false)));
assert!(!should_show_all(&make_opts(true, false, true, false)));
assert!(!should_show_all(&make_opts(false, true, true, false)));
}
#[test]
fn needs_discovery_when_files_requested() {
assert!(needs_file_discovery(true, false, false, false));
}
#[test]
fn needs_discovery_when_show_all() {
assert!(needs_file_discovery(false, true, false, false));
}
#[test]
fn needs_discovery_when_entry_points_requested() {
assert!(needs_file_discovery(false, false, true, false));
}
#[test]
fn needs_discovery_when_boundaries_requested() {
assert!(needs_file_discovery(false, false, false, true));
}
#[test]
fn no_discovery_when_only_plugins() {
assert!(!needs_file_discovery(false, false, false, false));
}
#[test]
fn list_options_default_flags() {
let opts = make_opts(false, false, false, false);
assert!(should_show_all(&opts));
}
#[test]
fn list_options_single_flag() {
let opts = make_opts(true, false, false, false);
assert!(!should_show_all(&opts));
assert!(needs_file_discovery(
opts.files,
should_show_all(&opts),
opts.entry_points,
opts.boundaries,
));
}
fn empty_boundary_data() -> BoundaryData {
BoundaryData {
zones: vec![],
rules: vec![],
logical_groups: vec![],
is_empty: true,
}
}
#[test]
fn boundary_json_empty_includes_logical_groups_key() {
let json = boundary_data_to_json(&empty_boundary_data());
assert_eq!(json["configured"], false);
assert!(json["logical_groups"].is_array());
assert_eq!(json["logical_groups"].as_array().unwrap().len(), 0);
}
#[test]
fn boundary_json_empty_branch_includes_all_count_fields() {
let json = boundary_data_to_json(&empty_boundary_data());
assert_eq!(json["zone_count"], 0);
assert_eq!(json["rule_count"], 0);
assert_eq!(json["logical_group_count"], 0);
}
#[test]
fn pluralize_singular_plural() {
assert_eq!(pluralize("file", 0), "files");
assert_eq!(pluralize("file", 1), "file");
assert_eq!(pluralize("file", 2), "files");
assert_eq!(pluralize("zone", 1), "zone");
assert_eq!(pluralize("group", 1), "group");
}
#[test]
fn boundary_json_logical_group_carries_all_fields() {
let bd = BoundaryData {
zones: vec![
ZoneInfo {
name: "features/auth".to_string(),
patterns: vec!["src/features/auth/**".to_string()],
file_count: 3,
},
ZoneInfo {
name: "features/billing".to_string(),
patterns: vec!["src/features/billing/**".to_string()],
file_count: 5,
},
],
rules: vec![],
logical_groups: vec![LogicalGroupInfo {
name: "features".to_string(),
children: vec!["features/auth".to_string(), "features/billing".to_string()],
auto_discover: vec!["./src/features/".to_string()],
authored_rule: Some(fallow_config::AuthoredRule {
allow: vec!["shared".to_string()],
allow_type_only: vec!["types".to_string()],
}),
fallback_zone: None,
source_zone_index: 1,
status: fallow_config::LogicalGroupStatus::Ok,
file_count: 8,
child_file_count: 8,
fallback_file_count: 0,
merged_from: None,
original_zone_root: None,
child_source_indices: vec![],
}],
is_empty: false,
};
let json = boundary_data_to_json(&bd);
assert_eq!(json["logical_group_count"], 1);
let groups = json["logical_groups"].as_array().unwrap();
assert_eq!(groups.len(), 1);
let g = &groups[0];
assert_eq!(g["name"], "features");
assert_eq!(g["children"][0], "features/auth");
assert_eq!(g["children"][1], "features/billing");
assert_eq!(g["auto_discover"][0], "./src/features/");
assert_eq!(g["status"], "ok");
assert_eq!(g["source_zone_index"], 1);
assert_eq!(g["file_count"], 8);
assert_eq!(g["authored_rule"]["allow"][0], "shared");
assert_eq!(g["authored_rule"]["allow_type_only"][0], "types");
assert!(g.get("fallback_zone").is_none());
assert!(g.get("merged_from").is_none());
assert!(g.get("original_zone_root").is_none());
assert!(g.get("child_source_indices").is_none());
}
#[test]
fn boundary_json_logical_group_status_serializations() {
for (status, expected) in [
(fallow_config::LogicalGroupStatus::Ok, "ok"),
(fallow_config::LogicalGroupStatus::Empty, "empty"),
(
fallow_config::LogicalGroupStatus::InvalidPath,
"invalid_path",
),
] {
let bd = BoundaryData {
zones: vec![],
rules: vec![],
logical_groups: vec![LogicalGroupInfo {
name: "features".to_string(),
children: vec![],
auto_discover: vec!["src/features".to_string()],
authored_rule: None,
fallback_zone: None,
source_zone_index: 0,
status,
file_count: 0,
child_file_count: 0,
fallback_file_count: 0,
merged_from: None,
original_zone_root: None,
child_source_indices: vec![],
}],
is_empty: false,
};
let json = boundary_data_to_json(&bd);
assert_eq!(json["logical_groups"][0]["status"], expected);
}
}
#[test]
fn boundary_json_logical_group_fallback_zone_round_trip() {
let bd = BoundaryData {
zones: vec![ZoneInfo {
name: "features".to_string(),
patterns: vec!["src/features/**".to_string()],
file_count: 2,
}],
rules: vec![],
logical_groups: vec![LogicalGroupInfo {
name: "features".to_string(),
children: vec![],
auto_discover: vec!["src/features".to_string()],
authored_rule: None,
fallback_zone: Some("features".to_string()),
source_zone_index: 0,
status: fallow_config::LogicalGroupStatus::Empty,
file_count: 2,
child_file_count: 0,
fallback_file_count: 2,
merged_from: None,
original_zone_root: None,
child_source_indices: vec![],
}],
is_empty: false,
};
let json = boundary_data_to_json(&bd);
assert_eq!(json["logical_groups"][0]["fallback_zone"], "features");
}
#[test]
fn boundary_json_logical_group_authored_rule_omits_empty_allow_type_only() {
let bd = BoundaryData {
zones: vec![],
rules: vec![],
logical_groups: vec![LogicalGroupInfo {
name: "features".to_string(),
children: vec![],
auto_discover: vec!["src/features".to_string()],
authored_rule: Some(fallow_config::AuthoredRule {
allow: vec!["shared".to_string()],
allow_type_only: vec![],
}),
fallback_zone: None,
source_zone_index: 0,
status: fallow_config::LogicalGroupStatus::Empty,
file_count: 0,
child_file_count: 0,
fallback_file_count: 0,
merged_from: None,
original_zone_root: None,
child_source_indices: vec![],
}],
is_empty: false,
};
let json = boundary_data_to_json(&bd);
let rule = &json["logical_groups"][0]["authored_rule"];
assert_eq!(rule["allow"][0], "shared");
assert!(rule.get("allow_type_only").is_none());
}
#[test]
fn boundary_json_logical_group_merged_from_when_duplicates() {
let bd = BoundaryData {
zones: vec![],
rules: vec![],
logical_groups: vec![LogicalGroupInfo {
name: "features".to_string(),
children: vec![],
auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
authored_rule: None,
fallback_zone: None,
source_zone_index: 0,
status: fallow_config::LogicalGroupStatus::Ok,
file_count: 0,
child_file_count: 0,
fallback_file_count: 0,
merged_from: Some(vec![0, 3]),
original_zone_root: None,
child_source_indices: vec![],
}],
is_empty: false,
};
let json = boundary_data_to_json(&bd);
let g = &json["logical_groups"][0];
assert_eq!(g["merged_from"][0], 0);
assert_eq!(g["merged_from"][1], 3);
}
#[test]
fn boundary_json_logical_group_original_zone_root_emitted() {
let bd = BoundaryData {
zones: vec![],
rules: vec![],
logical_groups: vec![LogicalGroupInfo {
name: "features".to_string(),
children: vec![],
auto_discover: vec!["src/features".to_string()],
authored_rule: None,
fallback_zone: None,
source_zone_index: 0,
status: fallow_config::LogicalGroupStatus::Ok,
file_count: 0,
child_file_count: 0,
fallback_file_count: 0,
merged_from: None,
original_zone_root: Some("packages/app/".to_string()),
child_source_indices: vec![],
}],
is_empty: false,
};
let json = boundary_data_to_json(&bd);
assert_eq!(
json["logical_groups"][0]["original_zone_root"],
"packages/app/"
);
}
#[test]
fn boundary_json_logical_group_child_source_indices_emitted_for_multi_path() {
let bd = BoundaryData {
zones: vec![],
rules: vec![],
logical_groups: vec![LogicalGroupInfo {
name: "features".to_string(),
children: vec!["features/auth".to_string(), "features/billing".to_string()],
auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
authored_rule: None,
fallback_zone: None,
source_zone_index: 0,
status: fallow_config::LogicalGroupStatus::Ok,
file_count: 0,
child_file_count: 0,
fallback_file_count: 0,
merged_from: None,
original_zone_root: None,
child_source_indices: vec![0, 1],
}],
is_empty: false,
};
let json = boundary_data_to_json(&bd);
assert_eq!(json["logical_groups"][0]["child_source_indices"][0], 0);
assert_eq!(json["logical_groups"][0]["child_source_indices"][1], 1);
}
}