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 pub inputs: fallow_config::ConfigInputs,
174 pub inputs_before_resolve: fallow_config::ConfigInputsSnapshot,
176}
177
178#[derive(Debug, Clone, Copy)]
180pub struct ProjectConfigOptions {
181 pub output: OutputFormat,
183 pub no_cache: bool,
185 pub threads: usize,
187 pub production_override: Option<bool>,
190 pub quiet: bool,
192 pub analysis: ProductionAnalysis,
194 pub allow_remote_extends: bool,
196}
197
198#[derive(Debug)]
200#[non_exhaustive]
201pub struct ProjectConfigReadiness {
202 pub project: ProjectConfig,
204 pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
206}
207
208pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
215 config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
216}
217
218pub fn config_for_project_with_load_options(
224 root: &Path,
225 config_path: Option<&Path>,
226 load_options: ConfigLoadOptions,
227) -> EngineResult<ProjectConfig> {
228 let user_config = load_user_config(root, config_path, load_options)?;
229 let (mut config, path) = match user_config {
230 Some((config, path)) => (config, Some(path)),
231 None => (FallowConfig::default(), None),
232 };
233 if path.is_some() {
234 config.production = config
235 .production
236 .for_analysis(ProductionAnalysis::DeadCode)
237 .into();
238 validate_boundaries_and_rule_packs(root, &config)?;
239 }
240 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
241 let inputs = fallow_config::ConfigInputs::new(root, &config);
242 let inputs_before_resolve = inputs.snapshot();
243 let mut resolved = config.resolve(
244 root.to_path_buf(),
245 OutputFormat::Human,
246 threads,
247 false,
248 true,
249 None,
250 );
251 apply_max_file_size_env(&mut resolved);
252 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
253 collect_workspace_metadata(&resolved)?;
254 Ok(ProjectConfig {
255 inputs,
256 inputs_before_resolve,
257 config: resolved,
258 path,
259 workspaces,
260 workspace_diagnostics,
261 workspace_discovery_ms: Some(workspace_discovery_ms),
262 })
263}
264
265#[must_use]
267pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
268 config
269 .cache_max_size_mb
270 .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
271 (mb as usize).saturating_mul(1024 * 1024)
272 })
273}
274
275pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
276 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
277 let inputs = fallow_config::ConfigInputs::new(root, &FallowConfig::default());
278 let inputs_before_resolve = inputs.snapshot();
279 let config = FallowConfig::default().resolve(
280 root.to_path_buf(),
281 OutputFormat::Human,
282 threads,
283 false,
284 true,
285 None,
286 );
287 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
288 collect_workspace_metadata_lossy(&config);
289 ProjectConfig {
290 inputs,
291 inputs_before_resolve,
292 config,
293 path: None,
294 workspaces,
295 workspace_diagnostics,
296 workspace_discovery_ms: Some(workspace_discovery_ms),
297 }
298}
299
300pub fn config_for_project_analysis(
311 root: &Path,
312 config_path: Option<&Path>,
313 options: ProjectConfigOptions,
314) -> EngineResult<ProjectConfig> {
315 resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
316}
317
318pub fn config_for_project_readiness(
325 root: &Path,
326 config_path: Option<&Path>,
327 options: ProjectConfigOptions,
328) -> EngineResult<ProjectConfigReadiness> {
329 let (project, configured_plugin_paths) =
330 resolve_project_config_analysis(root, config_path, options)?;
331 let configured_plugin_diagnostics =
332 fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
333 Ok(ProjectConfigReadiness {
334 project,
335 configured_plugin_diagnostics,
336 })
337}
338
339fn resolve_project_config_analysis(
340 root: &Path,
341 config_path: Option<&Path>,
342 options: ProjectConfigOptions,
343) -> EngineResult<(ProjectConfig, Vec<String>)> {
344 let user_config = load_user_config(
345 root,
346 config_path,
347 ConfigLoadOptions {
348 allow_remote_extends: options.allow_remote_extends,
349 },
350 )?;
351 let loaded_user_config = user_config.is_some();
352 let (mut config, path) = match user_config {
353 Some((config, path)) => (config, Some(path)),
354 None => (
355 FallowConfig {
356 production: options.production_override.unwrap_or(false).into(),
357 ..FallowConfig::default()
358 },
359 None,
360 ),
361 };
362
363 if loaded_user_config {
364 let production = ProductionFlags {
365 global: options.production_override,
366 ..ProductionFlags::default()
367 }
368 .effective(options.analysis, config.production);
369 config.production = production.into();
370 }
371 validate_config(root, &config)?;
372 let configured_plugin_paths = config.plugins.clone();
373 let inputs = fallow_config::ConfigInputs::new(root, &config);
374 let inputs_before_resolve = inputs.snapshot();
375 let mut resolved = config.resolve(
376 root.to_path_buf(),
377 options.output,
378 options.threads,
379 options.no_cache,
380 options.quiet,
381 None,
382 );
383 apply_max_file_size_env(&mut resolved);
384 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
385 collect_workspace_metadata(&resolved)?;
386 Ok((
387 ProjectConfig {
388 inputs,
389 inputs_before_resolve,
390 config: resolved,
391 path,
392 workspaces,
393 workspace_diagnostics,
394 workspace_discovery_ms: Some(workspace_discovery_ms),
395 },
396 configured_plugin_paths,
397 ))
398}
399
400fn apply_max_file_size_env(config: &mut ResolvedConfig) {
401 let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
402 .ok()
403 .and_then(|raw| raw.trim().parse::<u32>().ok());
404 if let Some(max_file_size_mb) = max_file_size_mb {
405 config.max_file_size_bytes =
406 fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
407 }
408}
409
410pub(crate) fn collect_workspace_metadata(
411 config: &ResolvedConfig,
412) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
413 let start = std::time::Instant::now();
414 let (workspaces, diagnostics) =
415 fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
416 .map_err(|err| EngineError::new(err.to_string()))?;
417 let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
418 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
419 Ok((workspaces, diagnostics, elapsed_ms))
420}
421
422fn collect_workspace_metadata_lossy(
423 config: &ResolvedConfig,
424) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
425 collect_workspace_metadata(config).unwrap_or_default()
426}
427
428fn with_undeclared_workspace_diagnostics(
429 config: &ResolvedConfig,
430 workspaces: &[WorkspaceInfo],
431 mut diagnostics: Vec<WorkspaceDiagnostic>,
432) -> Vec<WorkspaceDiagnostic> {
433 let mut existing: FxHashSet<PathBuf> = diagnostics
434 .iter()
435 .map(|diagnostic| {
436 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
437 })
438 .collect();
439 for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
440 &config.root,
441 workspaces,
442 &config.ignore_patterns,
443 ) {
444 let canonical =
445 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
446 if existing.insert(canonical) {
447 diagnostics.push(diagnostic);
448 }
449 }
450 diagnostics
451}
452
453fn load_user_config(
454 root: &Path,
455 config_path: Option<&Path>,
456 options: ConfigLoadOptions,
457) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
458 if let Some(path) = config_path {
459 let config = FallowConfig::load_with_options(path, options)
460 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
461 return Ok(Some((config, path.to_path_buf())));
462 }
463 FallowConfig::find_and_load_with_options(root, options)
464 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
465}
466
467fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
468 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
469 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
470 validate_boundaries_and_rule_packs(root, config)
471}
472
473fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
474 config
475 .validate_resolved_boundaries(root)
476 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
477 let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
478 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
479 let zone_errors = fallow_config::validate_rule_pack_zones(
480 root,
481 &config.boundaries,
482 &config.rule_packs,
483 &packs,
484 );
485 if !zone_errors.is_empty() {
486 return Err(joined_config_errors("invalid rule pack", &zone_errors));
487 }
488 Ok(())
489}
490
491fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
492 let joined = errors
493 .iter()
494 .map(ToString::to_string)
495 .collect::<Vec<_>>()
496 .join("\n - ");
497 EngineError::new(format!("{label}:\n - {joined}"))
498}