1use std::path::{Path, PathBuf};
4
5use fallow_config::{
6 ConfigLoadOptions, FallowConfig, ProductionAnalysis, ResolvedConfig, WorkspaceDiagnostic,
7 WorkspaceInfo,
8};
9use fallow_types::output_format::OutputFormat;
10use rustc_hash::FxHashSet;
11
12use crate::{EngineError, EngineResult};
13
14#[derive(Debug)]
16pub struct ProjectConfig {
17 pub config: ResolvedConfig,
19 pub path: Option<PathBuf>,
21 pub workspaces: Vec<WorkspaceInfo>,
23 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
25 pub workspace_discovery_ms: Option<f64>,
27}
28
29#[derive(Debug, Clone, Copy)]
31pub struct ProjectConfigOptions {
32 pub output: OutputFormat,
34 pub no_cache: bool,
36 pub threads: usize,
38 pub production_override: Option<bool>,
41 pub quiet: bool,
43 pub analysis: ProductionAnalysis,
45 pub allow_remote_extends: bool,
47}
48
49pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
56 config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
57}
58
59pub fn config_for_project_with_load_options(
65 root: &Path,
66 config_path: Option<&Path>,
67 load_options: ConfigLoadOptions,
68) -> EngineResult<ProjectConfig> {
69 let user_config = load_user_config(root, config_path, load_options)?;
70 let (mut config, path) = match user_config {
71 Some((config, path)) => (config, Some(path)),
72 None => (FallowConfig::default(), None),
73 };
74 if path.is_some() {
75 config.production = config
76 .production
77 .for_analysis(ProductionAnalysis::DeadCode)
78 .into();
79 validate_boundaries_and_rule_packs(root, &config)?;
80 }
81 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
82 let resolved = config.resolve(
83 root.to_path_buf(),
84 OutputFormat::Human,
85 threads,
86 false,
87 true,
88 None,
89 );
90 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
91 collect_workspace_metadata(&resolved)?;
92 Ok(ProjectConfig {
93 config: resolved,
94 path,
95 workspaces,
96 workspace_diagnostics,
97 workspace_discovery_ms: Some(workspace_discovery_ms),
98 })
99}
100
101#[must_use]
103pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
104 config
105 .cache_max_size_mb
106 .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
107 (mb as usize).saturating_mul(1024 * 1024)
108 })
109}
110
111pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
112 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
113 let config = FallowConfig::default().resolve(
114 root.to_path_buf(),
115 OutputFormat::Human,
116 threads,
117 false,
118 true,
119 None,
120 );
121 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
122 collect_workspace_metadata_lossy(&config);
123 ProjectConfig {
124 config,
125 path: None,
126 workspaces,
127 workspace_diagnostics,
128 workspace_discovery_ms: Some(workspace_discovery_ms),
129 }
130}
131
132pub fn config_for_project_analysis(
143 root: &Path,
144 config_path: Option<&Path>,
145 options: ProjectConfigOptions,
146) -> EngineResult<ProjectConfig> {
147 let user_config = load_user_config(
148 root,
149 config_path,
150 ConfigLoadOptions {
151 allow_remote_extends: options.allow_remote_extends,
152 },
153 )?;
154 let loaded_user_config = user_config.is_some();
155 let (mut config, path) = match user_config {
156 Some((config, path)) => (config, Some(path)),
157 None => (
158 FallowConfig {
159 production: options.production_override.unwrap_or(false).into(),
160 ..FallowConfig::default()
161 },
162 None,
163 ),
164 };
165
166 if loaded_user_config {
167 let production = options
168 .production_override
169 .unwrap_or_else(|| config.production.for_analysis(options.analysis));
170 config.production = production.into();
171 }
172 validate_config(root, &config)?;
173 let resolved = config.resolve(
174 root.to_path_buf(),
175 options.output,
176 options.threads,
177 options.no_cache,
178 options.quiet,
179 None,
180 );
181 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
182 collect_workspace_metadata(&resolved)?;
183 Ok(ProjectConfig {
184 config: resolved,
185 path,
186 workspaces,
187 workspace_diagnostics,
188 workspace_discovery_ms: Some(workspace_discovery_ms),
189 })
190}
191
192pub(crate) fn collect_workspace_metadata(
193 config: &ResolvedConfig,
194) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
195 let start = std::time::Instant::now();
196 let (workspaces, diagnostics) =
197 fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
198 .map_err(|err| EngineError::new(err.to_string()))?;
199 let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
200 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
201 Ok((workspaces, diagnostics, elapsed_ms))
202}
203
204fn collect_workspace_metadata_lossy(
205 config: &ResolvedConfig,
206) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
207 collect_workspace_metadata(config).unwrap_or_default()
208}
209
210fn with_undeclared_workspace_diagnostics(
211 config: &ResolvedConfig,
212 workspaces: &[WorkspaceInfo],
213 mut diagnostics: Vec<WorkspaceDiagnostic>,
214) -> Vec<WorkspaceDiagnostic> {
215 let mut existing: FxHashSet<PathBuf> = diagnostics
216 .iter()
217 .map(|diagnostic| {
218 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
219 })
220 .collect();
221 for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
222 &config.root,
223 workspaces,
224 &config.ignore_patterns,
225 ) {
226 let canonical =
227 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
228 if existing.insert(canonical) {
229 diagnostics.push(diagnostic);
230 }
231 }
232 diagnostics
233}
234
235fn load_user_config(
236 root: &Path,
237 config_path: Option<&Path>,
238 options: ConfigLoadOptions,
239) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
240 if let Some(path) = config_path {
241 let config = FallowConfig::load_with_options(path, options)
242 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
243 return Ok(Some((config, path.to_path_buf())));
244 }
245 FallowConfig::find_and_load_with_options(root, options)
246 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
247}
248
249fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
250 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
251 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
252 validate_boundaries_and_rule_packs(root, config)
253}
254
255fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
256 config
257 .validate_resolved_boundaries(root)
258 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
259 let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
260 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
261 let boundaries =
262 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
263 let zone_errors = fallow_config::validate_rule_pack_zone_references(
264 root,
265 &config.rule_packs,
266 &packs,
267 &boundaries,
268 );
269 if !zone_errors.is_empty() {
270 return Err(joined_config_errors("invalid rule pack", &zone_errors));
271 }
272 Ok(())
273}
274
275fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
276 let joined = errors
277 .iter()
278 .map(ToString::to_string)
279 .collect::<Vec<_>>()
280 .join("\n - ");
281 EngineError::new(format!("{label}:\n - {joined}"))
282}