Skip to main content

fallow_engine/
list_inventory.rs

1//! Engine-owned inventory helpers for list-style project metadata.
2
3use fallow_config::{ResolvedConfig, WorkspaceInfo};
4
5use crate::{
6    discover::{DiscoveredFile, EntryPoint},
7    plugins::AggregatedPluginResult,
8    session::AnalysisSession,
9};
10
11/// Error raised while assembling list inventory.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ListInventoryError {
14    /// The plugin stage failed, for example on an invalid user-authored plugin
15    /// regex. Carries the message the analysis reports for the same failure.
16    Plugins(String),
17}
18
19impl ListInventoryError {
20    /// The user-facing message.
21    #[must_use]
22    pub fn message(&self) -> &str {
23        match self {
24            Self::Plugins(message) => message,
25        }
26    }
27}
28
29/// The plugins and entry points of a project, as the analysis sees them.
30#[derive(Debug, Clone)]
31pub struct ListingInventory {
32    /// The plugin stage's result: active plugins and plugin diagnostics.
33    pub plugins: AggregatedPluginResult,
34    /// Every entry point the analysis uses, deduplicated. `None` when the
35    /// caller did not ask for entry points, so their discovery did not run.
36    pub entry_points: Option<Vec<EntryPoint>>,
37}
38
39/// Run the analysis prelude (plugins and scripts) and its entry-point
40/// discovery over the session's whole discovery.
41///
42/// One implementation for the listing and the analysis: the workspace merge,
43/// the auto-import gate, the script-derived entries and the plugin diagnostics
44/// are the same, so `fallow list --entry-points` names the entry points the
45/// analysis uses, and the listing can report the `plugin-config-unreadable`
46/// and `plugin-effect-not-modeled` diagnostics the analysis reports (issue
47/// #2804). A path or changed-file scope narrows what a listing shows, never
48/// which plugins are active, so the caller filters the result afterwards.
49///
50/// `with_entry_points` false skips the entry-point discovery, for a listing
51/// of plugins only.
52///
53/// # Errors
54///
55/// Returns the plugin stage's error, such as an invalid plugin regex.
56pub 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/// Collect root, workspace, and plugin entry points in one engine-owned pass.
79#[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);
87    for workspace in workspaces {
88        entries.extend(crate::discover::discover_workspace_entry_points(
89            &workspace.root,
90            config,
91            discovered,
92        ));
93    }
94    if let Some(plugin_result) = plugin_result {
95        entries.extend(crate::discover::discover_plugin_entry_points(
96            plugin_result,
97            config,
98            discovered,
99        ));
100    }
101    entries
102}
103
104#[cfg(test)]
105mod tests {
106    use std::path::Path;
107
108    use fallow_config::{FallowConfig, WorkspaceInfo};
109    use fallow_types::output_format::OutputFormat;
110
111    use super::*;
112    use crate::discover::{EntryPointSource, FileId};
113
114    #[test]
115    fn entry_points_include_root_and_workspace_entries() {
116        let temp = tempfile::tempdir().expect("tempdir");
117        let root = temp.path();
118        let config = FallowConfig::default().resolve(
119            root.to_path_buf(),
120            OutputFormat::Json,
121            1,
122            false,
123            true,
124            None,
125        );
126        let workspace = WorkspaceInfo {
127            root: root.join("packages/web"),
128            name: "web".to_owned(),
129            is_internal_dependency: false,
130        };
131        let discovered = vec![
132            DiscoveredFile {
133                id: FileId(0),
134                path: root.join("src/main.ts"),
135                size_bytes: 0,
136            },
137            DiscoveredFile {
138                id: FileId(1),
139                path: root.join("packages/web/src/index.ts"),
140                size_bytes: 0,
141            },
142        ];
143
144        let entries = collect_entry_points(&config, &discovered, &[workspace], None);
145
146        assert!(
147            entries
148                .iter()
149                .any(|entry| entry.path.ends_with("src/main.ts"))
150        );
151        assert!(
152            entries
153                .iter()
154                .any(|entry| entry.path.ends_with("packages/web/src/index.ts"))
155        );
156    }
157
158    fn session_at(root: &Path) -> AnalysisSession {
159        let config = FallowConfig::default().resolve(
160            root.to_path_buf(),
161            OutputFormat::Json,
162            1,
163            true,
164            true,
165            None,
166        );
167        AnalysisSession::from_resolved_config(config).expect("session")
168    }
169
170    #[test]
171    fn active_plugins_ignores_missing_package_manifests() {
172        let temp = tempfile::tempdir().expect("tempdir");
173        let session = session_at(temp.path());
174        let inventory =
175            collect_listing_inventory(&session, true).expect("missing package should not fail");
176
177        assert!(inventory.plugins.active_plugins().is_empty());
178        let plugins_only =
179            collect_listing_inventory(&session, false).expect("missing package should not fail");
180        assert!(
181            plugins_only.entry_points.is_none(),
182            "a plugins-only listing skips entry-point discovery"
183        );
184    }
185
186    #[test]
187    fn entry_points_accept_plugin_result() {
188        let temp = tempfile::tempdir().expect("tempdir");
189        std::fs::write(
190            temp.path().join("package.json"),
191            r#"{"dependencies":{"next":"15.0.0"}}"#,
192        )
193        .expect("package manifest");
194        for file in ["src/app/dashboard/page.tsx", "src/helpers/format.ts"] {
195            let path = temp.path().join(file);
196            std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
197            std::fs::write(path, "export const x = 1;\n").expect("source");
198        }
199        let session = session_at(temp.path());
200        let config = session.config();
201        let discovered = session.files();
202        let inventory = collect_listing_inventory(&session, true).expect("Next.js plugins load");
203        assert!(
204            inventory
205                .entry_points
206                .as_deref()
207                .unwrap_or_default()
208                .iter()
209                .any(|entry| entry.path.ends_with("src/app/dashboard/page.tsx")),
210            "the listing inventory carries the analysis entry points"
211        );
212        let plugin_result = inventory.plugins;
213
214        let entries = collect_entry_points(config, discovered, &[], None);
215
216        assert!(
217            entries
218                .iter()
219                .all(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
220        );
221
222        let entries = collect_entry_points(config, discovered, &[], Some(&plugin_result));
223        let plugin_entries: Vec<_> = entries
224            .iter()
225            .filter_map(|entry| match &entry.source {
226                EntryPointSource::Plugin { name } => Some((
227                    entry.path.strip_prefix(&config.root).expect("under root"),
228                    name.as_str(),
229                )),
230                _ => None,
231            })
232            .collect();
233        assert_eq!(
234            plugin_entries,
235            vec![(Path::new("src/app/dashboard/page.tsx"), "nextjs")]
236        );
237    }
238}