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