use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Instant;
use fallow_config::OutputFormat;
use fallow_engine::baseline::HealthBaselineMode;
use fallow_engine::dead_code::enable_security_rules;
use fallow_engine::flags::analyze_feature_flags_with_session_and_results;
use fallow_engine::health::{
HealthCoverageInputs, HealthExecutionOptions, HealthGateOptions, HealthSort,
HealthThresholdOverrides, run_ungrouped_health_with_session_artifacts,
};
use fallow_engine::project_analysis::ProjectAnalysisArtifactOptions;
use fallow_engine::session::AnalysisSession;
use fallow_engine::viz::{
VizAvailabilityState, VizBuildInput, VizData, VizFileStatus, apply_health_report,
build_viz_data,
};
use fallow_types::semantic::SemanticAnalysisIdentity;
mod payload;
use crate::error::emit_error;
use crate::resolve_coverage_inputs;
use crate::runtime_support::{LoadConfigArgs, load_config};
const VIZ_JS: &str = include_str!("../../viz-assets/viz.js");
const VIZ_CSS: &str = include_str!("../../viz-assets/viz.css");
const VIZ_SHELL: &str = include_str!("../../viz-assets/shell.html");
const SHELL_ROOT_PLACEHOLDER: &str = "__FALLOW_ROOT__";
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum VizFormat {
Html,
Dot,
Mermaid,
}
pub struct VizOptions<'a> {
pub root: &'a Path,
pub config_path: &'a Option<PathBuf>,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub production: bool,
pub allow_remote_extends: bool,
pub output_path: Option<&'a Path>,
pub no_open: bool,
pub format: VizFormat,
}
pub fn run_viz(opts: &VizOptions<'_>) -> ExitCode {
let start = Instant::now();
let data = match build_viz_data_from_options(opts) {
Ok(data) => data,
Err(code) => return code,
};
let elapsed = start.elapsed();
match opts.format {
VizFormat::Html => write_html(opts, &data, elapsed),
VizFormat::Dot => write_text_format(opts, &generate_dot(&data)),
VizFormat::Mermaid => write_text_format(opts, &generate_mermaid(&data)),
}
}
#[doc(hidden)]
pub fn benchmark_viz_html(root: &Path, threads: usize) -> Result<(usize, usize, usize), ExitCode> {
let config_path = None;
let opts = VizOptions {
root,
config_path: &config_path,
no_cache: true,
threads,
quiet: true,
production: false,
allow_remote_extends: false,
output_path: None,
no_open: true,
format: VizFormat::Html,
};
let data = build_viz_data_from_options(&opts)?;
let file_count = data.files.len();
let edge_count = data.edges.len();
let html = render_html(&data).map_err(|message| {
emit_error(
&format!("Failed to serialize viz data: {message}"),
2,
OutputFormat::Human,
)
})?;
Ok((file_count, edge_count, html.len()))
}
fn build_viz_data_from_options(opts: &VizOptions<'_>) -> Result<VizData, ExitCode> {
let mut config = load_config(
opts.root,
opts.config_path,
LoadConfigArgs {
output: OutputFormat::Human,
no_cache: opts.no_cache,
threads: opts.threads,
production: opts.production,
quiet: opts.quiet,
allow_remote_extends: opts.allow_remote_extends,
},
)?;
if opts.format == VizFormat::Html {
enable_security_rules(&mut config);
}
let coverage_inputs = resolve_coverage_inputs(None, None, OutputFormat::Human, || {
Ok(config.health.clone())
})?;
let session = match AnalysisSession::from_resolved_config(config) {
Ok(session) => session,
Err(e) => {
return Err(emit_error(
&format!("Analysis error: {e}"),
2,
OutputFormat::Human,
));
}
};
let duplicates_config = session.config().duplicates.clone();
let artifacts = match session.analyze_project_with_artifacts(
&duplicates_config,
ProjectAnalysisArtifactOptions {
retain_complexity_artifacts: true,
retain_graph: true,
..ProjectAnalysisArtifactOptions::default()
},
) {
Ok(artifacts) => artifacts,
Err(e) => {
return Err(emit_error(
&format!("Analysis error: {e}"),
2,
OutputFormat::Human,
));
}
};
let Some(graph) = artifacts.dead_code.graph.as_ref() else {
return Err(emit_error("Graph not available", 2, OutputFormat::Human));
};
let feature_flags = if opts.format == VizFormat::Html {
analyze_feature_flags_with_session_and_results(&session, &artifacts.dead_code.results).flags
} else {
Vec::new()
};
let mut data = build_viz_data(&VizBuildInput {
results: &artifacts.dead_code.results,
graph,
modules: artifacts.dead_code.modules.as_deref(),
files: session.files(),
duplication: &artifacts.duplication,
workspaces: session.workspaces(),
config: session.config(),
feature_flags: &feature_flags,
include_analysis_details: opts.format == VizFormat::Html,
});
if opts.format != VizFormat::Html {
return Ok(data);
}
let health_options = viz_health_options(
opts,
coverage_inputs.coverage.as_deref(),
coverage_inputs.coverage_root.as_deref(),
);
match run_ungrouped_health_with_session_artifacts(
&health_options,
None,
&session,
None,
Some(artifacts.dead_code),
Some(artifacts.duplication),
) {
Ok(result) => {
data.health.shared_parse = result.timings.as_ref().map(|timings| timings.shared_parse);
apply_health_report(&mut data, &result.report, session.config().root.as_path());
}
Err(error) => {
data.health.availability.state = VizAvailabilityState::Unavailable;
data.health.availability.reason =
Some(format!("Health analysis unavailable: {error:?}"));
data.styling.availability.state = VizAvailabilityState::Unavailable;
data.styling.availability.reason =
Some("Styling analysis depends on the Health runner".to_string());
}
}
Ok(data)
}
fn viz_health_options<'a>(
opts: &'a VizOptions<'a>,
coverage: Option<&'a Path>,
coverage_root: Option<&'a Path>,
) -> HealthExecutionOptions<'a> {
HealthExecutionOptions {
root: opts.root,
config_path: opts.config_path,
output: OutputFormat::Human,
no_cache: opts.no_cache,
threads: opts.threads,
quiet: true,
complexity_breakdown: false,
thresholds: HealthThresholdOverrides::default(),
top: None,
sort: HealthSort::Severity,
production: opts.production,
production_override: None,
allow_remote_extends: opts.allow_remote_extends,
changed_since: None,
diff_index: None,
use_shared_diff_index: false,
workspace: None,
changed_workspaces: None,
baseline: None,
save_baseline: None,
baseline_mode: HealthBaselineMode::default(),
baseline_mode_explicit: false,
complexity: true,
file_scores: true,
coverage_gaps: true,
config_activates_coverage_gaps: false,
hotspots: false,
ownership: false,
ownership_emails: None,
targets: false,
css: true,
css_deep: false,
force_full: true,
score_only_output: false,
enforce_coverage_gap_gate: false,
effort: None,
score: true,
gates: HealthGateOptions::default(),
since: None,
min_commits: None,
explain: false,
summary: false,
save_snapshot: None,
trend: false,
coverage_inputs: HealthCoverageInputs {
coverage,
coverage_root,
coverage_relocated: false,
},
performance: true,
runtime_coverage: None,
churn_file: None,
analysis_identity: SemanticAnalysisIdentity::default(),
group_by: None,
scope: None,
}
}
fn write_text_format(opts: &VizOptions<'_>, content: &str) -> ExitCode {
let Some(path) = opts.output_path else {
println!("{content}");
return ExitCode::SUCCESS;
};
if let Err(message) = write_output(path, content) {
return emit_error(&message, 2, OutputFormat::Human);
}
if !opts.quiet {
eprintln!(" → {}", path.display());
}
ExitCode::SUCCESS
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn escape_payload_json(json: &str) -> String {
json.replace('<', "\\u003c")
}
fn write_output(path: &Path, content: &str) -> Result<(), String> {
if let Ok(meta) = path.symlink_metadata()
&& meta.file_type().is_symlink()
{
return Err(format!(
"Refusing to write through symlink: {}",
path.display()
));
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create output directory {}: {e}",
parent.display()
)
})?;
}
std::fs::write(path, content).map_err(|e| format!("Failed to write output file: {e}"))
}
fn write_html(opts: &VizOptions<'_>, data: &VizData, elapsed: std::time::Duration) -> ExitCode {
let html = match render_html(data) {
Ok(html) => html,
Err(e) => {
return emit_error(
&format!("Failed to serialize viz data: {e}"),
2,
OutputFormat::Human,
);
}
};
let output_path = opts
.output_path
.map_or_else(|| opts.root.join("fallow-viz.html"), Path::to_path_buf);
if let Err(message) = write_output(&output_path, &html) {
return emit_error(&message, 2, OutputFormat::Human);
}
if !opts.quiet {
let file_count = data.summary.total_files;
let issue_count = data.summary.unused_files
+ data.summary.unused_exports
+ data.summary.unused_deps
+ data.summary.unresolved_imports
+ data.summary.circular_deps
+ data.summary.clone_groups
+ data.summary.boundary_violations;
eprintln!(
"Visualization generated in {:.0}ms ({file_count} files, {issue_count} findings)",
elapsed.as_secs_f64() * 1000.0,
);
eprintln!(" → {}", output_path.display());
}
if !opts.no_open
&& let Err(e) = open::that(&output_path)
{
eprintln!("Could not open browser: {e}");
eprintln!("Open manually: {}", output_path.display());
}
ExitCode::SUCCESS
}
fn render_html(data: &VizData) -> Result<String, serde_json::Error> {
let payload = payload::encode_payload(data)?;
let core = escape_payload_json(&payload.core);
let tables = if payload.tables { " data-tables" } else { "" };
let mut lazy = String::new();
for (path, section) in &payload.lazy {
let section = escape_payload_json(section);
let _ = writeln!(
lazy,
r#"<script type="application/json" data-fallow-lazy="{path}">{section}</script>"#,
);
}
let title = html_escape(&data.root);
let shell = VIZ_SHELL.replacen(SHELL_ROOT_PLACEHOLDER, &title, 1);
let css = VIZ_CSS;
let js = VIZ_JS;
Ok(format!(
r#"<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>fallow map: {title}</title>
<style>{css}</style>
</head>
<body>
{shell}<script type="application/json" id="fallow-data"{tables}>{core}</script>
{lazy}<script>{js}</script>
</body>
</html>"#,
))
}
const fn status_color(status: VizFileStatus) -> &'static str {
match status {
VizFileStatus::Unused => "#e5484d",
VizFileStatus::HasUnusedExports => "#ffc53d",
VizFileStatus::EntryPoint => "#30a46c",
VizFileStatus::Clean => "#62605b",
}
}
fn sanitize_label(path: &str) -> String {
path.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.filter(|c| !c.is_control())
.collect()
}
fn generate_dot(data: &VizData) -> String {
let mut out =
String::from("digraph fallow {\n rankdir=LR;\n node [shape=box, style=filled];\n\n");
for (i, f) in data.files.iter().enumerate() {
let color = status_color(f.status);
let font = match f.status {
VizFileStatus::HasUnusedExports => "#111110",
_ => "#eeeeec",
};
let escaped_path = sanitize_label(&f.path)
.replace('\\', "\\\\")
.replace('"', "\\\"");
let _ = writeln!(
out,
" n{i} [label=\"{escaped_path}\", fillcolor=\"{color}\", fontcolor=\"{font}\"];",
);
}
out.push('\n');
for [src, tgt, _flags] in &data.edges {
let _ = writeln!(out, " n{src} -> n{tgt};");
}
out.push_str("}\n");
out
}
fn generate_mermaid(data: &VizData) -> String {
let mut out = String::from("graph LR\n");
for (i, f) in data.files.iter().enumerate() {
let escaped_path = sanitize_label(&f.path)
.replace('"', "#quot;")
.replace('[', "#91;")
.replace(']', "#93;");
let _ = writeln!(out, " n{i}[\"{escaped_path}\"]");
}
out.push('\n');
for [src, tgt, _flags] in &data.edges {
let _ = writeln!(out, " n{src} --> n{tgt}");
}
out.push('\n');
let mut unused: Vec<usize> = Vec::new();
let mut has_unused: Vec<usize> = Vec::new();
let mut entry: Vec<usize> = Vec::new();
for (i, f) in data.files.iter().enumerate() {
match f.status {
VizFileStatus::Unused => unused.push(i),
VizFileStatus::HasUnusedExports => has_unused.push(i),
VizFileStatus::EntryPoint => entry.push(i),
VizFileStatus::Clean => {}
}
}
if !unused.is_empty() {
let nodes: Vec<String> = unused.iter().map(|i| format!("n{i}")).collect();
let _ = writeln!(out, " style {} fill:#e5484d,color:#fff", nodes.join(","));
}
if !has_unused.is_empty() {
let nodes: Vec<String> = has_unused.iter().map(|i| format!("n{i}")).collect();
let _ = writeln!(out, " style {} fill:#ffc53d,color:#111", nodes.join(","));
}
if !entry.is_empty() {
let nodes: Vec<String> = entry.iter().map(|i| format!("n{i}")).collect();
let _ = writeln!(out, " style {} fill:#30a46c,color:#fff", nodes.join(","));
}
out
}
#[cfg(test)]
mod tests {
use fallow_engine::viz::{
VizAvailability, VizFile, VizFindingAnalysis, VizFrameworkData, VizHealthCapabilities,
VizHealthData, VizSecurityData, VizStylingData, VizSummary,
};
use super::*;
fn file(path: &str, status: VizFileStatus) -> VizFile {
VizFile {
path: path.to_string(),
size: 100,
status,
export_count: 0,
unused_export_count: 0,
is_entry: false,
importer_count: 0,
import_count: 0,
workspace: None,
zone: None,
unused_exports: Vec::new(),
fn_count: 0,
max_cyclomatic: 0,
max_cognitive: 0,
react_hooks: 0,
jsx_depth: 0,
functions: Vec::new(),
dup_lines: 0,
clone_groups: Vec::new(),
in_cycle: false,
}
}
const fn availability(unit: &'static str) -> VizAvailability {
VizAvailability {
state: VizAvailabilityState::Complete,
count: 0,
unit,
reason: None,
truncated: None,
}
}
fn health_capabilities() -> VizHealthCapabilities {
let unavailable = |unit| VizAvailability {
state: VizAvailabilityState::Unavailable,
count: 0,
unit,
reason: Some("not loaded".to_string()),
truncated: None,
};
VizHealthCapabilities {
complexity: availability("findings"),
maintainability: availability("files"),
crap: availability("files"),
coverage: availability("gaps"),
runtime: unavailable("observations"),
churn: unavailable("files"),
hotspots: unavailable("files"),
ownership: unavailable("files"),
}
}
fn sample_data() -> VizData {
let complete = |unit| VizFindingAnalysis {
availability: availability(unit),
findings_truncated: None,
findings: Vec::new(),
};
VizData {
root: "proj".to_string(),
files: vec![
file("src/index.ts", VizFileStatus::EntryPoint),
file("src/dead.ts", VizFileStatus::Unused),
file("src/lib.ts", VizFileStatus::Clean),
],
edges: vec![[0, 2, 0]],
summary: VizSummary {
total_files: 3,
total_size: 300,
total_edges: 1,
unused_files: 1,
unused_exports: 0,
unused_types: 0,
unused_deps: 0,
unresolved_imports: 0,
circular_deps: 0,
clone_groups: 0,
duplicated_lines: 0,
boundary_violations: 0,
hotspot_files: 0,
clone_groups_truncated: None,
},
workspaces: Vec::new(),
zones: Vec::new(),
cycles: Vec::new(),
clones: Vec::new(),
violations: Vec::new(),
architecture: complete("violations"),
dependencies: complete("findings"),
health: VizHealthData {
availability: availability("files"),
capabilities: health_capabilities(),
shared_parse: Some(true),
score: None,
grade: None,
average_maintainability: None,
files: Vec::new(),
files_truncated: None,
findings_truncated: None,
findings: Vec::new(),
},
security: VizSecurityData {
availability: VizAvailability {
state: VizAvailabilityState::Complete,
count: 0,
unit: "observations",
reason: None,
truncated: None,
},
runtime_availability: VizAvailability {
state: VizAvailabilityState::Unavailable,
count: 0,
unit: "candidates",
reason: Some("No runtime coverage input was provided".to_string()),
truncated: None,
},
candidates: Vec::new(),
blind_spot_count: 0,
blind_spots_truncated: None,
blind_spots: Vec::new(),
},
frameworks: {
let analysis = complete("findings");
VizFrameworkData {
availability: analysis.availability,
detector_availability: availability("detectors"),
findings_truncated: analysis.findings_truncated,
findings: analysis.findings,
detected_frameworks: Vec::new(),
detectors: Vec::new(),
}
},
styling: {
let analysis = complete("findings");
VizStylingData {
availability: analysis.availability,
findings_truncated: analysis.findings_truncated,
findings: analysis.findings,
score: None,
grade: None,
confidence: None,
summary: None,
}
},
feature_flags: complete("flags"),
}
}
#[test]
fn html_escape_escapes_injection_characters() {
assert_eq!(
html_escape(r#"<script>"a"&'b'</script>"#),
"<script>"a"&'b'</script>"
);
}
#[test]
fn embedded_json_contains_no_raw_angle_bracket() {
let mut data = sample_data();
data.files[0].path = "</script><!--<script>alert(1)".to_string();
let json = serde_json::to_string(&data).expect("serialize viz data");
let escaped = escape_payload_json(&json);
assert!(!escaped.contains('<'));
assert!(!escaped.contains("</"));
}
#[test]
fn escaped_payload_round_trips() {
let mut data = sample_data();
data.files[0].path = "</script><!--<script>alert(1)".to_string();
let json = serde_json::to_string(&data).expect("serialize viz data");
let escaped = escape_payload_json(&json);
let value: serde_json::Value =
serde_json::from_str(&escaped).expect("escaped payload stays valid JSON");
assert_eq!(value["files"][0]["path"], "</script><!--<script>alert(1)");
}
#[test]
fn render_html_embeds_the_payload_and_static_assets() {
let html = render_html(&sample_data()).expect("render viz HTML");
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains(r#"<script type="application/json" id="fallow-data" data-tables>{"#));
assert!(html.contains(
r#"<script type="application/json" data-fallow-lazy="health.findings">[]</script>"#
));
assert!(html.contains("\"files\":{\"$k\":["));
assert!(html.contains("<style>"));
assert!(html.contains("</html>"));
}
#[test]
fn render_html_writes_the_static_shell_before_the_scripts() {
let mut data = sample_data();
data.root = "<b>proj</b>".to_string();
let html = render_html(&data).expect("render viz HTML");
assert!(html.contains(r#"<html lang="en" data-theme="dark">"#));
let shell = html
.find(r#"<div id="app" data-static-shell>"#)
.expect("static shell in the page");
let first_script = html.find("<script").expect("script tag");
assert!(shell < first_script);
assert!(html.contains(r#"<span class="project"><b>proj</b></span"#));
assert!(!html.contains("__FALLOW_ROOT__"));
}
#[test]
fn render_html_escapes_markup_in_every_payload_tag() {
let mut data = sample_data();
data.files[0].path = "</script><!--<script>alert(1)".to_string();
data.health.grade = Some("</script>".to_string());
let html = render_html(&data).expect("render viz HTML");
assert!(!html.contains("</script><!--"));
assert!(!html.contains("\"</script>"));
assert!(html.contains("\\u003c/script>\\u003c!--\\u003cscript>alert(1)"));
}
#[test]
fn generate_dot_emits_nodes_edges_and_status_colors() {
let dot = generate_dot(&sample_data());
assert!(dot.starts_with("digraph fallow {"));
assert!(dot.contains("n0 [label=\"src/index.ts\", fillcolor=\"#30a46c\""));
assert!(dot.contains("n1 [label=\"src/dead.ts\", fillcolor=\"#e5484d\""));
assert!(dot.contains("n0 -> n2;"));
assert!(dot.trim_end().ends_with('}'));
}
#[test]
fn generate_dot_escapes_quotes_and_backslashes_in_paths() {
let mut data = sample_data();
data.files[0].path = r#"a\b"c.ts"#.to_string();
let dot = generate_dot(&data);
assert!(dot.contains(r#"label="a\\b\"c.ts""#));
}
#[test]
fn generate_mermaid_emits_graph_nodes_edges_and_styles() {
let mermaid = generate_mermaid(&sample_data());
assert!(mermaid.starts_with("graph LR\n"));
assert!(mermaid.contains("n0[\"src/index.ts\"]"));
assert!(mermaid.contains("n0 --> n2"));
assert!(mermaid.contains("style n1 fill:#e5484d"));
assert!(mermaid.contains("style n0 fill:#30a46c"));
}
#[test]
fn generate_dot_strips_control_characters_from_paths() {
let mut data = sample_data();
data.files[0].path = "evil\nname\".ts".to_string();
let dot = generate_dot(&data);
assert!(dot.contains(r#"n0 [label="evil name\".ts""#));
assert!(!dot.contains("evil\n"));
}
#[test]
fn generate_mermaid_strips_control_characters_from_paths() {
let mut data = sample_data();
data.files[0].path = "evil\r\nname\t[].ts".to_string();
let mermaid = generate_mermaid(&data);
assert!(mermaid.contains("n0[\"evil name#91;#93;.ts\"]"));
assert!(!mermaid.contains("evil\r"));
assert!(!mermaid.contains("evil\n"));
}
#[test]
fn mermaid_escapes_dynamic_route_brackets() {
let mut data = sample_data();
data.files[0].path = "app/routes/[id]/page.tsx".to_string();
let mermaid = generate_mermaid(&data);
assert!(mermaid.contains("n0[\"app/routes/#91;id#93;/page.tsx\"]"));
assert!(!mermaid.contains("[id]"));
}
#[cfg(unix)]
#[test]
fn write_refuses_symlink_target() {
let temp = tempfile::tempdir().expect("tempdir");
let real = temp.path().join("real.txt");
std::fs::write(&real, "secret").expect("write real target");
let link = temp.path().join("fallow-viz.html");
std::os::unix::fs::symlink(&real, &link).expect("plant symlink");
let result = write_output(&link, "<html></html>");
let message = result.expect_err("symlink target must be refused");
assert!(message.contains("Refusing to write through symlink"));
assert_eq!(
std::fs::read_to_string(&real).expect("read real target"),
"secret"
);
}
#[test]
fn write_creates_parent_dirs() {
let temp = tempfile::tempdir().expect("tempdir");
let target = temp.path().join("nested/dir/out.html");
write_output(&target, "<html></html>").expect("write with parent creation");
assert_eq!(
std::fs::read_to_string(&target).expect("read output"),
"<html></html>"
);
}
#[test]
fn text_format_honors_out_path() {
let temp = tempfile::tempdir().expect("tempdir");
let target = temp.path().join("graph.dot");
let config_path = None;
let opts = VizOptions {
root: temp.path(),
config_path: &config_path,
no_cache: false,
threads: 1,
quiet: true,
production: false,
allow_remote_extends: false,
output_path: Some(target.as_path()),
no_open: true,
format: VizFormat::Dot,
};
let _ = write_text_format(&opts, "digraph fallow {}\n");
assert_eq!(
std::fs::read_to_string(&target).expect("--out should have been written"),
"digraph fallow {}\n"
);
}
}