1use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5
6use fallow_config::{
7 ConfigLoadOptions, FallowConfig, ProductionAnalysis, ResolvedConfig, WorkspaceDiagnostic,
8 WorkspaceInfo,
9};
10use fallow_types::output_format::OutputFormat;
11use rustc_hash::FxHashSet;
12
13use crate::{EngineError, EngineResult};
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
24pub struct ProductionFlags {
25 pub global: Option<bool>,
28 pub dead_code: Option<bool>,
30 pub health: Option<bool>,
32 pub dupes: Option<bool>,
34}
35
36impl ProductionFlags {
37 #[must_use]
40 pub const fn from_cli(
41 production: bool,
42 dead_code: Option<bool>,
43 health: Option<bool>,
44 dupes: Option<bool>,
45 ) -> Self {
46 Self {
47 global: if production { Some(true) } else { None },
48 dead_code,
49 health,
50 dupes,
51 }
52 }
53
54 #[must_use]
58 pub const fn single_analysis_override(production: bool, own: Option<bool>) -> Option<bool> {
59 Self::from_cli(production, own, None, None).override_for(ProductionAnalysis::DeadCode)
60 }
61
62 #[must_use]
64 pub const fn own(self, analysis: ProductionAnalysis) -> Option<bool> {
65 match analysis {
66 ProductionAnalysis::DeadCode => self.dead_code,
67 ProductionAnalysis::Health => self.health,
68 ProductionAnalysis::Dupes => self.dupes,
69 }
70 }
71
72 #[must_use]
75 pub const fn override_for(self, analysis: ProductionAnalysis) -> Option<bool> {
76 match self.own(analysis) {
77 Some(value) => Some(value),
78 None => self.global,
79 }
80 }
81
82 #[must_use]
85 pub const fn mode(self, analysis: ProductionAnalysis) -> bool {
86 match self.override_for(analysis) {
87 Some(value) => value,
88 None => false,
89 }
90 }
91
92 #[must_use]
94 pub const fn modes(self) -> ProductionModes {
95 ProductionModes {
96 dead_code: self.mode(ProductionAnalysis::DeadCode),
97 health: self.mode(ProductionAnalysis::Health),
98 dupes: self.mode(ProductionAnalysis::Dupes),
99 }
100 }
101
102 #[must_use]
105 pub const fn effective(
106 self,
107 analysis: ProductionAnalysis,
108 config: fallow_config::ProductionConfig,
109 ) -> bool {
110 match self.override_for(analysis) {
111 Some(value) => value,
112 None => config.for_analysis(analysis),
113 }
114 }
115
116 #[must_use]
118 pub const fn effective_modes(self, config: fallow_config::ProductionConfig) -> ProductionModes {
119 ProductionModes {
120 dead_code: self.effective(ProductionAnalysis::DeadCode, config),
121 health: self.effective(ProductionAnalysis::Health, config),
122 dupes: self.effective(ProductionAnalysis::Dupes, config),
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
129pub struct ProductionModes {
130 pub dead_code: bool,
132 pub health: bool,
134 pub dupes: bool,
136}
137
138impl ProductionModes {
139 #[must_use]
141 pub const fn dead_code_matches_health(self) -> bool {
142 self.dead_code == self.health
143 }
144
145 #[must_use]
148 pub const fn dead_code_matches_dupes(self) -> bool {
149 self.dead_code == self.dupes
150 }
151
152 #[must_use]
154 pub const fn all_match(self) -> bool {
155 self.dead_code_matches_health() && self.dead_code_matches_dupes()
156 }
157}
158
159#[derive(Debug)]
161pub struct ProjectConfig {
162 pub config: ResolvedConfig,
164 pub path: Option<PathBuf>,
166 pub workspaces: Vec<WorkspaceInfo>,
168 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
170 pub workspace_discovery_ms: Option<f64>,
172 pub inputs: fallow_config::ConfigInputs,
175 pub inputs_before_resolve: fallow_config::ConfigInputsSnapshot,
177}
178
179#[derive(Debug, Clone, Copy)]
181pub struct ProjectConfigOptions {
182 pub output: OutputFormat,
184 pub no_cache: bool,
186 pub threads: usize,
188 pub production_override: Option<bool>,
191 pub quiet: bool,
193 pub analysis: ProductionAnalysis,
195 pub allow_remote_extends: bool,
197}
198
199#[derive(Debug)]
201#[non_exhaustive]
202pub struct ProjectConfigReadiness {
203 pub project: ProjectConfig,
205 pub configured_plugin_diagnostics: Vec<fallow_config::ConfiguredPluginDiagnostic>,
207}
208
209pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
216 config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
217}
218
219pub fn config_for_project_with_load_options(
225 root: &Path,
226 config_path: Option<&Path>,
227 load_options: ConfigLoadOptions,
228) -> EngineResult<ProjectConfig> {
229 let user_config = load_user_config(root, config_path, load_options)?;
230 let (mut config, path) = match user_config {
231 Some((config, path)) => (config, Some(path)),
232 None => (FallowConfig::default(), None),
233 };
234 if path.is_some() {
235 config.production = config
236 .production
237 .for_analysis(ProductionAnalysis::DeadCode)
238 .into();
239 validate_boundaries_and_rule_packs(root, &config)?;
240 }
241 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
242 let inputs = fallow_config::ConfigInputs::new(root, &config);
243 let inputs_before_resolve = inputs.snapshot();
244 let mut resolved = config.resolve(
245 root.to_path_buf(),
246 OutputFormat::Human,
247 threads,
248 false,
249 true,
250 None,
251 );
252 apply_env_overrides(&mut resolved);
253 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
254 collect_workspace_metadata(&resolved)?;
255 Ok(ProjectConfig {
256 inputs,
257 inputs_before_resolve,
258 config: resolved,
259 path,
260 workspaces,
261 workspace_diagnostics,
262 workspace_discovery_ms: Some(workspace_discovery_ms),
263 })
264}
265
266#[must_use]
268pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
269 config
270 .cache_max_size_mb
271 .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
272 (mb as usize).saturating_mul(1024 * 1024)
273 })
274}
275
276pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
277 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
278 let inputs = fallow_config::ConfigInputs::new(root, &FallowConfig::default());
279 let inputs_before_resolve = inputs.snapshot();
280 let mut config = FallowConfig::default().resolve(
281 root.to_path_buf(),
282 OutputFormat::Human,
283 threads,
284 false,
285 true,
286 None,
287 );
288 apply_env_overrides(&mut config);
289 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
290 collect_workspace_metadata_lossy(&config);
291 ProjectConfig {
292 inputs,
293 inputs_before_resolve,
294 config,
295 path: None,
296 workspaces,
297 workspace_diagnostics,
298 workspace_discovery_ms: Some(workspace_discovery_ms),
299 }
300}
301
302pub fn config_for_project_analysis(
313 root: &Path,
314 config_path: Option<&Path>,
315 options: ProjectConfigOptions,
316) -> EngineResult<ProjectConfig> {
317 resolve_project_config_analysis(root, config_path, options).map(|(project, _)| project)
318}
319
320pub fn config_for_project_readiness(
327 root: &Path,
328 config_path: Option<&Path>,
329 options: ProjectConfigOptions,
330) -> EngineResult<ProjectConfigReadiness> {
331 let (project, configured_plugin_paths) =
332 resolve_project_config_analysis(root, config_path, options)?;
333 let configured_plugin_diagnostics =
334 fallow_config::diagnose_configured_external_plugins(root, &configured_plugin_paths);
335 Ok(ProjectConfigReadiness {
336 project,
337 configured_plugin_diagnostics,
338 })
339}
340
341fn resolve_project_config_analysis(
342 root: &Path,
343 config_path: Option<&Path>,
344 options: ProjectConfigOptions,
345) -> EngineResult<(ProjectConfig, Vec<String>)> {
346 let user_config = load_user_config(
347 root,
348 config_path,
349 ConfigLoadOptions {
350 allow_remote_extends: options.allow_remote_extends,
351 },
352 )?;
353 let loaded_user_config = user_config.is_some();
354 let (mut config, path) = match user_config {
355 Some((config, path)) => (config, Some(path)),
356 None => (
357 FallowConfig {
358 production: options.production_override.unwrap_or(false).into(),
359 ..FallowConfig::default()
360 },
361 None,
362 ),
363 };
364
365 if loaded_user_config {
366 let production = ProductionFlags {
367 global: options.production_override,
368 ..ProductionFlags::default()
369 }
370 .effective(options.analysis, config.production);
371 config.production = production.into();
372 }
373 validate_config(root, &config)?;
374 let configured_plugin_paths = config.plugins.clone();
375 let inputs = fallow_config::ConfigInputs::new(root, &config);
376 let inputs_before_resolve = inputs.snapshot();
377 let mut resolved = config.resolve(
378 root.to_path_buf(),
379 options.output,
380 options.threads,
381 options.no_cache,
382 options.quiet,
383 None,
384 );
385 apply_env_overrides(&mut resolved);
386 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
387 collect_workspace_metadata(&resolved)?;
388 Ok((
389 ProjectConfig {
390 inputs,
391 inputs_before_resolve,
392 config: resolved,
393 path,
394 workspaces,
395 workspace_diagnostics,
396 workspace_discovery_ms: Some(workspace_discovery_ms),
397 },
398 configured_plugin_paths,
399 ))
400}
401
402fn apply_env_overrides(config: &mut ResolvedConfig) {
406 apply_env_overrides_from(config, |name| std::env::var_os(name));
407}
408
409fn apply_env_overrides_from(
410 config: &mut ResolvedConfig,
411 lookup: impl Fn(&str) -> Option<OsString>,
412) {
413 let max_file_size_mb = lookup("FALLOW_MAX_FILE_SIZE")
414 .and_then(|raw| raw.to_str().and_then(|raw| raw.trim().parse::<u32>().ok()));
415 if let Some(max_file_size_mb) = max_file_size_mb {
416 config.max_file_size_bytes =
417 fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
418 }
419 if let Some(dir) =
420 lookup(fallow_config::CACHE_DIR_ENV).and_then(fallow_config::cache_dir_from_env_value)
421 {
422 config.override_cache_dir(dir);
423 }
424 if let Some(mb) = lookup(fallow_config::CACHE_MAX_SIZE_ENV)
425 .as_deref()
426 .and_then(fallow_config::cache_max_size_from_env_value)
427 {
428 config.cache_max_size_mb = Some(mb);
429 }
430 config.apply_package_baselines_env(lookup(fallow_config::PACKAGE_BASELINES_ENV).as_deref());
431}
432
433pub(crate) fn collect_workspace_metadata(
434 config: &ResolvedConfig,
435) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
436 let start = std::time::Instant::now();
437 let (workspaces, diagnostics) =
438 fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
439 .map_err(|err| EngineError::new(err.to_string()))?;
440 let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
441 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
442 Ok((workspaces, diagnostics, elapsed_ms))
443}
444
445fn collect_workspace_metadata_lossy(
446 config: &ResolvedConfig,
447) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
448 collect_workspace_metadata(config).unwrap_or_default()
449}
450
451fn with_undeclared_workspace_diagnostics(
452 config: &ResolvedConfig,
453 workspaces: &[WorkspaceInfo],
454 mut diagnostics: Vec<WorkspaceDiagnostic>,
455) -> Vec<WorkspaceDiagnostic> {
456 let mut existing: FxHashSet<PathBuf> = diagnostics
457 .iter()
458 .map(|diagnostic| {
459 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
460 })
461 .collect();
462 for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
463 &config.root,
464 workspaces,
465 &config.ignore_patterns,
466 ) {
467 let canonical =
468 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
469 if existing.insert(canonical) {
470 diagnostics.push(diagnostic);
471 }
472 }
473 diagnostics
474}
475
476fn load_user_config(
477 root: &Path,
478 config_path: Option<&Path>,
479 options: ConfigLoadOptions,
480) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
481 if let Some(path) = config_path {
482 let config = FallowConfig::load_with_options(path, options)
483 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
484 return Ok(Some((config, path.to_path_buf())));
485 }
486 FallowConfig::find_and_load_with_options(root, options)
487 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
488}
489
490fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
491 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
492 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
493 validate_boundaries_and_rule_packs(root, config)
494}
495
496fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
497 config
498 .validate_resolved_boundaries(root)
499 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
500 let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
501 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
502 let zone_errors = fallow_config::validate_rule_pack_zones(
503 root,
504 &config.boundaries,
505 &config.rule_packs,
506 &packs,
507 );
508 if !zone_errors.is_empty() {
509 return Err(joined_config_errors("invalid rule pack", &zone_errors));
510 }
511 Ok(())
512}
513
514fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
515 let joined = errors
516 .iter()
517 .map(ToString::to_string)
518 .collect::<Vec<_>>()
519 .join("\n - ");
520 EngineError::new(format!("{label}:\n - {joined}"))
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn lookup_from(
528 vars: &'static [(&'static str, &'static str)],
529 ) -> impl Fn(&str) -> Option<OsString> {
530 move |name| {
531 vars.iter()
532 .find(|(key, _)| *key == name)
533 .map(|(_, value)| OsString::from(value))
534 }
535 }
536
537 #[test]
538 fn env_overrides_reach_every_host_config() {
539 let root = Path::new("/repo");
540 let mut config = default_project_config(root).config;
541
542 apply_env_overrides_from(
543 &mut config,
544 lookup_from(&[
545 ("FALLOW_MAX_FILE_SIZE", "7"),
546 ("FALLOW_CACHE_DIR", ".cache/fallow"),
547 ("FALLOW_CACHE_MAX_SIZE", "64"),
548 ]),
549 );
550
551 assert_eq!(
552 config.max_file_size_bytes,
553 fallow_config::resolve_max_file_size_bytes(Some(7))
554 );
555 assert_eq!(config.cache_dir, root.join(".cache/fallow"));
556 assert_eq!(config.cache_max_size_mb, Some(64));
557 }
558
559 #[test]
560 fn invalid_or_empty_env_values_keep_the_config() {
561 let root = Path::new("/repo");
562 let mut config = default_project_config(root).config;
563 let before_dir = config.cache_dir.clone();
564 let before_max = config.cache_max_size_mb;
565
566 apply_env_overrides_from(
567 &mut config,
568 lookup_from(&[("FALLOW_CACHE_DIR", ""), ("FALLOW_CACHE_MAX_SIZE", "0")]),
569 );
570
571 assert_eq!(config.cache_dir, before_dir);
572 assert_eq!(config.cache_max_size_mb, before_max);
573 }
574
575 #[test]
576 fn package_baselines_env_turns_the_map_off_only_for_a_false_value() {
577 let root = Path::new("/repo");
578 let with_map = || {
579 let mut config = default_project_config(root).config;
580 config
581 .workspace_changed_since
582 .insert("packages/web".to_owned(), "main".to_owned());
583 config
584 };
585
586 let mut disabled = with_map();
587 apply_env_overrides_from(
588 &mut disabled,
589 lookup_from(&[("FALLOW_PACKAGE_BASELINES", " Off ")]),
590 );
591 assert!(disabled.workspace_changed_since.is_empty());
592
593 for value in ["true", "1", "maybe", ""] {
594 let mut kept = with_map();
595 let vars: &'static [(&'static str, &'static str)] = match value {
596 "true" => &[("FALLOW_PACKAGE_BASELINES", "true")],
597 "1" => &[("FALLOW_PACKAGE_BASELINES", "1")],
598 "maybe" => &[("FALLOW_PACKAGE_BASELINES", "maybe")],
599 _ => &[("FALLOW_PACKAGE_BASELINES", "")],
600 };
601 apply_env_overrides_from(&mut kept, lookup_from(vars));
602 assert_eq!(kept.workspace_changed_since.len(), 1, "{value:?}");
603 }
604 }
605}