use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::job::normalize_path;
const JS_MODULE_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"];
#[derive(Debug, Clone, Default)]
pub(crate) struct EntryPointSet {
liveness_root_files: BTreeSet<PathBuf>,
public_api_files: BTreeSet<PathBuf>,
warnings: Vec<String>,
}
impl EntryPointSet {
pub(crate) fn is_entry_point(&self, file: &Path) -> bool {
self.is_liveness_root_file(file)
}
pub(crate) fn is_liveness_root_file(&self, file: &Path) -> bool {
contains_path(&self.liveness_root_files, file)
}
pub(crate) fn is_public_api_file(&self, file: &Path) -> bool {
contains_path(&self.public_api_files, file)
}
pub(crate) fn public_api_files_relative(&self, project_root: &Path) -> BTreeSet<String> {
self.public_api_files
.iter()
.map(|file| relative_path(project_root, file))
.collect()
}
pub(crate) fn warnings(&self) -> &[String] {
&self.warnings
}
fn insert_liveness_root(&mut self, path: &Path) {
self.liveness_root_files.insert(snapshot_path(path));
}
fn insert_public_api_file(&mut self, path: &Path) {
let path = snapshot_path(path);
self.liveness_root_files.insert(path.clone());
self.public_api_files.insert(path);
}
fn warn(&mut self, message: String) {
self.warnings.push(message);
}
fn has_liveness_roots(&self) -> bool {
!self.liveness_root_files.is_empty()
}
}
#[derive(Debug, Default)]
struct ManifestPaths {
cargo_tomls: Vec<PathBuf>,
package_jsons: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy)]
enum EntryPointKind {
LivenessRoot,
PublicApi,
}
pub(crate) fn resolve_entry_points(project_root: &Path) -> EntryPointSet {
let project_root = snapshot_path(project_root);
let mut manifests = ManifestPaths::default();
collect_manifests(&project_root, &mut manifests);
let manifest_found = !manifests.cargo_tomls.is_empty() || !manifests.package_jsons.is_empty();
let mut entry_points = EntryPointSet::default();
for manifest in manifests.cargo_tomls {
collect_cargo_manifest_entry_points(&manifest, &mut entry_points);
}
for manifest in manifests.package_jsons {
collect_package_manifest_entry_points(&manifest, &mut entry_points);
}
if !manifest_found || !entry_points.has_liveness_roots() {
collect_fallback_entry_points(&project_root, &mut entry_points);
}
entry_points
}
pub(crate) fn collect_entry_point_manifests(project_root: &Path) -> Vec<PathBuf> {
let project_root = snapshot_path(project_root);
let mut manifests = ManifestPaths::default();
collect_manifests(&project_root, &mut manifests);
let mut paths = manifests.cargo_tomls;
paths.extend(manifests.package_jsons);
paths.sort();
paths.dedup();
paths
}
fn collect_manifests(project_root: &Path, manifests: &mut ManifestPaths) {
for path in entry_point_walk_files(project_root) {
match path.file_name().and_then(|name| name.to_str()) {
Some("Cargo.toml") => manifests.cargo_tomls.push(path),
Some("package.json") => manifests.package_jsons.push(path),
_ => {}
}
}
manifests.cargo_tomls.sort();
manifests.cargo_tomls.dedup();
manifests.package_jsons.sort();
manifests.package_jsons.dedup();
}
fn entry_point_walk_files(project_root: &Path) -> Vec<PathBuf> {
let mut builder = ignore::WalkBuilder::new(project_root);
builder
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(false)
.add_custom_ignore_filename(".aftignore")
.filter_entry(|entry| {
!entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
|| !should_skip_manifest_dir(entry.path())
});
let mut files = builder
.build()
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_type()
.is_some_and(|file_type| file_type.is_file())
})
.map(|entry| entry.into_path())
.collect::<Vec<_>>();
files.sort();
files
}
fn should_skip_manifest_dir(path: &Path) -> bool {
matches!(
path.file_name().and_then(|name| name.to_str()),
Some(
".git"
| "node_modules"
| "target"
| "venv"
| ".venv"
| "__pycache__"
| ".tox"
| "dist"
| "build"
| ".aft-test-storage"
| ".aft-cache"
)
)
}
fn collect_cargo_manifest_entry_points(manifest: &Path, entry_points: &mut EntryPointSet) {
let package_dir = manifest.parent().unwrap_or_else(|| Path::new("."));
let source = match fs::read_to_string(manifest) {
Ok(source) => source,
Err(error) => {
entry_points.warn(format!("failed to read {}: {error}", manifest.display()));
return;
}
};
let value = match source.parse::<toml::Value>() {
Ok(value) => value,
Err(error) => {
entry_points.warn(format!("failed to parse {}: {error}", manifest.display()));
return;
}
};
let has_package = value.get("package").is_some_and(toml::Value::is_table);
collect_cargo_lib_target(package_dir, &value, has_package, entry_points);
collect_cargo_bin_targets(package_dir, &value, has_package, entry_points);
}
fn collect_cargo_lib_target(
package_dir: &Path,
value: &toml::Value,
has_package: bool,
entry_points: &mut EntryPointSet,
) {
if let Some(lib) = value.get("lib") {
if let Some(path) = lib.get("path").and_then(toml::Value::as_str) {
insert_existing_entry_point(
entry_points,
&package_dir.join(path),
EntryPointKind::PublicApi,
);
} else {
insert_existing_entry_point(
entry_points,
&package_dir.join("src/lib.rs"),
EntryPointKind::PublicApi,
);
}
return;
}
if has_package && package_autodiscovery_enabled(value, "autolib") {
insert_existing_entry_point(
entry_points,
&package_dir.join("src/lib.rs"),
EntryPointKind::PublicApi,
);
}
}
fn collect_cargo_bin_targets(
package_dir: &Path,
value: &toml::Value,
has_package: bool,
entry_points: &mut EntryPointSet,
) {
if let Some(bins) = value.get("bin").and_then(toml::Value::as_array) {
for bin in bins {
if let Some(path) = bin.get("path").and_then(toml::Value::as_str) {
insert_existing_entry_point(
entry_points,
&package_dir.join(path),
EntryPointKind::LivenessRoot,
);
} else if let Some(name) = bin.get("name").and_then(toml::Value::as_str) {
let named_bin = package_dir.join("src/bin").join(format!("{name}.rs"));
if named_bin.is_file() {
insert_existing_entry_point(
entry_points,
&named_bin,
EntryPointKind::LivenessRoot,
);
} else {
insert_existing_entry_point(
entry_points,
&package_dir.join("src/main.rs"),
EntryPointKind::LivenessRoot,
);
}
} else {
insert_existing_entry_point(
entry_points,
&package_dir.join("src/main.rs"),
EntryPointKind::LivenessRoot,
);
}
}
}
if has_package && package_autodiscovery_enabled(value, "autobins") {
insert_existing_entry_point(
entry_points,
&package_dir.join("src/main.rs"),
EntryPointKind::LivenessRoot,
);
collect_cargo_src_bin_targets(package_dir, entry_points);
}
}
fn package_autodiscovery_enabled(value: &toml::Value, key: &str) -> bool {
value
.get("package")
.and_then(|package| package.get(key))
.and_then(toml::Value::as_bool)
.unwrap_or(true)
}
fn collect_cargo_src_bin_targets(package_dir: &Path, entry_points: &mut EntryPointSet) {
let src_bin = package_dir.join("src/bin");
let Ok(entries) = fs::read_dir(src_bin) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) == Some("rs") && path.is_file()
{
insert_existing_entry_point(entry_points, &path, EntryPointKind::LivenessRoot);
}
}
}
fn collect_package_manifest_entry_points(manifest: &Path, entry_points: &mut EntryPointSet) {
let package_dir = manifest.parent().unwrap_or_else(|| Path::new("."));
let source = match fs::read_to_string(manifest) {
Ok(source) => source,
Err(error) => {
entry_points.warn(format!("failed to read {}: {error}", manifest.display()));
return;
}
};
let value = match serde_json::from_str::<Value>(&source) {
Ok(value) => value,
Err(error) => {
entry_points.warn(format!("failed to parse {}: {error}", manifest.display()));
return;
}
};
let mut public_entries = BTreeSet::new();
if let Some(main) = value.get("main").and_then(Value::as_str) {
public_entries.insert(main.to_string());
}
if let Some(module) = value.get("module").and_then(Value::as_str) {
public_entries.insert(module.to_string());
}
if let Some(exports) = value.get("exports") {
collect_json_entry_strings(exports, &mut public_entries);
}
let mut bin_entries = BTreeSet::new();
if let Some(bin) = value.get("bin") {
collect_json_entry_strings(bin, &mut bin_entries);
}
for entry in public_entries {
insert_package_entry(package_dir, &entry, entry_points, EntryPointKind::PublicApi);
}
for entry in bin_entries {
insert_package_entry(
package_dir,
&entry,
entry_points,
EntryPointKind::LivenessRoot,
);
}
if let Some(scripts) = value.get("scripts").and_then(Value::as_object) {
for command in scripts.values().filter_map(Value::as_str) {
collect_script_entry_points(package_dir, command, entry_points);
}
}
}
fn collect_script_entry_points(
package_dir: &Path,
command: &str,
entry_points: &mut EntryPointSet,
) {
let is_separator =
|c: char| c.is_whitespace() || matches!(c, '&' | '|' | ';' | '(' | ')' | '<' | '>' | ',');
for token in command.split(is_separator) {
let token = token
.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '`');
if token.is_empty() || token.starts_with('-') || token.contains("node_modules") {
continue;
}
let is_source = Path::new(token)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| JS_MODULE_EXTENSIONS.contains(&extension));
if !is_source {
continue;
}
let candidate = normalize_path(&package_dir.join(token));
if candidate.is_file() {
insert_resolved_entry_point(entry_points, &candidate, EntryPointKind::LivenessRoot);
}
}
}
fn collect_json_entry_strings(value: &Value, entries: &mut BTreeSet<String>) {
match value {
Value::String(entry) => {
entries.insert(entry.clone());
}
Value::Array(values) => {
for value in values {
collect_json_entry_strings(value, entries);
}
}
Value::Object(map) => {
for value in map.values() {
collect_json_entry_strings(value, entries);
}
}
_ => {}
}
}
fn insert_package_entry(
package_dir: &Path,
entry: &str,
entry_points: &mut EntryPointSet,
kind: EntryPointKind,
) {
let entry = entry.trim();
if entry.is_empty() || entry.starts_with('#') || entry.contains('*') {
return;
}
if let Some(path) = resolve_package_entry(package_dir, entry) {
insert_resolved_entry_point(entry_points, &path, kind);
}
}
fn resolve_package_entry(package_dir: &Path, entry: &str) -> Option<PathBuf> {
if entry.starts_with("node:") || entry.contains("://") {
return None;
}
let rel = if is_relative_module(entry) {
entry.trim_start_matches("./").to_string()
} else {
entry.trim_start_matches('/').to_string()
};
let mut bases = Vec::new();
if let Some(src_rel) = remap_build_output_to_src(&rel) {
bases.push(package_dir.join(src_rel));
}
bases.push(package_dir.join(&rel));
bases
.iter()
.flat_map(|base| candidate_paths(base))
.map(|candidate| normalize_path(&candidate))
.find(|candidate| candidate.is_file())
}
fn remap_build_output_to_src(rel: &str) -> Option<String> {
const BUILD_DIRS: &[&str] = &["dist", "build", "out", "output", "esm", "cjs"];
let mut components = rel.split('/');
let first = components.next()?;
if !BUILD_DIRS.contains(&first) {
return None;
}
let rest: Vec<&str> = components.collect();
if rest.is_empty() {
return None;
}
Some(format!("src/{}", rest.join("/")))
}
fn candidate_paths(base: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
candidates.push(base.to_path_buf());
let has_remappable_ext = base
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| JS_MODULE_EXTENSIONS.contains(&ext))
.unwrap_or(false);
if base.extension().is_none() || has_remappable_ext {
for extension in JS_MODULE_EXTENSIONS {
candidates.push(base.with_extension(extension));
}
}
for extension in JS_MODULE_EXTENSIONS {
candidates.push(base.join(format!("index.{extension}")));
}
candidates
}
fn is_relative_module(module_path: &str) -> bool {
module_path.starts_with("./")
|| module_path.starts_with("../")
|| module_path == "."
|| module_path == ".."
}
fn collect_fallback_entry_points(project_root: &Path, entry_points: &mut EntryPointSet) {
for path in entry_point_walk_files(project_root) {
if let Some(kind) = conventional_entry_point_kind(project_root, &path) {
insert_resolved_entry_point(entry_points, &path, kind);
}
}
}
fn conventional_entry_point_kind(project_root: &Path, file: &Path) -> Option<EntryPointKind> {
let relative = file.strip_prefix(project_root).unwrap_or(file);
let relative_display = relative.to_string_lossy().replace('\\', "/");
if relative_display.starts_with("bin/") || relative_display.contains("/bin/") {
return Some(EntryPointKind::LivenessRoot);
}
let file_name = relative.file_name().and_then(|name| name.to_str())?;
match file_name {
"lib.rs" | "index.ts" | "index.tsx" | "index.js" | "index.jsx" => {
Some(EntryPointKind::PublicApi)
}
"main.rs" | "main.ts" | "main.tsx" | "main.js" | "main.jsx" | "main.py" | "main.go" => {
Some(EntryPointKind::LivenessRoot)
}
_ => None,
}
}
fn insert_existing_entry_point(
entry_points: &mut EntryPointSet,
path: &Path,
kind: EntryPointKind,
) {
if path.is_file() {
insert_resolved_entry_point(entry_points, path, kind);
}
}
fn insert_resolved_entry_point(
entry_points: &mut EntryPointSet,
path: &Path,
kind: EntryPointKind,
) {
match kind {
EntryPointKind::LivenessRoot => entry_points.insert_liveness_root(path),
EntryPointKind::PublicApi => entry_points.insert_public_api_file(path),
}
}
fn contains_path(paths: &BTreeSet<PathBuf>, file: &Path) -> bool {
let snapshot = snapshot_path(file);
paths.contains(&snapshot) || paths.contains(&normalize_path(file))
}
fn snapshot_path(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
}
fn relative_path(project_root: &Path, path: &Path) -> String {
let project_root = snapshot_path(project_root);
let path = snapshot_path(path);
path.strip_prefix(&project_root)
.unwrap_or(path.as_path())
.to_string_lossy()
.replace('\\', "/")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum SignalTier {
Product = 0,
Test = 1,
Tooling = 2,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ProjectRoles {
product_dirs: Vec<String>,
tooling_dirs: Vec<String>,
}
pub(crate) fn resolve_project_roles(project_root: &Path) -> ProjectRoles {
let project_root = snapshot_path(project_root);
let mut roles = ProjectRoles::default();
for manifest in collect_entry_point_manifests(&project_root) {
let Some(dir) = manifest.parent() else {
continue;
};
let rel_dir = relative_path(&project_root, dir);
if rel_dir.is_empty() {
continue;
}
match manifest.file_name().and_then(|name| name.to_str()) {
Some("package.json") => match classify_package_json(&manifest) {
Some(true) => roles.product_dirs.push(rel_dir),
Some(false) => roles.tooling_dirs.push(rel_dir),
None => {}
},
Some("Cargo.toml") => {
if cargo_manifest_is_package(&manifest) {
roles.product_dirs.push(rel_dir);
}
}
_ => {}
}
}
roles
.product_dirs
.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
roles
.tooling_dirs
.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
roles
}
impl ProjectRoles {
pub(crate) fn role_for(&self, relative_file: &str) -> SignalTier {
let norm = relative_file.replace('\\', "/");
if is_test_path(&norm) {
return SignalTier::Test;
}
let product = best_prefix_len(&self.product_dirs, &norm);
let tooling = best_prefix_len(&self.tooling_dirs, &norm);
match (product, tooling) {
(Some(p), Some(t)) => {
if p >= t {
SignalTier::Product
} else {
SignalTier::Tooling
}
}
(Some(_), None) => SignalTier::Product,
(None, Some(_)) => SignalTier::Tooling,
(None, None) => fallback_role_by_name(&norm),
}
}
}
fn best_prefix_len(dirs: &[String], file: &str) -> Option<usize> {
dirs.iter()
.filter(|dir| file == dir.as_str() || file.starts_with(&format!("{dir}/")))
.map(String::len)
.max()
}
fn is_test_path(norm: &str) -> bool {
if super::job::is_test_support_file(norm) {
return true;
}
if norm
.split('/')
.any(|segment| matches!(segment, "tests" | "__tests__" | "test"))
{
return true;
}
let file_name = norm.rsplit('/').next().unwrap_or(norm);
file_name.contains(".test.")
|| file_name.contains(".spec.")
|| file_name.ends_with("_test.rs")
|| file_name.ends_with("_test.go")
|| file_name.starts_with("test_")
}
fn fallback_role_by_name(norm: &str) -> SignalTier {
if norm.split('/').any(|segment| {
matches!(
segment,
"benchmarks" | "benchmark" | "bench" | "scripts" | "examples"
)
}) {
SignalTier::Tooling
} else {
SignalTier::Product
}
}
fn classify_package_json(manifest: &Path) -> Option<bool> {
let value = fs::read_to_string(manifest)
.ok()
.and_then(|source| serde_json::from_str::<Value>(&source).ok())?;
let has_public_api = value.get("main").is_some()
|| value.get("module").is_some()
|| value.get("exports").is_some()
|| value.get("bin").is_some();
Some(has_public_api)
}
fn cargo_manifest_is_package(manifest: &Path) -> bool {
fs::read_to_string(manifest)
.ok()
.and_then(|source| source.parse::<toml::Value>().ok())
.is_some_and(|value| value.get("package").is_some_and(toml::Value::is_table))
}
pub(crate) fn rank_and_truncate_items(
mut items: Vec<Value>,
roles: &ProjectRoles,
limit: Option<usize>,
) -> Vec<Value> {
items.sort_by_key(|item| {
let file = item.get("file").and_then(Value::as_str).unwrap_or("");
roles.role_for(file)
});
if let Some(limit) = limit {
items.truncate(limit);
}
items
}
pub(crate) const TOP_PREVIEW_ITEMS: usize = 3;
pub(crate) fn top_preview_symbols(items: &[Value]) -> Vec<Value> {
items
.iter()
.take(TOP_PREVIEW_ITEMS)
.map(|item| {
serde_json::json!({
"file": item.get("file").and_then(Value::as_str).unwrap_or(""),
"symbol": item.get("symbol").and_then(Value::as_str).unwrap_or(""),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_scripts_seed_source_files_as_liveness_roots() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("scripts")).unwrap();
std::fs::write(root.join("src/runner.ts"), "export const x = 1;\n").unwrap();
std::fs::write(root.join("scripts/build.mjs"), "console.log(1)\n").unwrap();
std::fs::write(
root.join("package.json"),
r#"{
"name": "x",
"private": true,
"scripts": {
"bench": "bun run src/runner.ts",
"build": "node scripts/build.mjs --flag",
"lint": "biome check .",
"missing": "bun run src/does-not-exist.ts"
}
}"#,
)
.unwrap();
let entry_points = resolve_entry_points(root);
assert!(entry_points.is_liveness_root_file(&root.join("src/runner.ts")));
assert!(entry_points.is_liveness_root_file(&root.join("scripts/build.mjs")));
assert!(!entry_points.is_liveness_root_file(&root.join("src/does-not-exist.ts")));
assert!(!entry_points.is_public_api_file(&root.join("src/runner.ts")));
}
#[test]
fn public_api_entry_pointing_at_dist_resolves_to_src_source() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/index.ts"), "export const x = 1;\n").unwrap();
std::fs::write(
root.join("package.json"),
r#"{
"name": "@scope/pkg",
"main": "dist/index.js",
"module": "dist/index.js",
"exports": { ".": { "import": "./dist/index.js" } }
}"#,
)
.unwrap();
let entry_points = resolve_entry_points(root);
assert!(
entry_points.is_public_api_file(&root.join("src/index.ts")),
"src/index.ts should be recognized as public-API via dist->src remap"
);
}
#[test]
fn remap_build_output_to_src_excludes_ambiguous_lib() {
assert_eq!(
remap_build_output_to_src("dist/index.js").as_deref(),
Some("src/index.js")
);
assert_eq!(
remap_build_output_to_src("build/sub/mod.js").as_deref(),
Some("src/sub/mod.js")
);
assert_eq!(remap_build_output_to_src("lib/index.js"), None);
assert_eq!(remap_build_output_to_src("src/index.ts"), None);
}
fn write_pkg(root: &Path, dir: &str, body: &str) {
let pkg_dir = root.join(dir);
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("package.json"), body).unwrap();
}
#[test]
fn project_roles_classify_product_vs_tooling_from_manifests() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_pkg(
root,
"packages/lib",
r#"{ "name": "@scope/lib", "main": "dist/index.js" }"#,
);
write_pkg(
root,
"benchmarks/perf",
r#"{ "name": "perf", "private": true, "scripts": { "bench": "bun run src/run.ts" } }"#,
);
let crate_dir = root.join("crates/engine");
std::fs::create_dir_all(&crate_dir).unwrap();
std::fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"engine\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let roles = resolve_project_roles(root);
assert_eq!(
roles.role_for("packages/lib/src/foo.ts"),
SignalTier::Product
);
assert_eq!(
roles.role_for("crates/engine/src/lib.rs"),
SignalTier::Product
);
assert_eq!(
roles.role_for("benchmarks/perf/src/run.ts"),
SignalTier::Tooling
);
assert_eq!(
roles.role_for("packages/lib/src/__tests__/foo.test.ts"),
SignalTier::Test
);
assert_eq!(roles.role_for("scripts/release.ts"), SignalTier::Tooling);
assert_eq!(roles.role_for("README.md"), SignalTier::Product);
}
#[test]
fn rank_and_truncate_puts_product_first_then_caps() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_pkg(
root,
"packages/lib",
r#"{ "name": "@scope/lib", "main": "dist/index.js" }"#,
);
write_pkg(
root,
"benchmarks/perf",
r#"{ "name": "perf", "private": true, "scripts": { "x": "bun run a.ts" } }"#,
);
let roles = resolve_project_roles(root);
let items = vec![
serde_json::json!({ "file": "benchmarks/perf/src/a.ts", "symbol": "A" }),
serde_json::json!({ "file": "benchmarks/perf/src/b.ts", "symbol": "B" }),
serde_json::json!({ "file": "packages/lib/src/real.ts", "symbol": "Real" }),
];
let ranked = rank_and_truncate_items(items, &roles, Some(2));
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0]["symbol"], "Real");
}
}