Skip to main content

fallow_core/scripts/
resolve.rs

1//! Binary name → npm package name resolution.
2
3use std::path::Path;
4
5/// Known binary-name → package-name mappings where they diverge.
6static BINARY_TO_PACKAGE: &[(&str, &str)] = &[
7    ("tsc", "typescript"),
8    ("tsserver", "typescript"),
9    ("ng", "@angular/cli"),
10    ("nuxi", "nuxt"),
11    ("run-s", "npm-run-all"),
12    ("run-p", "npm-run-all"),
13    ("run-s2", "npm-run-all2"),
14    ("run-p2", "npm-run-all2"),
15    ("sb", "storybook"),
16    ("biome", "@biomejs/biome"),
17    ("oxlint", "oxlint"),
18];
19
20/// Resolve a binary name to its npm package name.
21///
22/// Strategy:
23/// 1. Check known binary→package divergence map
24/// 2. Read `node_modules/.bin/<binary>` symlink target
25/// 3. Fall back: binary name = package name
26#[must_use]
27pub fn resolve_binary_to_package(binary: &str, root: &Path) -> String {
28    // 1. Known divergences
29    if let Some(&(_, pkg)) = BINARY_TO_PACKAGE.iter().find(|(bin, _)| *bin == binary) {
30        return pkg.to_string();
31    }
32
33    // 2. Try reading the symlink in node_modules/.bin/
34    let bin_link = root.join("node_modules/.bin").join(binary);
35    if let Ok(target) = std::fs::read_link(&bin_link)
36        && let Some(pkg_name) = extract_package_from_bin_path(&target)
37    {
38        return pkg_name;
39    }
40
41    // 3. Fallback: binary name = package name
42    binary.to_string()
43}
44
45/// Extract a package name from a `node_modules/.bin` symlink target path.
46///
47/// Typical symlink targets:
48/// - `../webpack/bin/webpack.js` → `webpack`
49/// - `../@babel/cli/bin/babel.js` → `@babel/cli`
50pub fn extract_package_from_bin_path(target: &std::path::Path) -> Option<String> {
51    let target_str = target.to_string_lossy();
52    let parts: Vec<&str> = target_str.split('/').collect();
53
54    for (i, part) in parts.iter().enumerate() {
55        if *part == ".." {
56            continue;
57        }
58        // Scoped package: @scope/name
59        if part.starts_with('@') && i + 1 < parts.len() {
60            return Some(format!("{}/{}", part, parts[i + 1]));
61        }
62        // Regular package
63        return Some(part.to_string());
64    }
65
66    None
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    // --- BINARY_TO_PACKAGE known mappings ---
74
75    #[test]
76    fn tsserver_maps_to_typescript() {
77        let pkg = resolve_binary_to_package("tsserver", Path::new("/nonexistent"));
78        assert_eq!(pkg, "typescript");
79    }
80
81    #[test]
82    fn nuxi_maps_to_nuxt() {
83        let pkg = resolve_binary_to_package("nuxi", Path::new("/nonexistent"));
84        assert_eq!(pkg, "nuxt");
85    }
86
87    #[test]
88    fn run_p_maps_to_npm_run_all() {
89        let pkg = resolve_binary_to_package("run-p", Path::new("/nonexistent"));
90        assert_eq!(pkg, "npm-run-all");
91    }
92
93    #[test]
94    fn run_s2_maps_to_npm_run_all2() {
95        let pkg = resolve_binary_to_package("run-s2", Path::new("/nonexistent"));
96        assert_eq!(pkg, "npm-run-all2");
97    }
98
99    #[test]
100    fn run_p2_maps_to_npm_run_all2() {
101        let pkg = resolve_binary_to_package("run-p2", Path::new("/nonexistent"));
102        assert_eq!(pkg, "npm-run-all2");
103    }
104
105    #[test]
106    fn sb_maps_to_storybook() {
107        let pkg = resolve_binary_to_package("sb", Path::new("/nonexistent"));
108        assert_eq!(pkg, "storybook");
109    }
110
111    #[test]
112    fn oxlint_maps_to_oxlint() {
113        let pkg = resolve_binary_to_package("oxlint", Path::new("/nonexistent"));
114        assert_eq!(pkg, "oxlint");
115    }
116
117    // --- Unknown binary falls back to identity ---
118
119    #[test]
120    fn unknown_binary_returns_identity() {
121        let pkg = resolve_binary_to_package("some-random-tool", Path::new("/nonexistent"));
122        assert_eq!(pkg, "some-random-tool");
123    }
124
125    #[test]
126    fn jest_identity_without_symlink() {
127        // jest is not in the divergence map, and no symlink exists at /nonexistent
128        let pkg = resolve_binary_to_package("jest", Path::new("/nonexistent"));
129        assert_eq!(pkg, "jest");
130    }
131
132    #[test]
133    fn eslint_identity_without_symlink() {
134        let pkg = resolve_binary_to_package("eslint", Path::new("/nonexistent"));
135        assert_eq!(pkg, "eslint");
136    }
137
138    // --- extract_package_from_bin_path ---
139
140    #[test]
141    fn bin_path_simple_package() {
142        let path = std::path::Path::new("../eslint/bin/eslint.js");
143        assert_eq!(
144            extract_package_from_bin_path(path),
145            Some("eslint".to_string())
146        );
147    }
148
149    #[test]
150    fn bin_path_scoped_package() {
151        let path = std::path::Path::new("../@angular/cli/bin/ng");
152        assert_eq!(
153            extract_package_from_bin_path(path),
154            Some("@angular/cli".to_string())
155        );
156    }
157
158    #[test]
159    fn bin_path_deeply_nested() {
160        let path = std::path::Path::new("../../typescript/bin/tsc");
161        assert_eq!(
162            extract_package_from_bin_path(path),
163            Some("typescript".to_string())
164        );
165    }
166
167    #[test]
168    fn bin_path_no_parent_dots() {
169        let path = std::path::Path::new("webpack/bin/webpack.js");
170        assert_eq!(
171            extract_package_from_bin_path(path),
172            Some("webpack".to_string())
173        );
174    }
175
176    #[test]
177    fn bin_path_only_dots() {
178        let path = std::path::Path::new("../../..");
179        assert_eq!(extract_package_from_bin_path(path), None);
180    }
181
182    #[test]
183    fn bin_path_scoped_with_multiple_parents() {
184        let path = std::path::Path::new("../../../@biomejs/biome/bin/biome");
185        assert_eq!(
186            extract_package_from_bin_path(path),
187            Some("@biomejs/biome".to_string())
188        );
189    }
190}