use super::common::{create_config, fixture_path};
fn force_symlink(target: &std::path::Path, link: &std::path::Path) {
if link.symlink_metadata().is_ok() {
if link.is_dir() && !link.is_symlink() {
let _ = std::fs::remove_dir_all(link);
} else {
let _ = std::fs::remove_file(link);
}
}
#[cfg(unix)]
std::os::unix::fs::symlink(target, link).expect("symlink creation should succeed");
#[cfg(windows)]
std::os::windows::fs::symlink_dir(target, link).expect("symlink creation should succeed");
}
#[test]
fn workspace_patterns_from_package_json() {
let pkg: fallow_config::PackageJson =
serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
let patterns = pkg.workspace_patterns();
assert_eq!(patterns, vec!["packages/*", "apps/*"]);
}
#[test]
fn workspace_patterns_yarn_format() {
let pkg: fallow_config::PackageJson =
serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
let patterns = pkg.workspace_patterns();
assert_eq!(patterns, vec!["packages/*"]);
}
#[test]
fn workspace_project_discovers_workspace_packages() {
let root = fixture_path("workspace-project");
let nm = root.join("node_modules");
let _ = std::fs::create_dir_all(nm.join("@workspace"));
force_symlink(&root.join("packages/shared"), &nm.join("shared"));
force_symlink(&root.join("packages/utils"), &nm.join("@workspace/utils"));
let config = create_config(root);
let results = fallow_core::analyze(&config).expect("analysis should succeed");
let unused_file_names: Vec<String> = results
.unused_files
.iter()
.map(|f| f.path.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(
unused_file_names.contains(&"orphan.ts".to_string()),
"orphan.ts should be detected as unused file, found: {unused_file_names:?}"
);
assert!(
!unused_file_names.contains(&"deep.ts".to_string()),
"deep.ts should NOT be unused (reachable via cross-workspace import through symlink), \
but found in unused files: {unused_file_names:?}"
);
let unused_export_names: Vec<String> = results
.unused_exports
.iter()
.map(|e| e.export_name.clone())
.collect();
assert!(
unused_export_names.contains(&"unusedDeep".to_string()),
"unusedDeep should be detected as unused export, found: {unused_export_names:?}"
);
assert!(
results.unresolved_imports.is_empty(),
"should have no unresolved imports, found: {:?}",
results
.unresolved_imports
.iter()
.map(|i| &i.specifier)
.collect::<Vec<_>>()
);
assert!(
results.has_issues(),
"workspace project should have issues detected"
);
}
#[test]
fn project_state_stable_file_ids_by_path() {
let root = fixture_path("workspace-project");
let config = create_config(root);
let files_a = fallow_core::discover::discover_files(&config);
let files_b = fallow_core::discover::discover_files(&config);
assert_eq!(files_a.len(), files_b.len());
for (a, b) in files_a.iter().zip(files_b.iter()) {
assert_eq!(a.id, b.id, "FileId mismatch for {:?}", a.path);
assert_eq!(a.path, b.path);
}
for window in files_a.windows(2) {
assert!(
window[0].path <= window[1].path,
"Files not sorted by path: {:?} > {:?}",
window[0].path,
window[1].path
);
}
}
#[test]
fn project_state_workspace_queries() {
use fallow_config::discover_workspaces;
let root = fixture_path("workspace-project");
let config = create_config(root.clone());
let files = fallow_core::discover::discover_files(&config);
let workspaces = discover_workspaces(&root);
let project = fallow_core::project::ProjectState::new(files, workspaces);
assert!(project.workspace_by_name("app").is_some());
assert!(project.workspace_by_name("shared").is_some());
assert!(project.workspace_by_name("@workspace/utils").is_some());
assert!(project.workspace_by_name("nonexistent").is_none());
let app_ws = project.workspace_by_name("app").unwrap();
let app_files = project.files_in_workspace(app_ws);
assert!(
!app_files.is_empty(),
"app workspace should have at least one file"
);
for fid in &app_files {
if let Some(file) = project.file_by_id(*fid) {
assert!(
file.path.starts_with(&app_ws.root),
"File {:?} should be under app workspace root {:?}",
file.path,
app_ws.root
);
}
}
}
#[test]
fn workspace_exports_map_resolves_subpath_imports() {
let root = fixture_path("workspace-exports-map");
let nm = root.join("node_modules");
let _ = std::fs::create_dir_all(nm.join("@workspace"));
force_symlink(&root.join("packages/ui"), &nm.join("@workspace/ui"));
let config = create_config(root);
let results = fallow_core::analyze(&config).expect("analysis should succeed");
let unused_file_names: Vec<String> = results
.unused_files
.iter()
.map(|f| f.path.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(
unused_file_names.contains(&"orphan.ts".to_string()),
"orphan.ts should be detected as unused file, found: {unused_file_names:?}"
);
assert!(
!unused_file_names.contains(&"utils.ts".to_string()),
"utils.ts should be reachable via exports map subpath import, unused: {unused_file_names:?}"
);
assert!(
!unused_file_names.contains(&"helpers.ts".to_string()),
"helpers.ts should be reachable via dist→src fallback from exports map, unused: {unused_file_names:?}"
);
assert!(
!unused_file_names.contains(&"internal.ts".to_string()),
"internal.ts should be reachable via import from utils.ts, unused: {unused_file_names:?}"
);
let unused_export_names: Vec<&str> = results
.unused_exports
.iter()
.map(|e| e.export_name.as_str())
.collect();
assert!(
unused_export_names.contains(&"unusedInternal"),
"unusedInternal should be unused (internal.ts is not an entry point), found: {unused_export_names:?}"
);
assert!(
!unused_export_names.contains(&"internalHelper"),
"internalHelper should be used (imported by utils.ts)"
);
assert!(
results.unresolved_imports.is_empty(),
"should have no unresolved imports, found: {:?}",
results
.unresolved_imports
.iter()
.map(|i| &i.specifier)
.collect::<Vec<_>>()
);
}
#[test]
fn workspace_nested_exports_resolves_dist_to_source() {
let root = fixture_path("workspace-nested-exports");
let nm = root.join("node_modules");
let _ = std::fs::create_dir_all(nm.join("@workspace"));
force_symlink(&root.join("packages/ui"), &nm.join("@workspace/ui"));
let config = create_config(root);
let results = fallow_core::analyze(&config).expect("analysis should succeed");
let unused_file_names: Vec<String> = results
.unused_files
.iter()
.map(|f| {
f.path
.to_string_lossy()
.replace('\\', "/")
.rsplit('/')
.next()
.unwrap_or_default()
.to_string()
})
.collect();
assert!(
!unused_file_names.contains(&"index.ts".to_string()),
"index.ts should be reachable via exports map root entry, unused: {unused_file_names:?}"
);
assert!(
!unused_file_names.contains(&"utils.ts".to_string()),
"utils.ts should be reachable via dist/esm/utils.mjs→src/utils.ts fallback, \
unused: {unused_file_names:?}"
);
assert!(
!unused_file_names.contains(&"Button.ts".to_string()),
"Button.ts should be reachable via dist/esm/components/Button.mjs→src/components/Button.ts \
fallback, unused: {unused_file_names:?}"
);
let unused_export_names: Vec<&str> = results
.unused_exports
.iter()
.map(|e| e.export_name.as_str())
.collect();
assert!(
!unused_export_names.contains(&"unusedComponent"),
"unusedComponent should NOT be flagged (index.ts is an entry point)"
);
assert!(
unused_export_names.contains(&"unusedUtil"),
"unusedUtil should be unused (utils.ts export not imported by app), \
found: {unused_export_names:?}"
);
assert!(
unused_export_names.contains(&"unusedButtonHelper"),
"unusedButtonHelper should be unused (Button.ts export not imported by app), \
found: {unused_export_names:?}"
);
assert!(
!unused_export_names.contains(&"Card"),
"Card should be used (imported by app)"
);
assert!(
!unused_export_names.contains(&"formatColor"),
"formatColor should be used (imported by app)"
);
assert!(
!unused_export_names.contains(&"Button"),
"Button should be used (imported by app)"
);
assert!(
results.unresolved_imports.is_empty(),
"should have no unresolved imports, found: {:?}",
results
.unresolved_imports
.iter()
.map(|i| &i.specifier)
.collect::<Vec<_>>()
);
}
#[test]
fn tsconfig_references_discovers_workspaces() {
use fallow_config::discover_workspaces;
let root = fixture_path("tsconfig-references");
let workspaces = discover_workspaces(&root);
assert!(
workspaces.len() >= 2,
"Expected at least 2 workspaces from tsconfig references, got: {workspaces:?}"
);
assert!(
workspaces.iter().any(|ws| ws.name == "@project/core"),
"Should discover @project/core from package.json name: {workspaces:?}"
);
assert!(
workspaces.iter().any(|ws| ws.name == "ui"),
"Should discover ui from directory name (no package.json): {workspaces:?}"
);
}
#[test]
fn tsconfig_references_analysis_detects_unused() {
let root = fixture_path("tsconfig-references");
let config = create_config(root);
let results = fallow_core::analyze(&config).expect("analysis should succeed");
let unused_file_names: Vec<String> = results
.unused_files
.iter()
.map(|f| f.path.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(
unused_file_names.contains(&"unused.ts".to_string()),
"unused.ts should be detected as unused file: {unused_file_names:?}"
);
assert!(
unused_file_names.contains(&"orphan.ts".to_string()),
"orphan.ts should be detected as unused file: {unused_file_names:?}"
);
assert!(
!unused_file_names.contains(&"index.ts".to_string()),
"index.ts should not be unused: {unused_file_names:?}"
);
}