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,
18 pub path: Option<PathBuf>,
19 pub workspaces: Vec<WorkspaceInfo>,
20 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
21 pub workspace_discovery_ms: Option<f64>,
22}
23
24#[derive(Debug, Clone, Copy)]
26pub struct ProjectConfigOptions {
27 pub output: OutputFormat,
28 pub no_cache: bool,
29 pub threads: usize,
30 pub production_override: Option<bool>,
31 pub quiet: bool,
32 pub analysis: ProductionAnalysis,
33 pub allow_remote_extends: bool,
34}
35
36pub fn config_for_project(root: &Path, config_path: Option<&Path>) -> EngineResult<ProjectConfig> {
43 config_for_project_with_load_options(root, config_path, ConfigLoadOptions::default())
44}
45
46pub fn config_for_project_with_load_options(
52 root: &Path,
53 config_path: Option<&Path>,
54 load_options: ConfigLoadOptions,
55) -> EngineResult<ProjectConfig> {
56 let user_config = load_user_config(root, config_path, load_options)?;
57 let (mut config, path) = match user_config {
58 Some((config, path)) => (config, Some(path)),
59 None => (FallowConfig::default(), None),
60 };
61 if path.is_some() {
62 config.production = config
63 .production
64 .for_analysis(ProductionAnalysis::DeadCode)
65 .into();
66 validate_boundaries_and_rule_packs(root, &config)?;
67 }
68 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
69 let resolved = config.resolve(
70 root.to_path_buf(),
71 OutputFormat::Human,
72 threads,
73 false,
74 true,
75 None,
76 );
77 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
78 collect_workspace_metadata(&resolved)?;
79 Ok(ProjectConfig {
80 config: resolved,
81 path,
82 workspaces,
83 workspace_diagnostics,
84 workspace_discovery_ms: Some(workspace_discovery_ms),
85 })
86}
87
88#[must_use]
90pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
91 config
92 .cache_max_size_mb
93 .map_or(fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
94 (mb as usize).saturating_mul(1024 * 1024)
95 })
96}
97
98pub fn default_project_config(root: &Path) -> ProjectConfig {
99 let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
100 let config = FallowConfig::default().resolve(
101 root.to_path_buf(),
102 OutputFormat::Human,
103 threads,
104 false,
105 true,
106 None,
107 );
108 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
109 collect_workspace_metadata_lossy(&config);
110 ProjectConfig {
111 config,
112 path: None,
113 workspaces,
114 workspace_diagnostics,
115 workspace_discovery_ms: Some(workspace_discovery_ms),
116 }
117}
118
119pub fn config_for_project_analysis(
130 root: &Path,
131 config_path: Option<&Path>,
132 options: ProjectConfigOptions,
133) -> EngineResult<ProjectConfig> {
134 let user_config = load_user_config(
135 root,
136 config_path,
137 ConfigLoadOptions {
138 allow_remote_extends: options.allow_remote_extends,
139 },
140 )?;
141 let loaded_user_config = user_config.is_some();
142 let (mut config, path) = match user_config {
143 Some((config, path)) => (config, Some(path)),
144 None => (
145 FallowConfig {
146 production: options.production_override.unwrap_or(false).into(),
147 ..FallowConfig::default()
148 },
149 None,
150 ),
151 };
152
153 if loaded_user_config {
154 let production = options
155 .production_override
156 .unwrap_or_else(|| config.production.for_analysis(options.analysis));
157 config.production = production.into();
158 }
159 validate_config(root, &config)?;
160 let resolved = config.resolve(
161 root.to_path_buf(),
162 options.output,
163 options.threads,
164 options.no_cache,
165 options.quiet,
166 None,
167 );
168 let (workspaces, workspace_diagnostics, workspace_discovery_ms) =
169 collect_workspace_metadata(&resolved)?;
170 Ok(ProjectConfig {
171 config: resolved,
172 path,
173 workspaces,
174 workspace_diagnostics,
175 workspace_discovery_ms: Some(workspace_discovery_ms),
176 })
177}
178
179fn collect_workspace_metadata(
180 config: &ResolvedConfig,
181) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64)> {
182 let start = std::time::Instant::now();
183 let (workspaces, diagnostics) =
184 fallow_config::discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
185 .map_err(|err| EngineError::new(err.to_string()))?;
186 let diagnostics = with_undeclared_workspace_diagnostics(config, &workspaces, diagnostics);
187 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
188 Ok((workspaces, diagnostics, elapsed_ms))
189}
190
191fn collect_workspace_metadata_lossy(
192 config: &ResolvedConfig,
193) -> (Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>, f64) {
194 collect_workspace_metadata(config).unwrap_or_default()
195}
196
197fn with_undeclared_workspace_diagnostics(
198 config: &ResolvedConfig,
199 workspaces: &[WorkspaceInfo],
200 mut diagnostics: Vec<WorkspaceDiagnostic>,
201) -> Vec<WorkspaceDiagnostic> {
202 let mut existing: FxHashSet<PathBuf> = diagnostics
203 .iter()
204 .map(|diagnostic| {
205 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
206 })
207 .collect();
208 for diagnostic in fallow_config::find_undeclared_workspaces_with_ignores(
209 &config.root,
210 workspaces,
211 &config.ignore_patterns,
212 ) {
213 let canonical =
214 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
215 if existing.insert(canonical) {
216 diagnostics.push(diagnostic);
217 }
218 }
219 diagnostics
220}
221
222fn load_user_config(
223 root: &Path,
224 config_path: Option<&Path>,
225 options: ConfigLoadOptions,
226) -> EngineResult<Option<(FallowConfig, PathBuf)>> {
227 if let Some(path) = config_path {
228 let config = FallowConfig::load_with_options(path, options)
229 .map_err(|err| EngineError::new(format!("invalid config: {err:#}")))?;
230 return Ok(Some((config, path.to_path_buf())));
231 }
232 FallowConfig::find_and_load_with_options(root, options)
233 .map_err(|err| EngineError::new(format!("invalid config: {err}")))
234}
235
236fn validate_config(root: &Path, config: &FallowConfig) -> EngineResult<()> {
237 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
238 .map_err(|errors| joined_config_errors("invalid external plugin definition", &errors))?;
239 validate_boundaries_and_rule_packs(root, config)
240}
241
242fn validate_boundaries_and_rule_packs(root: &Path, config: &FallowConfig) -> EngineResult<()> {
243 config
244 .validate_resolved_boundaries(root)
245 .map_err(|errors| joined_config_errors("invalid boundary configuration", &errors))?;
246 let packs = fallow_config::load_rule_packs(root, &config.rule_packs)
247 .map_err(|errors| joined_config_errors("invalid rule pack", &errors))?;
248 let boundaries =
249 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
250 let zone_errors = fallow_config::validate_rule_pack_zone_references(
251 root,
252 &config.rule_packs,
253 &packs,
254 &boundaries,
255 );
256 if !zone_errors.is_empty() {
257 return Err(joined_config_errors("invalid rule pack", &zone_errors));
258 }
259 Ok(())
260}
261
262fn joined_config_errors(label: &str, errors: &[impl ToString]) -> EngineError {
263 let joined = errors
264 .iter()
265 .map(ToString::to_string)
266 .collect::<Vec<_>>()
267 .join("\n - ");
268 EngineError::new(format!("{label}:\n - {joined}"))
269}