use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::tempdir;
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/monorepo")
}
fn chakra_example_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/chakra-ui")
}
#[cfg(feature = "python")]
fn python_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/python")
}
#[cfg(feature = "python")]
fn fastapi_example_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/fastapi")
}
#[cfg(feature = "rust")]
fn rust_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/rust")
}
#[cfg(all(feature = "vue", feature = "svelte"))]
fn component_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/components")
}
#[cfg(feature = "ruby")]
fn ruby_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ruby")
}
#[cfg(feature = "java")]
fn java_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/java")
}
fn copy_dir(from: &Path, to: &Path) {
fs::create_dir_all(to).unwrap();
for entry in fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let source_path = entry.path();
let target_path = to.join(entry.file_name());
let file_type = entry.file_type().unwrap();
if file_type.is_dir() {
copy_dir(&source_path, &target_path);
} else {
fs::copy(&source_path, &target_path).unwrap();
}
}
}
fn setup_repo() -> tempfile::TempDir {
let dir = tempdir().unwrap();
copy_dir(&fixture_root(), dir.path());
dir
}
fn run_json(repo: &Path, args: &[&str]) -> Value {
let mut command = AssertCommand::cargo_bin("blast-radius").unwrap();
command
.current_dir(repo)
.args(["--repo-root", repo.to_str().unwrap(), "--format", "json"])
.args(args);
let output = command.assert().success().get_output().stdout.clone();
serde_json::from_slice(&output).unwrap()
}
fn node_labels(json: &Value) -> Vec<String> {
json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect()
}
fn label_count(labels: &[String], expected: &str) -> usize {
labels
.iter()
.filter(|label| label.as_str() == expected)
.count()
}
#[test]
fn export_mode_reports_transitive_blast_radius() {
let repo = setup_repo();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"--format",
"json",
"export",
"packages/ui/src/Button.tsx",
"Button",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(
labels
.iter()
.any(|label| label.contains("packages/ui/src/Card.tsx"))
);
assert!(
labels
.iter()
.any(|label| label.contains("packages/ui/src/Toolbar.tsx"))
);
assert!(
labels
.iter()
.any(|label| label.contains("packages/ui/src/index.ts#Button"))
);
assert!(
labels
.iter()
.any(|label| label.contains("apps/storefront/src/PromoCard.tsx"))
);
assert!(
labels
.iter()
.any(|label| label.contains("apps/storefront/src/App.tsx"))
);
assert!(
labels
.iter()
.any(|label| label.contains("apps/storefront/src/LegacyButtonCard.jsx"))
);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
}
#[test]
fn export_mode_keeps_default_and_named_imports_separate() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(
repo.path().join("src/source.ts"),
"export default function DefaultThing() {}\nexport function namedThing() {}\n",
)
.unwrap();
fs::write(
repo.path().join("src/default-consumer.ts"),
"import DefaultThing from './source';\nexport const useDefault = () => DefaultThing();\n",
)
.unwrap();
fs::write(
repo.path().join("src/named-consumer.ts"),
"import { namedThing } from './source';\nexport const useNamed = () => namedThing();\n",
)
.unwrap();
let named = run_json(repo.path(), &["export", "src/source.ts", "namedThing"]);
let labels = node_labels(&named);
assert!(labels.iter().any(|label| label == "src/named-consumer.ts"));
assert!(
!labels
.iter()
.any(|label| label == "src/default-consumer.ts")
);
assert!(
named["edges"].as_array().unwrap().iter().any(|edge| {
edge["to"]
.as_str()
.is_some_and(|to| to.contains("src/named-consumer.ts"))
&& edge["kind"] == "imports_named"
}),
"ordinary named import usage should not be reported as JSX component usage"
);
assert!(
!named["edges"].as_array().unwrap().iter().any(|edge| {
edge["to"]
.as_str()
.is_some_and(|to| to.contains("src/named-consumer.ts"))
&& edge["kind"] == "uses_jsx_component"
}),
"ordinary named import usage was incorrectly reported as JSX component usage"
);
let default = run_json(repo.path(), &["export", "src/source.ts", "default"]);
let labels = node_labels(&default);
assert!(
labels
.iter()
.any(|label| label == "src/default-consumer.ts")
);
assert!(!labels.iter().any(|label| label == "src/named-consumer.ts"));
}
#[test]
fn export_mode_tracks_namespace_member_usage() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(
repo.path().join("src/source.ts"),
"export function alpha() {}\nexport function beta() {}\n",
)
.unwrap();
fs::write(
repo.path().join("src/consumer.ts"),
"import * as source from './source';\nexport const useAlpha = () => source.alpha();\n",
)
.unwrap();
let alpha = run_json(repo.path(), &["export", "src/source.ts", "alpha"]);
assert!(
node_labels(&alpha)
.iter()
.any(|label| label == "src/consumer.ts")
);
let beta = run_json(repo.path(), &["export", "src/source.ts", "beta"]);
assert!(
!node_labels(&beta)
.iter()
.any(|label| label == "src/consumer.ts")
);
}
#[test]
fn export_mode_follows_star_reexport_chains() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(
repo.path().join("src/source.ts"),
"export const target = 1;\n",
)
.unwrap();
fs::write(
repo.path().join("src/barrel-one.ts"),
"export * from './source';\n",
)
.unwrap();
fs::write(
repo.path().join("src/barrel-two.ts"),
"export * from './barrel-one';\n",
)
.unwrap();
fs::write(
repo.path().join("src/app.ts"),
"import { target } from './barrel-two';\nexport const value = target;\n",
)
.unwrap();
let json = run_json(repo.path(), &["export", "src/source.ts", "target"]);
let labels = node_labels(&json);
assert!(labels.iter().any(|label| label == "src/barrel-one.ts"));
assert!(labels.iter().any(|label| label == "src/barrel-two.ts"));
assert!(labels.iter().any(|label| label == "src/app.ts"));
assert_eq!(
json["edges"]
.as_array()
.unwrap()
.iter()
.filter(|edge| edge["kind"].as_str() == Some("reexports_star"))
.count(),
2
);
}
#[test]
fn files_mode_deduplicates_overlapping_multi_root_impact() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(repo.path().join("src/source-a.ts"), "export const a = 1;\n").unwrap();
fs::write(repo.path().join("src/source-b.ts"), "export const b = 2;\n").unwrap();
fs::write(
repo.path().join("src/app.ts"),
"import { a } from './source-a';\nimport { b } from './source-b';\nexport const value = a + b;\n",
)
.unwrap();
let json = run_json(
repo.path(),
&["files", "src/source-a.ts", "src/source-b.ts"],
);
let labels = node_labels(&json);
assert_eq!(label_count(&labels, "src/app.ts"), 1);
assert_eq!(json["roots"].as_array().unwrap().len(), 2);
assert_eq!(json["summary"]["total_affected_files"].as_u64().unwrap(), 3);
}
#[test]
fn files_mode_skips_unknown_inputs_and_analyzes_the_rest() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(repo.path().join("src/source-a.ts"), "export const a = 1;\n").unwrap();
fs::write(repo.path().join("src/source-b.ts"), "export const b = 2;\n").unwrap();
fs::write(
repo.path().join("src/app.ts"),
"import { a } from './source-a';\nimport { b } from './source-b';\nexport const value = a + b;\n",
)
.unwrap();
fs::write(repo.path().join("src/styles.css"), ".x { color: red; }\n").unwrap();
let json = run_json(
repo.path(),
&[
"files",
"src/source-a.ts",
"src/does-not-exist.ts",
"src/styles.css",
"src/source-b.ts",
],
);
let labels = node_labels(&json);
assert_eq!(label_count(&labels, "src/app.ts"), 1);
assert_eq!(json["roots"].as_array().unwrap().len(), 2);
assert_eq!(json["summary"]["total_affected_files"].as_u64().unwrap(), 3);
assert_eq!(json["summary"]["skipped_inputs"].as_u64().unwrap(), 2);
let warnings = json["warnings"]
.as_array()
.unwrap()
.iter()
.filter_map(|warning| warning.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(warnings.contains("src/does-not-exist.ts"));
assert!(warnings.contains("not found on disk"));
assert!(warnings.contains("styles.css"));
assert!(warnings.contains("not a recognized source file"));
}
#[test]
fn files_mode_with_all_unknown_inputs_reports_empty_with_warnings() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(repo.path().join("src/real.ts"), "export const x = 1;\n").unwrap();
fs::write(repo.path().join("src/styles.css"), ".x { color: red; }\n").unwrap();
let json = run_json(repo.path(), &["files", "src/gone.ts", "src/styles.css"]);
assert_eq!(json["summary"]["total_affected_files"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["skipped_inputs"].as_u64().unwrap(), 2);
assert!(json["roots"].as_array().unwrap().is_empty());
let warnings = json["warnings"]
.as_array()
.unwrap()
.iter()
.filter_map(|warning| warning.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(warnings.contains("no recognized source files among 2 input paths"));
}
#[test]
fn fail_on_risk_gates_exit_code_at_or_above_tier() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(repo.path().join("src/source.ts"), "export const x = 1;\n").unwrap();
for name in ["a", "b", "c", "d"] {
fs::write(
repo.path().join(format!("src/{name}.ts")),
"import { x } from './source';\nexport const v = x + 1;\n",
)
.unwrap();
}
let json = run_json(repo.path(), &["file", "src/source.ts"]);
assert_eq!(json["summary"]["risk_tier"].as_str().unwrap(), "moderate");
AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"--fail-on-risk",
"moderate",
"file",
"src/source.ts",
])
.assert()
.failure()
.code(2);
AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"--fail-on-risk",
"risky",
"file",
"src/source.ts",
])
.assert()
.success();
}
#[test]
fn file_mode_reports_tree_output() {
let repo = setup_repo();
AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"file",
"packages/ui/src/Button.tsx",
])
.assert()
.success()
.stdout(predicate::str::contains("IMPACTED FILES"))
.stdout(predicate::str::contains("confidence:"))
.stdout(predicate::str::contains("packages/ui"))
.stdout(predicate::str::contains("apps/storefront/src/App.tsx"));
AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"file",
"packages/ui/src/Button.tsx",
"--verbose",
])
.assert()
.success()
.stdout(predicate::str::contains("CASCADE · OVERVIEW"))
.stdout(predicate::str::contains("CASCADE · PATHS"))
.stdout(predicate::str::contains("packages/ui/src/Card.tsx"))
.stdout(predicate::str::contains("apps/storefront/src/App.tsx"));
}
#[test]
fn graph_formats_render() {
let repo = setup_repo();
AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"--format",
"mermaid",
"export",
"packages/ui/src/Button.tsx",
"Button",
])
.assert()
.success()
.stdout(predicate::str::contains("graph TD"));
AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"--format",
"dot",
"export",
"packages/ui/src/Button.tsx",
"Button",
])
.assert()
.success()
.stdout(predicate::str::contains("digraph blast_radius"));
}
#[test]
fn files_mode_breaks_down_each_changed_file() {
let repo = setup_repo();
fs::write(
repo.path().join("packages/ui/src/Button.tsx"),
"export const Button = () => <button>changed</button>;\nexport default Button;\n",
)
.unwrap();
fs::write(
repo.path().join("packages/ui/src/Card.tsx"),
"import { Button } from './Button';\nexport const Card = () => <div><Button /> changed</div>;\n",
)
.unwrap();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"--format",
"json",
"files",
"packages/ui/src/Button.tsx",
"packages/ui/src/Card.tsx",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
let roots = json["roots"].as_array().unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.iter().all(|root| root["affected"].is_number()));
AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"files",
"packages/ui/src/Button.tsx",
"packages/ui/src/Card.tsx",
])
.assert()
.success()
.stdout(predicate::str::contains("IMPACT BY INPUT FILE"))
.stdout(predicate::str::contains("input files"))
.stdout(predicate::str::contains("impacted file"));
}
#[test]
fn file_mode_skips_unparseable_files_and_reports_them() {
let repo = setup_repo();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(
repo.path().join("src").join("template.js"),
"export default makeThing({{{placeholder}}});\n",
)
.unwrap();
fs::write(
repo.path().join("src").join("index.js"),
"export const ok = () => null;\n",
)
.unwrap();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(repo.path())
.args([
"--repo-root",
repo.path().to_str().unwrap(),
"--format",
"json",
"file",
"src/index.js",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 1);
let warnings = json["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|warning| {
warning
.as_str()
.is_some_and(|warning| warning.contains("could not be parsed"))
}));
}
#[test]
fn chakra_ui_example_analyzes_real_world_repo() {
let repo = chakra_example_root();
if !repo.join("package.json").exists() {
eprintln!(
"skipping chakra_ui_example_analyzes_real_world_repo: examples/chakra-ui \
not fetched (run scripts/fetch-examples.sh)"
);
return;
}
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"packages/react/src/components/button/button.tsx",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert!(json["summary"]["total_affected_files"].as_u64().unwrap() > 100);
assert!(json["summary"]["unresolved_imports"].as_u64().unwrap() <= 1);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(
labels
.iter()
.any(|label| label.contains("packages/react/__stories__/button.stories.tsx"))
);
assert!(
labels
.iter()
.any(|label| label.contains("apps/compositions/src/examples/button-basic.tsx"))
);
}
#[test]
fn vite_example_analyzes_real_world_repo() {
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/vite-react-ts");
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"src/App.tsx",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["total_affected_files"].as_u64().unwrap(), 2);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(labels.iter().any(|label| label == "src/main.tsx"));
}
#[cfg(feature = "python")]
#[test]
fn python_file_mode_reports_transitive_blast_radius() {
let repo = python_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"app/utils/formatting.py",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(labels.iter().any(|label| label == "app/services/email.py"));
assert!(labels.iter().any(|label| label == "app/main.py"));
assert!(labels.iter().any(|label| label == "tests/test_main.py"));
}
#[cfg(feature = "python")]
#[test]
fn python_export_mode_tracks_reexports() {
let repo = python_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"export",
"app/services/email.py",
"send_email",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(
labels
.iter()
.any(|label| label.contains("app/services/__init__.py#send_email"))
);
assert!(labels.iter().any(|label| label == "app/main.py"));
}
#[cfg(feature = "python")]
#[test]
fn fastapi_example_analyzes_real_world_python_repo() {
let repo = fastapi_example_root();
if !repo.join("pyproject.toml").exists() {
eprintln!(
"skipping fastapi_example_analyzes_real_world_python_repo: examples/fastapi \
not fetched (run scripts/fetch-examples.sh)"
);
return;
}
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"fastapi/applications.py",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert!(json["summary"]["unresolved_imports"].as_u64().unwrap() <= 1);
assert!(json["source_file_count"].as_u64().unwrap() > 1_000);
assert!(json["summary"]["total_affected_files"].as_u64().unwrap() > 600);
}
#[cfg(feature = "rust")]
#[test]
fn rust_file_mode_reports_transitive_blast_radius() {
let repo = rust_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"src/utils/formatting.rs",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(labels.iter().any(|label| label == "src/services/email.rs"));
assert!(labels.iter().any(|label| label == "src/services/mod.rs"));
assert!(labels.iter().any(|label| label == "src/lib.rs"));
assert!(labels.iter().any(|label| label == "src/main.rs"));
}
#[cfg(feature = "rust")]
#[test]
fn rust_export_mode_tracks_reexports() {
let repo = rust_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"export",
"src/services/email.rs",
"send_email",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(
labels
.iter()
.any(|label| label.contains("src/services/mod.rs#send_email"))
);
assert!(
labels
.iter()
.any(|label| label.contains("src/lib.rs#send_email"))
);
}
#[cfg(all(feature = "vue", feature = "svelte"))]
#[test]
fn component_file_mode_reports_vue_svelte_transitive_blast_radius() {
let repo = component_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"src/shared.ts",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(labels.iter().any(|label| label == "src/Button.vue"));
assert!(labels.iter().any(|label| label == "src/Card.svelte"));
assert!(labels.iter().any(|label| label == "src/App.ts"));
}
#[cfg(all(feature = "vue", feature = "svelte"))]
#[test]
fn component_file_mode_tracks_default_component_imports() {
let repo = component_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"src/Button.vue",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(labels.iter().any(|label| label == "src/Card.svelte"));
assert!(labels.iter().any(|label| label == "src/App.ts"));
}
#[cfg(feature = "ruby")]
#[test]
fn ruby_file_mode_reports_transitive_blast_radius() {
let repo = ruby_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"lib/app/utils/formatter.rb",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(
labels
.iter()
.any(|label| label == "lib/app/services/email_service.rb")
);
assert!(labels.iter().any(|label| label == "lib/app.rb"));
}
#[cfg(feature = "ruby")]
#[test]
fn ruby_suffix_ambiguity_is_reported_as_warning() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("app/utils")).unwrap();
fs::create_dir_all(repo.path().join("lib/utils")).unwrap();
fs::write(
repo.path().join("app/utils/formatter.rb"),
"class Formatter; end",
)
.unwrap();
fs::write(
repo.path().join("lib/utils/formatter.rb"),
"class Formatter; end",
)
.unwrap();
fs::write(
repo.path().join("lib/app.rb"),
"require 'utils/formatter'\nclass App; end",
)
.unwrap();
let json = run_json(repo.path(), &["file", "lib/app.rb"]);
let warnings = json["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|warning| {
warning
.as_str()
.is_some_and(|warning| warning.contains("ambiguous suffix resolution"))
&& warning
.as_str()
.is_some_and(|warning| warning.contains("utils/formatter.rb"))
}));
}
#[cfg(feature = "java")]
#[test]
fn java_file_mode_reports_transitive_blast_radius() {
let repo = java_fixture_root();
let output = AssertCommand::cargo_bin("blast-radius")
.unwrap()
.current_dir(&repo)
.args([
"--repo-root",
repo.to_str().unwrap(),
"--format",
"json",
"file",
"src/main/java/com/example/util/Formatter.java",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["summary"]["parse_failures"].as_u64().unwrap(), 0);
assert_eq!(json["summary"]["unresolved_imports"].as_u64().unwrap(), 0);
let labels: Vec<String> = json["nodes"]
.as_array()
.unwrap()
.iter()
.filter_map(|node| node["label"].as_str().map(ToOwned::to_owned))
.collect();
assert!(
labels
.iter()
.any(|label| label == "src/main/java/com/example/service/EmailService.java")
);
assert!(
labels
.iter()
.any(|label| label == "src/main/java/com/example/App.java")
);
}
#[cfg(feature = "java")]
#[test]
fn java_suffix_ambiguity_is_reported_as_warning() {
let repo = tempdir().unwrap();
fs::create_dir_all(repo.path().join("src/main/java/com/example/model")).unwrap();
fs::create_dir_all(repo.path().join("src/test/java/com/example/model")).unwrap();
fs::create_dir_all(repo.path().join("src/main/java/com/example")).unwrap();
fs::write(
repo.path()
.join("src/main/java/com/example/model/User.java"),
"package com.example.model; public class User {}",
)
.unwrap();
fs::write(
repo.path()
.join("src/test/java/com/example/model/User.java"),
"package com.example.model; public class User {}",
)
.unwrap();
fs::write(
repo.path().join("src/main/java/com/example/App.java"),
"package com.example; import com.example.model.User; public class App {}",
)
.unwrap();
let json = run_json(repo.path(), &["file", "src/main/java/com/example/App.java"]);
let warnings = json["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|warning| {
warning
.as_str()
.is_some_and(|warning| warning.contains("ambiguous suffix resolution"))
&& warning
.as_str()
.is_some_and(|warning| warning.contains("com/example/model/User.java"))
}));
}