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 mut resolved = config.resolve(
83 root.to_path_buf(),
84 OutputFormat::Human,
85 threads,
86 false,
87 true,
88 None,
89 );
90 apply_max_file_size_env(&mut resolved);
91 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
92 collect_workspace_metadata(&resolved)?;
93 Ok(ProjectConfig {
94 config: resolved,
95 path,
96 workspaces,
97 workspace_diagnostics,
98 workspace_discovery_ms: Some(workspace_discovery_ms),
99 })
100}
101
102#[must_use]
104pub(crate) fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
105 config
106 .cache_max_size_mb
107 .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
108 (mb as usize).saturating_mul(1024 * 1024)
109 })
110}
111
112pub(crate) fn default_project_config(root: &Path) -> ProjectConfig {
113 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
114 let config = FallowConfig::default().resolve(
115 root.to_path_buf(),
116 OutputFormat::Human,
117 threads,
118 false,
119 true,
120 None,
121 );
122 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
123 collect_workspace_metadata_lossy(&config);
124 ProjectConfig {
125 config,
126 path: None,
127 workspaces,
128 workspace_diagnostics,
129 workspace_discovery_ms: Some(workspace_discovery_ms),
130 }
131}
132
133pub fn config_for_project_analysis(
144 root: &Path,
145 config_path: Option<&Path>,
146 options: ProjectConfigOptions,
147) -> EngineResult<ProjectConfig> {
148 let user_config = load_user_config(
149 root,
150 config_path,
151 ConfigLoadOptions {
152 allow_remote_extends: options.allow_remote_extends,
153 },
154 )?;
155 let loaded_user_config = user_config.is_some();
156 let (mut config, path) = match user_config {
157 Some((config, path)) => (config, Some(path)),
158 None => (
159 FallowConfig {
160 production: options.production_override.unwrap_or(false).into(),
161 ..FallowConfig::default()
162 },
163 None,
164 ),
165 };
166
167 if loaded_user_config {
168 let production = options
169 .production_override
170 .unwrap_or_else(|| config.production.for_analysis(options.analysis));
171 config.production = production.into();
172 }
173 validate_config(root, &config)?;
174 let mut resolved = config.resolve(
175 root.to_path_buf(),
176 options.output,
177 options.threads,
178 options.no_cache,
179 options.quiet,
180 None,
181 );
182 apply_max_file_size_env(&mut resolved);
183 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
184 collect_workspace_metadata(&resolved)?;
185 Ok(ProjectConfig {
186 config: resolved,
187 path,
188 workspaces,
189 workspace_diagnostics,
190 workspace_discovery_ms: Some(workspace_discovery_ms),
191 })
192}
193
194fn apply_max_file_size_env(config: &mut ResolvedConfig) {
195 let max_file_size_mb = std::env::var("FALLOW_MAX_FILE_SIZE")
196 .ok()
197 .and_then(|raw| raw.trim().parse::<u32>().ok());
198 if let Some(max_file_size_mb) = max_file_size_mb {
199 config.max_file_size_bytes =
200 fallow_config::resolve_max_file_size_bytes(Some(max_file_size_mb));
201 }
202}
203
204pub(crate) fn collect_workspace_metadata(
205 config: &ResolvedConfig,
206) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
207 let start = std::time::Instant::now();
208 let (workspaces, diagnostics) =
209 fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
210 .map_err(|err| EngineError::new(err.to_string()))?;
211 let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
212 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
213 Ok((workspaces, diagnostics, elapsed_ms))
214}
215
216fn collect_workspace_metadata_lossy(
217 config: &ResolvedConfig,
218) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
219 collect_workspace_metadata(config).unwrap_or_default()
220}
221
222fn with_undeclared_workspace_diagnostics(
223 config: &ResolvedConfig,
224 workspaces: &[WorkspaceInfo],
225 mut diagnostics: Vec<WorkspaceDiagnostic>,
226) -> Vec<WorkspaceDiagnostic> {
227 let mut existing: FxHashSet<PathBuf> = diagnostics
228 .iter()
229 .map(|diagnostic| {
230 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
231 })
232 .collect();
233 for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
234 &config.root,
235 workspaces,
236 &config.ignore_patterns,
237 ) {
238 let canonical =
239 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
240 if existing.insert(canonical) {
241 diagnostics.push(diagnostic);
242 }
243 }
244 diagnostics
245}
246
247fn load_user_config(
248 root: &Path,
249 config_path: Option<&Path>,
250 options: ConfigLoadOptions,
251) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
252 if let Some(path) = config_path {
253 let config = FallowConfig::load_with_options(path, options)
254 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
255 return Ok(Some((config, path.to_path_buf())));
256 }
257 FallowConfig::find_and_load_with_options(root, options)
258 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
259}
260
261fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
262 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
263 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
264 validate_boundaries_and_rule_packs(root, config)
265}
266
267fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
268 config
269 .validate_resolved_boundaries(root)
270 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
271 let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
272 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
273 let boundaries =
274 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
275 let zone_errors = fallow_config::validate_rule_pack_zone_references(
276 root,
277 &config.rule_packs,
278 &packs,
279 &boundaries,
280 );
281 if !zone_errors.is_empty() {
282 return Err(joined_config_errors("invalid rule pack", &zone_errors));
283 }
284 Ok(())
285}
286
287fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
288 let joined = errors
289 .iter()
290 .map(ToString::to_string)
291 .collect::<Vec<_>>()
292 .join("\n - ");
293 EngineError::new(format!("{label}:\n - {joined}"))
294}