1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3use std::sync::{LazyLock, Mutex, OnceLock};
4
5use fallow_config::{
6 FallowConfig, OutputFormat, PartialRulesConfig, ProductionAnalysis, ResolvedConfig, RulesConfig,
7};
8use rustc_hash::FxHashSet;
9
10static CONFIG_LOADED_LOGGED: LazyLock<Mutex<FxHashSet<PathBuf>>> =
11 LazyLock::new(|| Mutex::new(FxHashSet::default()));
12
13static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
19
20pub fn set_max_file_size_override(max_file_size_mb: Option<u32>) {
23 let _ = MAX_FILE_SIZE_OVERRIDE.set(max_file_size_mb);
24}
25
26fn resolve_max_file_size_mb() -> Option<u32> {
30 if let Some(Some(mb)) = MAX_FILE_SIZE_OVERRIDE.get() {
31 return Some(*mb);
32 }
33 std::env::var("FALLOW_MAX_FILE_SIZE")
34 .ok()
35 .and_then(|raw| raw.trim().parse::<u32>().ok())
36}
37
38#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
40pub enum AnalysisKind {
41 #[value(alias = "check")]
42 DeadCode,
43 Dupes,
44 Health,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
49pub enum GroupBy {
50 #[value(alias = "team", alias = "codeowner")]
52 Owner,
53 Directory,
55 #[value(alias = "workspace", alias = "pkg")]
57 Package,
58 #[value(alias = "gl-section")]
62 Section,
63}
64
65pub fn build_ownership_resolver(
70 group_by: Option<GroupBy>,
71 root: &Path,
72 codeowners_path: Option<&str>,
73 output: OutputFormat,
74) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
75 let Some(mode) = group_by else {
76 return Ok(None);
77 };
78 match mode {
79 GroupBy::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
80 Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
81 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
82 },
83 GroupBy::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
84 Ok(co) => {
85 if co.has_sections() {
86 Ok(Some(crate::report::OwnershipResolver::Section(co)))
87 } else {
88 Err(crate::error::emit_error(
89 "--group-by section requires a GitLab-style CODEOWNERS file \
90 with `[Section]` headers. This CODEOWNERS has no sections; \
91 use --group-by owner instead.",
92 2,
93 output,
94 ))
95 }
96 }
97 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
98 },
99 GroupBy::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
100 GroupBy::Package => {
101 let workspaces = fallow_config::discover_workspaces(root);
102 if workspaces.is_empty() {
103 Err(crate::error::emit_error(
104 "--group-by package requires a monorepo with workspace packages \
105 (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
106 For single-package projects try --group-by directory instead.",
107 2,
108 output,
109 ))
110 } else {
111 Ok(Some(crate::report::OwnershipResolver::Package(
112 crate::report::grouping::PackageResolver::new(root, &workspaces),
113 )))
114 }
115 }
116 }
117}
118
119fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
124 if quiet || !matches!(output, OutputFormat::Human) {
125 return;
126 }
127 if !should_log_config_loaded(path) {
128 return;
129 }
130 eprintln!("loaded config: {}", path.display());
131}
132
133fn should_log_config_loaded(path: &Path) -> bool {
134 let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
135 CONFIG_LOADED_LOGGED
136 .lock()
137 .is_ok_and(|mut logged| logged.insert(key))
138}
139
140#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
141pub fn load_config(
142 root: &Path,
143 config_path: &Option<PathBuf>,
144 output: OutputFormat,
145 no_cache: bool,
146 threads: usize,
147 production: bool,
148 quiet: bool,
149) -> Result<ResolvedConfig, ExitCode> {
150 load_config_for_analysis(
151 root,
152 config_path,
153 output,
154 no_cache,
155 threads,
156 production.then_some(true),
157 quiet,
158 ProductionAnalysis::DeadCode,
159 )
160}
161
162#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
163#[expect(
164 clippy::too_many_arguments,
165 reason = "central config loader mirrors CLI dispatch options"
166)]
167pub fn load_config_for_analysis(
168 root: &Path,
169 config_path: &Option<PathBuf>,
170 output: OutputFormat,
171 no_cache: bool,
172 threads: usize,
173 production_override: Option<bool>,
174 quiet: bool,
175 analysis: ProductionAnalysis,
176) -> Result<ResolvedConfig, ExitCode> {
177 let user_config = if let Some(path) = config_path {
178 match FallowConfig::load(path) {
179 Ok(c) => {
180 log_config_loaded(path, output, quiet);
181 Some(c)
182 }
183 Err(e) => {
184 let msg = format!("failed to load config '{}': {e}", path.display());
185 return Err(crate::error::emit_error(&msg, 2, output));
186 }
187 }
188 } else {
189 match FallowConfig::find_and_load(root) {
190 Ok(Some((config, found_path))) => {
191 log_config_loaded(&found_path, output, quiet);
192 Some(config)
193 }
194 Ok(None) => None,
195 Err(e) => {
196 return Err(crate::error::emit_error(&e, 2, output));
197 }
198 }
199 };
200
201 let loaded_user_config = user_config.is_some();
202 let final_config = match user_config {
203 Some(mut config) => {
204 let production =
205 production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
206 config.production = production.into();
207 config
208 }
209 None => FallowConfig {
210 production: production_override.unwrap_or(false).into(),
211 ..FallowConfig::default()
212 },
213 };
214 crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
215
216 if let Err(errors) =
217 fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
218 {
219 let joined = errors
220 .iter()
221 .map(ToString::to_string)
222 .collect::<Vec<_>>()
223 .join("\n - ");
224 let msg = format!("invalid external plugin definition:\n - {joined}");
225 return Err(crate::error::emit_error(&msg, 2, output));
226 }
227
228 if let Err(errors) = final_config.validate_resolved_boundaries(root) {
229 let joined = errors
230 .iter()
231 .map(ToString::to_string)
232 .collect::<Vec<_>>()
233 .join("\n - ");
234 let msg = format!("invalid boundary configuration:\n - {joined}");
235 return Err(crate::error::emit_error(&msg, 2, output));
236 }
237
238 if let Err(errors) = fallow_config::load_rule_packs(root, &final_config.rule_packs) {
241 let joined = errors
242 .iter()
243 .map(ToString::to_string)
244 .collect::<Vec<_>>()
245 .join("\n - ");
246 let msg = format!("invalid rule pack:\n - {joined}");
247 return Err(crate::error::emit_error(&msg, 2, output));
248 }
249
250 let cache_max_size_mb = resolve_cache_max_size_env();
251 let mut resolved = final_config.resolve(
252 root.to_path_buf(),
253 output,
254 threads,
255 no_cache,
256 quiet,
257 cache_max_size_mb,
258 );
259 if let Some(mb) = resolve_max_file_size_mb() {
260 resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
261 }
262 apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
263 crate::cache_notice::record_candidate(
264 root,
265 &resolved.cache_dir,
266 output,
267 quiet,
268 resolved.no_cache,
269 );
270
271 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
272 Ok((_, diagnostics)) => {
273 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
274 if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
275 eprintln!(
276 "fallow: {} workspace discovery diagnostic{}. \
277 Run `fallow list --workspaces` for detail.",
278 diagnostics.len(),
279 if diagnostics.len() == 1 { "" } else { "s" }
280 );
281 }
282 }
283 Err(err) => {
284 return Err(crate::error::emit_error(&err.to_string(), 2, output));
285 }
286 }
287
288 Ok(resolved)
289}
290
291fn config_shape_for(
292 config: &FallowConfig,
293 loaded_user_config: bool,
294) -> crate::telemetry::ConfigShape {
295 if !config.plugins.is_empty() || !config.framework.is_empty() {
296 return crate::telemetry::ConfigShape::PluginsEnabled;
297 }
298 if config.rules != RulesConfig::default()
299 || config
300 .overrides
301 .iter()
302 .any(|entry| partial_rules_config_has_values(&entry.rules))
303 {
304 return crate::telemetry::ConfigShape::CustomRules;
305 }
306 if loaded_user_config {
307 return crate::telemetry::ConfigShape::CustomConfig;
308 }
309 crate::telemetry::ConfigShape::Default
310}
311
312fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
313 serde_json::to_value(rules)
314 .ok()
315 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
316 .unwrap_or(false)
317}
318
319#[must_use]
324pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
325 fallow_config::workspace_diagnostics_for(root)
326}
327
328fn resolve_cache_max_size_env() -> Option<u32> {
334 std::env::var("FALLOW_CACHE_MAX_SIZE")
335 .ok()
336 .and_then(|raw| raw.trim().parse::<u32>().ok())
337 .filter(|mb| *mb > 0)
338}
339
340fn resolve_cache_dir_env() -> Option<PathBuf> {
343 std::env::var_os("FALLOW_CACHE_DIR")
344 .map(PathBuf::from)
345 .filter(|path| !path.as_os_str().is_empty())
346}
347
348fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
349 if path.is_absolute() {
350 path
351 } else {
352 root.join(path)
353 }
354}
355
356fn apply_cache_dir_env_override(
357 root: &Path,
358 resolved: &mut ResolvedConfig,
359 env_value: Option<PathBuf>,
360) {
361 if let Some(path) = env_value {
362 resolved.cache_dir = resolve_cache_dir_value(root, path);
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn config_loaded_notice_dedupes_by_config_path() {
372 let dir = tempfile::tempdir().unwrap();
373 let first = dir.path().join("first.fallow.json");
374 let second = dir.path().join("second.fallow.json");
375 std::fs::write(&first, "{}").unwrap();
376 std::fs::write(&second, "{}").unwrap();
377
378 assert!(should_log_config_loaded(&first));
379 assert!(!should_log_config_loaded(&first));
380 assert!(should_log_config_loaded(&second));
381 }
382
383 #[test]
384 fn cache_dir_env_value_resolves_relative_to_project_root() {
385 assert_eq!(
386 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
387 PathBuf::from("/repo/.cache/fallow")
388 );
389 assert_eq!(
390 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
391 PathBuf::from("/tmp/fallow-cache")
392 );
393 }
394
395 #[test]
396 fn cache_dir_env_value_wins_over_configured_cache_dir() {
397 let mut resolved = FallowConfig {
398 cache: fallow_config::CacheConfig {
399 dir: Some(PathBuf::from(".cache/from-config")),
400 ..Default::default()
401 },
402 ..Default::default()
403 }
404 .resolve(
405 PathBuf::from("/repo"),
406 OutputFormat::Human,
407 1,
408 false,
409 true,
410 None,
411 );
412
413 apply_cache_dir_env_override(
414 Path::new("/repo"),
415 &mut resolved,
416 Some(PathBuf::from(".cache/from-env")),
417 );
418
419 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
420 }
421}