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_and_patterns(
439 &config.root,
440 &config.ignore_patterns,
441 &config.workspace_patterns,
442 )
443 .map_err(|err| EngineError::new(err.to_string()))?;
444 let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
445 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
446 Ok((workspaces, diagnostics, elapsed_ms))
447}
448
449fn collect_workspace_metadata_lossy(
450 config: &ResolvedConfig,
451) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
452 collect_workspace_metadata(config).unwrap_or_default()
453}
454
455fn with_undeclared_workspace_diagnostics(
456 config: &ResolvedConfig,
457 workspaces: &[WorkspaceInfo],
458 mut diagnostics: Vec<WorkspaceDiagnostic>,
459) -> Vec<WorkspaceDiagnostic> {
460 let mut existing: FxHashSet<PathBuf> = diagnostics
461 .iter()
462 .map(|diagnostic| {
463 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
464 })
465 .collect();
466 for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
467 &config.root,
468 workspaces,
469 &config.ignore_patterns,
470 ) {
471 let canonical =
472 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
473 if existing.insert(canonical) {
474 diagnostics.push(diagnostic);
475 }
476 }
477 diagnostics
478}
479
480fn load_user_config(
481 root: &Path,
482 config_path: Option<&Path>,
483 options: ConfigLoadOptions,
484) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
485 if let Some(path) = config_path {
486 let config = FallowConfig::load_with_options(path, options)
487 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
488 return Ok(Some((config, path.to_path_buf())));
489 }
490 FallowConfig::find_and_load_with_options(root, options)
491 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
492}
493
494fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
495 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
496 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
497 validate_boundaries_and_rule_packs(root, config)
498}
499
500fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
501 config
502 .validate_resolved_boundaries(root)
503 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
504 let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
505 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
506 let zone_errors = fallow_config::validate_rule_pack_zones(
507 root,
508 &config.boundaries,
509 &config.rule_packs,
510 &packs,
511 );
512 if !zone_errors.is_empty() {
513 return Err(joined_config_errors("invalid rule pack", &zone_errors));
514 }
515 Ok(())
516}
517
518fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
519 let joined = errors
520 .iter()
521 .map(ToString::to_string)
522 .collect::<Vec<_>>()
523 .join("\n - ");
524 EngineError::new(format!("{label}:\n - {joined}"))
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 fn lookup_from(
532 vars: &'static [(&'static str, &'static str)],
533 ) -> impl Fn(&str) -> Option<OsString> {
534 move |name| {
535 vars.iter()
536 .find(|(key, _)| *key == name)
537 .map(|(_, value)| OsString::from(value))
538 }
539 }
540
541 #[test]
542 fn env_overrides_reach_every_host_config() {
543 let root = Path::new("/repo");
544 let mut config = default_project_config(root).config;
545
546 apply_env_overrides_from(
547 &mut config,
548 lookup_from(&[
549 ("FALLOW_MAX_FILE_SIZE", "7"),
550 ("FALLOW_CACHE_DIR", ".cache/fallow"),
551 ("FALLOW_CACHE_MAX_SIZE", "64"),
552 ]),
553 );
554
555 assert_eq!(
556 config.max_file_size_bytes,
557 fallow_config::resolve_max_file_size_bytes(Some(7))
558 );
559 assert_eq!(config.cache_dir, root.join(".cache/fallow"));
560 assert_eq!(config.cache_max_size_mb, Some(64));
561 }
562
563 #[test]
564 fn invalid_or_empty_env_values_keep_the_config() {
565 let root = Path::new("/repo");
566 let mut config = default_project_config(root).config;
567 let before_dir = config.cache_dir.clone();
568 let before_max = config.cache_max_size_mb;
569
570 apply_env_overrides_from(
571 &mut config,
572 lookup_from(&[("FALLOW_CACHE_DIR", ""), ("FALLOW_CACHE_MAX_SIZE", "0")]),
573 );
574
575 assert_eq!(config.cache_dir, before_dir);
576 assert_eq!(config.cache_max_size_mb, before_max);
577 }
578
579 #[test]
580 fn package_baselines_env_turns_the_map_off_only_for_a_false_value() {
581 let root = Path::new("/repo");
582 let with_map = || {
583 let mut config = default_project_config(root).config;
584 config
585 .workspace_changed_since
586 .insert("packages/web".to_owned(), "main".to_owned());
587 config
588 };
589
590 let mut disabled = with_map();
591 apply_env_overrides_from(
592 &mut disabled,
593 lookup_from(&[("FALLOW_PACKAGE_BASELINES", " Off ")]),
594 );
595 assert!(disabled.workspace_changed_since.is_empty());
596
597 for value in ["true", "1", "maybe", ""] {
598 let mut kept = with_map();
599 let vars: &'static [(&'static str, &'static str)] = match value {
600 "true" => &[("FALLOW_PACKAGE_BASELINES", "true")],
601 "1" => &[("FALLOW_PACKAGE_BASELINES", "1")],
602 "maybe" => &[("FALLOW_PACKAGE_BASELINES", "maybe")],
603 _ => &[("FALLOW_PACKAGE_BASELINES", "")],
604 };
605 apply_env_overrides_from(&mut kept, lookup_from(vars));
606 assert_eq!(kept.workspace_changed_since.len(), 1, "{value:?}");
607 }
608 }
609}