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, Clone, Copy, Default, PartialEq, Eq)]
23pub struct ProductionFlags {
24 pub global: Option<bool>,
27 pub dead_code: Option<bool>,
29 pub health: Option<bool>,
31 pub dupes: Option<bool>,
33}
34
35impl ProductionFlags {
36 #[must_use]
39 pub const fn from_cli(
40 production: bool,
41 dead_code: Option<bool>,
42 health: Option<bool>,
43 dupes: Option<bool>,
44 ) -> Self {
45 Self {
46 global: if production { Some(true) } else { None },
47 dead_code,
48 health,
49 dupes,
50 }
51 }
52
53 #[must_use]
57 pub const fn single_analysis_override(production: bool, own: Option<bool>) -> Option<bool> {
58 Self::from_cli(production, own, None, None).override_for(ProductionAnalysis::DeadCode)
59 }
60
61 #[must_use]
63 pub const fn own(self, analysis: ProductionAnalysis) -> Option<bool> {
64 match analysis {
65 ProductionAnalysis::DeadCode => self.dead_code,
66 ProductionAnalysis::Health => self.health,
67 ProductionAnalysis::Dupes => self.dupes,
68 }
69 }
70
71 #[must_use]
74 pub const fn override_for(self, analysis: ProductionAnalysis) -> Option<bool> {
75 match self.own(analysis) {
76 Some(value) => Some(value),
77 None => self.global,
78 }
79 }
80
81 #[must_use]
84 pub const fn mode(self, analysis: ProductionAnalysis) -> bool {
85 match self.override_for(analysis) {
86 Some(value) => value,
87 None => false,
88 }
89 }
90
91 #[must_use]
93 pub const fn modes(self) -> ProductionModes {
94 ProductionModes {
95 dead_code: self.mode(ProductionAnalysis::DeadCode),
96 health: self.mode(ProductionAnalysis::Health),
97 dupes: self.mode(ProductionAnalysis::Dupes),
98 }
99 }
100
101 #[must_use]
104 pub const fn effective(
105 self,
106 analysis: ProductionAnalysis,
107 config: fallow_config::ProductionConfig,
108 ) -> bool {
109 match self.override_for(analysis) {
110 Some(value) => value,
111 None => config.for_analysis(analysis),
112 }
113 }
114
115 #[must_use]
117 pub const fn effective_modes(self, config: fallow_config::ProductionConfig) -> ProductionModes {
118 ProductionModes {
119 dead_code: self.effective(ProductionAnalysis::DeadCode, config),
120 health: self.effective(ProductionAnalysis::Health, config),
121 dupes: self.effective(ProductionAnalysis::Dupes, config),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128pub struct ProductionModes {
129 pub dead_code: bool,
131 pub health: bool,
133 pub dupes: bool,
135}
136
137impl ProductionModes {
138 #[must_use]
140 pub const fn dead_code_matches_health(self) -> bool {
141 self.dead_code == self.health
142 }
143
144 #[must_use]
147 pub const fn dead_code_matches_dupes(self) -> bool {
148 self.dead_code == self.dupes
149 }
150
151 #[must_use]
153 pub const fn all_match(self) -> bool {
154 self.dead_code_matches_health() && self.dead_code_matches_dupes()
155 }
156}
157
158#[derive(Debug)]
160pub struct ProjectConfig {
161 pub config: ResolvedConfig,
163 pub path: Option<PathBuf>,
165 pub workspaces: Vec<WorkspaceInfo>,
167 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
169 pub workspace_discovery_ms: Option<f64>,
171}
172
173#[derive(Debug, Clone, Copy)]
175pub struct ProjectConfigOptions {
176 pub output: OutputFormat,
178 pub no_cache: bool,
180 pub threads: usize,
182 pub production_override: Option<bool>,
185 pub quiet: bool,
187 pub analysis: ProductionAnalysis,
189 pub allow_remote_extends: bool,
191}
192
193#[derive(Debug)]
195#[non_exhaustive]
196pub struct ProjectConfigReadiness {
197 pub project: ProjectConfig,
199 pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
201}
202
203pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
210 config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
211}
212
213pub fn config_for_project_with_load_options(
219 root: &Path,
220 config_path: Option<&Path>,
221 load_options: ConfigLoadOptions,
222) -> EngineResult<ProjectConfig> {
223 let user_config = load_user_config(root, config_path, load_options)?;
224 let (mut config, path) = match user_config {
225 Some((config, path)) => (config, Some(path)),
226 None => (FallowConfig::default(), None),
227 };
228 if path.is_some() {
229 config.production = config
230 .production
231 .for_analysis(ProductionAnalysis::DeadCode)
232 .into();
233 validate_boundaries_and_rule_packs(root, &config)?;
234 }
235 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
236 let mut resolved = config.resolve(
237 root.to_path_buf(),
238 OutputFormat::Human,
239 threads,
240 false,
241 true,
242 None,
243 );
244 apply_max_file_size_env(&mut resolved);
245 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
246 collect_workspace_metadata(&resolved)?;
247 Ok(ProjectConfig {
248 config: resolved,
249 path,
250 workspaces,
251 workspace_diagnostics,
252 workspace_discovery_ms: Some(workspace_discovery_ms),
253 })
254}
255
256#[must_use]
258pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
259 config
260 .cache_max_size_mb
261 .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
262 (mb as usize).saturating_mul(1024 * 1024)
263 })
264}
265
266pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
267 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
268 let config = FallowConfig::default().resolve(
269 root.to_path_buf(),
270 OutputFormat::Human,
271 threads,
272 false,
273 true,
274 None,
275 );
276 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
277 collect_workspace_metadata_lossy(&config);
278 ProjectConfig {
279 config,
280 path: None,
281 workspaces,
282 workspace_diagnostics,
283 workspace_discovery_ms: Some(workspace_discovery_ms),
284 }
285}
286
287pub fn config_for_project_analysis(
298 root: &Path,
299 config_path: Option<&Path>,
300 options: ProjectConfigOptions,
301) -> EngineResult<ProjectConfig> {
302 resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
303}
304
305pub fn config_for_project_readiness(
312 root: &Path,
313 config_path: Option<&Path>,
314 options: ProjectConfigOptions,
315) -> EngineResult<ProjectConfigReadiness> {
316 let (project, configured_plugin_paths) =
317 resolve_project_config_analysis(root, config_path, options)?;
318 let configured_plugin_diagnostics =
319 fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
320 Ok(ProjectConfigReadiness {
321 project,
322 configured_plugin_diagnostics,
323 })
324}
325
326fn resolve_project_config_analysis(
327 root: &Path,
328 config_path: Option<&Path>,
329 options: ProjectConfigOptions,
330) -> EngineResult<(ProjectConfig, Vec<String>)> {
331 let user_config = load_user_config(
332 root,
333 config_path,
334 ConfigLoadOptions {
335 allow_remote_extends: options.allow_remote_extends,
336 },
337 )?;
338 let loaded_user_config = user_config.is_some();
339 let (mut config, path) = match user_config {
340 Some((config, path)) => (config, Some(path)),
341 None => (
342 FallowConfig {
343 production: options.production_override.unwrap_or(false).into(),
344 ..FallowConfig::default()
345 },
346 None,
347 ),
348 };
349
350 if loaded_user_config {
351 let production = ProductionFlags {
352 global: options.production_override,
353 ..ProductionFlags::default()
354 }
355 .effective(options.analysis, config.production);
356 config.production = production.into();
357 }
358 validate_config(root, &config)?;
359 let configured_plugin_paths = config.plugins.clone();
360 let mut resolved = config.resolve(
361 root.to_path_buf(),
362 options.output,
363 options.threads,
364 options.no_cache,
365 options.quiet,
366 None,
367 );
368 apply_max_file_size_env(&mut resolved);
369 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
370 collect_workspace_metadata(&resolved)?;
371 Ok((
372 ProjectConfig {
373 config: resolved,
374 path,
375 workspaces,
376 workspace_diagnostics,
377 workspace_discovery_ms: Some(workspace_discovery_ms),
378 },
379 configured_plugin_paths,
380 ))
381}
382
383fn apply_max_file_size_env(config: &mut ResolvedConfig) {
384 let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
385 .ok()
386 .and_then(|raw| raw.trim().parse::<u32>().ok());
387 if let Some(max_file_size_mb) = max_file_size_mb {
388 config.max_file_size_bytes =
389 fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
390 }
391}
392
393pub(crate) fn collect_workspace_metadata(
394 config: &ResolvedConfig,
395) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
396 let start = std::time::Instant::now();
397 let (workspaces, diagnostics) =
398 fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
399 .map_err(|err| EngineError::new(err.to_string()))?;
400 let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
401 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
402 Ok((workspaces, diagnostics, elapsed_ms))
403}
404
405fn collect_workspace_metadata_lossy(
406 config: &ResolvedConfig,
407) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
408 collect_workspace_metadata(config).unwrap_or_default()
409}
410
411fn with_undeclared_workspace_diagnostics(
412 config: &ResolvedConfig,
413 workspaces: &[WorkspaceInfo],
414 mut diagnostics: Vec<WorkspaceDiagnostic>,
415) -> Vec<WorkspaceDiagnostic> {
416 let mut existing: FxHashSet<PathBuf> = diagnostics
417 .iter()
418 .map(|diagnostic| {
419 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
420 })
421 .collect();
422 for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
423 &config.root,
424 workspaces,
425 &config.ignore_patterns,
426 ) {
427 let canonical =
428 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
429 if existing.insert(canonical) {
430 diagnostics.push(diagnostic);
431 }
432 }
433 diagnostics
434}
435
436fn load_user_config(
437 root: &Path,
438 config_path: Option<&Path>,
439 options: ConfigLoadOptions,
440) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
441 if let Some(path) = config_path {
442 let config = FallowConfig::load_with_options(path, options)
443 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
444 return Ok(Some((config, path.to_path_buf())));
445 }
446 FallowConfig::find_and_load_with_options(root, options)
447 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
448}
449
450fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
451 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
452 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
453 validate_boundaries_and_rule_packs(root, config)
454}
455
456fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
457 config
458 .validate_resolved_boundaries(root)
459 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
460 let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
461 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
462 let zone_errors = fallow_config::validate_rule_pack_zones(
463 root,
464 &config.boundaries,
465 &config.rule_packs,
466 &packs,
467 );
468 if !zone_errors.is_empty() {
469 return Err(joined_config_errors("invalid rule pack", &zone_errors));
470 }
471 Ok(())
472}
473
474fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
475 let joined = errors
476 .iter()
477 .map(ToString::to_string)
478 .collect::<Vec<_>>()
479 .join("\n - ");
480 EngineError::new(format!("{label}:\n - {joined}"))
481}