Skip to main content

fallow_engine/
list_inventory.rs

1//! Engine-owned inventory helpers for list-style project metadata.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{PackageJson, ResolvedConfig, WorkspaceInfo};
6
7use crate::{
8    discover::{DiscoveredFile, EntryPoint},
9    plugins::{AggregatedPluginResult, PluginRegistry, registry::PluginRegexValidationError},
10};
11
12/// Error raised while assembling list inventory.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ListInventoryError {
15    /// One or more plugin regexes failed validation.
16    PluginRegex(Vec<PluginRegexValidationError>),
17}
18
19/// Collect active plugins from the root package and every workspace package.
20///
21/// Missing package manifests are ignored, matching the historical list-command
22/// behavior.
23///
24/// # Errors
25///
26/// Returns plugin regex validation errors from user-authored plugin settings.
27pub fn collect_active_plugins(
28    root: &Path,
29    config: &ResolvedConfig,
30    discovered: &[DiscoveredFile],
31    workspaces: &[WorkspaceInfo],
32) -> Result<AggregatedPluginResult, ListInventoryError> {
33    let file_paths = discovered
34        .iter()
35        .map(|file| file.path.clone())
36        .collect::<Vec<_>>();
37    let registry = PluginRegistry::new(config.external_plugins.clone());
38    let mut result = run_package_plugins(&registry, &root.join("package.json"), root, &file_paths)?
39        .unwrap_or_default();
40
41    for workspace in workspaces {
42        let Some(workspace_result) = run_package_plugins(
43            &registry,
44            &workspace.root.join("package.json"),
45            &workspace.root,
46            &file_paths,
47        )?
48        else {
49            continue;
50        };
51        result.merge_active_plugins_from(&workspace_result);
52    }
53
54    Ok(result)
55}
56
57/// Collect root, workspace, and plugin entry points in one engine-owned pass.
58#[must_use]
59pub fn collect_entry_points(
60    config: &ResolvedConfig,
61    discovered: &[DiscoveredFile],
62    workspaces: &[WorkspaceInfo],
63    plugin_result: Option<&AggregatedPluginResult>,
64) -> Vec<EntryPoint> {
65    let mut entries = crate::discover::discover_entry_points(config, discovered);
66    for workspace in workspaces {
67        entries.extend(crate::discover::discover_workspace_entry_points(
68            &workspace.root,
69            config,
70            discovered,
71        ));
72    }
73    if let Some(plugin_result) = plugin_result {
74        entries.extend(crate::discover::discover_plugin_entry_points(
75            plugin_result,
76            config,
77            discovered,
78        ));
79    }
80    entries
81}
82
83fn run_package_plugins(
84    registry: &PluginRegistry,
85    package_path: &Path,
86    root: &Path,
87    file_paths: &[PathBuf],
88) -> Result<Option<AggregatedPluginResult>, ListInventoryError> {
89    let Ok(package) = PackageJson::load(package_path) else {
90        return Ok(None);
91    };
92    registry
93        .try_run(&package, root, file_paths)
94        .map(Some)
95        .map_err(ListInventoryError::PluginRegex)
96}
97
98#[cfg(test)]
99mod tests {
100    use std::path::Path;
101
102    use fallow_config::{FallowConfig, WorkspaceInfo};
103    use fallow_types::output_format::OutputFormat;
104
105    use super::*;
106    use crate::discover::{EntryPointSource, FileId};
107
108    #[test]
109    fn entry_points_include_root_and_workspace_entries() {
110        let temp = tempfile::tempdir().expect("tempdir");
111        let root = temp.path();
112        let config = FallowConfig::default().resolve(
113            root.to_path_buf(),
114            OutputFormat::Json,
115            1,
116            false,
117            true,
118            None,
119        );
120        let workspace = WorkspaceInfo {
121            root: root.join("packages/web"),
122            name: "web".to_owned(),
123            is_internal_dependency: false,
124        };
125        let discovered = vec![
126            DiscoveredFile {
127                id: FileId(0),
128                path: root.join("src/main.ts"),
129                size_bytes: 0,
130            },
131            DiscoveredFile {
132                id: FileId(1),
133                path: root.join("packages/web/src/index.ts"),
134                size_bytes: 0,
135            },
136        ];
137
138        let entries = collect_entry_points(&config, &discovered, &[workspace], None);
139
140        assert!(
141            entries
142                .iter()
143                .any(|entry| entry.path.ends_with("src/main.ts"))
144        );
145        assert!(
146            entries
147                .iter()
148                .any(|entry| entry.path.ends_with("packages/web/src/index.ts"))
149        );
150    }
151
152    #[test]
153    fn active_plugins_ignores_missing_package_manifests() {
154        let config = FallowConfig::default().resolve(
155            Path::new("/missing-project").to_path_buf(),
156            OutputFormat::Json,
157            1,
158            false,
159            true,
160            None,
161        );
162        let result = collect_active_plugins(Path::new("/missing-project"), &config, &[], &[])
163            .expect("missing package should not fail");
164
165        assert!(result.active_plugins().is_empty());
166    }
167
168    #[test]
169    fn entry_points_accept_plugin_result() {
170        let config = FallowConfig::default().resolve(
171            Path::new("/project").to_path_buf(),
172            OutputFormat::Json,
173            1,
174            false,
175            true,
176            None,
177        );
178        let discovered = Vec::new();
179
180        let entries = collect_entry_points(&config, &discovered, &[], None);
181
182        assert!(
183            entries
184                .iter()
185                .all(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
186        );
187    }
188}