1use fallow_config::{ResolvedConfig, WorkspaceInfo};
4
5use crate::{
6 discover::{DiscoveredFile, EntryPoint},
7 plugins::AggregatedPluginResult,
8 session::AnalysisSession,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ListInventoryError {
14 Plugins(String),
17}
18
19impl ListInventoryError {
20 #[must_use]
22 pub fn message(&self) -> &str {
23 match self {
24 Self::Plugins(message) => message,
25 }
26 }
27}
28
29#[derive(Debug, Clone)]
31pub struct ListingInventory {
32 pub plugins: AggregatedPluginResult,
34 pub entry_points: Option<Vec<EntryPoint>>,
37}
38
39pub fn collect_listing_inventory(
57 session: &AnalysisSession,
58 with_entry_points: bool,
59) -> Result<ListingInventory, ListInventoryError> {
60 let prelude = crate::core_backend::prepare_dead_code_backend_prelude(
61 session.config(),
62 session.discovery(),
63 )
64 .map_err(|err| ListInventoryError::Plugins(err.message().to_owned()))?;
65 let entry_points = with_entry_points.then(|| {
66 crate::core_backend::discover_dead_code_entry_points(&prelude)
67 .all()
68 .to_vec()
69 });
70 let plugins = AggregatedPluginResult::from(prelude.plugin_result());
71 prelude.finish();
72 Ok(ListingInventory {
73 plugins,
74 entry_points,
75 })
76}
77
78#[must_use]
80pub fn collect_entry_points(
81 config: &ResolvedConfig,
82 discovered: &[DiscoveredFile],
83 workspaces: &[WorkspaceInfo],
84 plugin_result: Option<&AggregatedPluginResult>,
85) -> Vec<EntryPoint> {
86 let mut entries = crate::discover::discover_entry_points(config, discovered, workspaces);
87 if let Some(plugin_result) = plugin_result {
88 entries.extend(crate::discover::discover_plugin_entry_points(
89 plugin_result,
90 config,
91 discovered,
92 ));
93 }
94 entries
95}
96
97#[cfg(test)]
98mod tests {
99 use std::path::Path;
100
101 use fallow_config::{FallowConfig, WorkspaceInfo};
102 use fallow_types::output_format::OutputFormat;
103
104 use super::*;
105 use crate::discover::{EntryPointSource, FileId};
106
107 #[test]
108 fn entry_points_include_root_and_workspace_entries() {
109 let temp = tempfile::tempdir().expect("tempdir");
110 let root = temp.path();
111 let config = FallowConfig::default().resolve(
112 root.to_path_buf(),
113 OutputFormat::Json,
114 1,
115 false,
116 true,
117 None,
118 );
119 let workspace = WorkspaceInfo {
120 root: root.join("packages/web"),
121 name: "web".to_owned(),
122 is_internal_dependency: false,
123 };
124 let discovered = vec![
125 DiscoveredFile {
126 id: FileId(0),
127 path: root.join("src/main.ts"),
128 size_bytes: 0,
129 },
130 DiscoveredFile {
131 id: FileId(1),
132 path: root.join("packages/web/src/index.ts"),
133 size_bytes: 0,
134 },
135 ];
136
137 let entries = collect_entry_points(&config, &discovered, &[workspace], None);
138
139 assert!(
140 entries
141 .iter()
142 .any(|entry| entry.path.ends_with("src/main.ts"))
143 );
144 assert!(
145 entries
146 .iter()
147 .any(|entry| entry.path.ends_with("packages/web/src/index.ts"))
148 );
149 }
150
151 fn session_at(root: &Path) -> AnalysisSession {
152 let config = FallowConfig::default().resolve(
153 root.to_path_buf(),
154 OutputFormat::Json,
155 1,
156 true,
157 true,
158 None,
159 );
160 AnalysisSession::from_resolved_config(config).expect("session")
161 }
162
163 fn write(root: &Path, file: &str, source: &str) {
164 let path = root.join(file);
165 std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
166 std::fs::write(path, source).expect("write");
167 }
168
169 #[test]
173 fn collected_entry_points_match_the_analysis_for_package_selections() {
174 let temp = tempfile::tempdir().expect("tempdir");
175 let root = temp.path();
176 write(
177 root,
178 "package.json",
179 r#"{
180 "name": "monorepo",
181 "private": true,
182 "workspaces": ["packages/*"],
183 "scripts": {
184 "start": "yarn workspace web node scripts/root-start.ts",
185 "gen": "pnpm -r exec tsx scripts/gen.ts",
186 "all": "yarn workspaces foreach -A exec node scripts/all.ts",
187 "serve": "pnpm -C packages/api run serve"
188 }
189 }"#,
190 );
191 write(
192 root,
193 "packages/web/package.json",
194 r#"{"name":"web","main":"src/index.ts"}"#,
195 );
196 write(
197 root,
198 "packages/api/package.json",
199 r#"{"name":"api","main":"src/index.ts","scripts":{"serve":"node src/server.ts"}}"#,
200 );
201 for file in [
202 "scripts/all.ts",
203 "packages/web/src/index.ts",
204 "packages/web/scripts/root-start.ts",
205 "packages/web/scripts/gen.ts",
206 "packages/api/src/index.ts",
207 "packages/api/src/server.ts",
208 ] {
209 write(root, file, "export const x = 1;\n");
210 }
211 let session = session_at(root);
212 let inventory = collect_listing_inventory(&session, true).expect("inventory");
213 let relative = |entries: &[EntryPoint]| {
214 let mut paths: Vec<String> = entries
215 .iter()
216 .map(|entry| {
217 entry
218 .path
219 .strip_prefix(&session.config().root)
220 .expect("under root")
221 .to_string_lossy()
222 .replace('\\', "/")
223 })
224 .collect();
225 paths.sort();
226 paths.dedup();
227 paths
228 };
229 let analysis_entries = inventory.entry_points.expect("entry points");
230 let analysis = relative(&analysis_entries);
231 let analysis_packages: Vec<EntryPoint> = analysis_entries
232 .into_iter()
233 .filter(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
234 .collect();
235 let analysis_packages = relative(&analysis_packages);
236
237 let collected = relative(&collect_entry_points(
239 session.config(),
240 session.files(),
241 session.workspaces(),
242 None,
243 ));
244
245 for selected in [
246 "packages/web/scripts/root-start.ts",
247 "packages/web/scripts/gen.ts",
248 "scripts/all.ts",
249 "packages/api/src/server.ts",
250 ] {
251 assert!(
252 collected.iter().any(|path| path == selected),
253 "{selected} is missing from {collected:?}"
254 );
255 }
256 assert_eq!(collected, analysis_packages);
257 assert_eq!(
258 relative(&collect_entry_points(
259 session.config(),
260 session.files(),
261 session.workspaces(),
262 Some(&inventory.plugins),
263 )),
264 analysis
265 );
266 }
267
268 #[test]
269 fn active_plugins_ignores_missing_package_manifests() {
270 let temp = tempfile::tempdir().expect("tempdir");
271 let session = session_at(temp.path());
272 let inventory =
273 collect_listing_inventory(&session, true).expect("missing package should not fail");
274
275 assert!(inventory.plugins.active_plugins().is_empty());
276 let plugins_only =
277 collect_listing_inventory(&session, false).expect("missing package should not fail");
278 assert!(
279 plugins_only.entry_points.is_none(),
280 "a plugins-only listing skips entry-point discovery"
281 );
282 }
283
284 #[test]
285 fn entry_points_accept_plugin_result() {
286 let temp = tempfile::tempdir().expect("tempdir");
287 std::fs::write(
288 temp.path().join("package.json"),
289 r#"{"dependencies":{"next":"15.0.0"}}"#,
290 )
291 .expect("package manifest");
292 for file in ["src/app/dashboard/page.tsx", "src/helpers/format.ts"] {
293 let path = temp.path().join(file);
294 std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
295 std::fs::write(path, "export const x = 1;\n").expect("source");
296 }
297 let session = session_at(temp.path());
298 let config = session.config();
299 let discovered = session.files();
300 let inventory = collect_listing_inventory(&session, true).expect("Next.js plugins load");
301 assert!(
302 inventory
303 .entry_points
304 .as_deref()
305 .unwrap_or_default()
306 .iter()
307 .any(|entry| entry.path.ends_with("src/app/dashboard/page.tsx")),
308 "the listing inventory carries the analysis entry points"
309 );
310 let plugin_result = inventory.plugins;
311
312 let entries = collect_entry_points(config, discovered, &[], None);
313
314 assert!(
315 entries
316 .iter()
317 .all(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
318 );
319
320 let entries = collect_entry_points(config, discovered, &[], Some(&plugin_result));
321 let plugin_entries: Vec<_> = entries
322 .iter()
323 .filter_map(|entry| match &entry.source {
324 EntryPointSource::Plugin { name } => Some((
325 entry.path.strip_prefix(&config.root).expect("under root"),
326 name.as_str(),
327 )),
328 _ => None,
329 })
330 .collect();
331 assert_eq!(
332 plugin_entries,
333 vec![(Path::new("src/app/dashboard/page.tsx"), "nextjs")]
334 );
335 }
336}