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 fallow_output::GroupByMode;
9use rustc_hash::FxHashSet;
10
11static CONFIG_LOADED_LOGGED: LazyLock<Mutex<FxHashSet<PathBuf>>> =
12 LazyLock::new(|| Mutex::new(FxHashSet::default()));
13
14static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
20
21pub fn set_max_file_size_override(max_file_size_mb: Option<u32>) {
24 let _ = MAX_FILE_SIZE_OVERRIDE.set(max_file_size_mb);
25}
26
27fn resolve_max_file_size_mb() -> Option<u32> {
31 if let Some(Some(mb)) = MAX_FILE_SIZE_OVERRIDE.get() {
32 return Some(*mb);
33 }
34 std::env::var("FALLOW_MAX_FILE_SIZE")
35 .ok()
36 .and_then(|raw| raw.trim().parse::<u32>().ok())
37}
38
39#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
41pub enum AnalysisKind {
42 #[value(alias = "check")]
43 DeadCode,
44 Dupes,
45 Health,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
50pub enum GroupBy {
51 #[value(alias = "team", alias = "codeowner")]
53 Owner,
54 Directory,
56 #[value(alias = "workspace", alias = "pkg")]
58 Package,
59 #[value(alias = "gl-section")]
63 Section,
64}
65
66impl From<GroupBy> for GroupByMode {
67 fn from(value: GroupBy) -> Self {
68 match value {
69 GroupBy::Owner => Self::Owner,
70 GroupBy::Directory => Self::Directory,
71 GroupBy::Package => Self::Package,
72 GroupBy::Section => Self::Section,
73 }
74 }
75}
76
77pub fn build_ownership_resolver(
82 group_by: Option<GroupBy>,
83 root: &Path,
84 codeowners_path: Option<&str>,
85 output: OutputFormat,
86) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
87 build_ownership_resolver_for_mode(group_by.map(Into::into), root, codeowners_path, output)
88}
89
90pub fn build_ownership_resolver_for_mode(
92 group_by: Option<GroupByMode>,
93 root: &Path,
94 codeowners_path: Option<&str>,
95 output: OutputFormat,
96) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
97 let Some(mode) = group_by else {
98 return Ok(None);
99 };
100 match mode {
101 GroupByMode::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
102 Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
103 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
104 },
105 GroupByMode::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
106 Ok(co) => {
107 if co.has_sections() {
108 Ok(Some(crate::report::OwnershipResolver::Section(co)))
109 } else {
110 Err(crate::error::emit_error(
111 "--group-by section requires a GitLab-style CODEOWNERS file \
112 with `[Section]` headers. This CODEOWNERS has no sections; \
113 use --group-by owner instead.",
114 2,
115 output,
116 ))
117 }
118 }
119 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
120 },
121 GroupByMode::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
122 GroupByMode::Package => {
123 let workspaces = fallow_config::discover_workspaces(root);
124 if workspaces.is_empty() {
125 Err(crate::error::emit_error(
126 "--group-by package requires a monorepo with workspace packages \
127 (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
128 For single-package projects try --group-by directory instead.",
129 2,
130 output,
131 ))
132 } else {
133 Ok(Some(crate::report::OwnershipResolver::Package(
134 crate::report::grouping::PackageResolver::new(root, &workspaces),
135 )))
136 }
137 }
138 }
139}
140
141fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
146 if quiet || !matches!(output, OutputFormat::Human) {
147 return;
148 }
149 if !should_log_config_loaded(path) {
150 return;
151 }
152 eprintln!("loaded config: {}", path.display());
153}
154
155fn should_log_config_loaded(path: &Path) -> bool {
156 let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
157 CONFIG_LOADED_LOGGED
158 .lock()
159 .is_ok_and(|mut logged| logged.insert(key))
160}
161
162#[derive(Clone, Copy)]
163pub struct ConfigLoadOptions {
164 pub output: OutputFormat,
165 pub no_cache: bool,
166 pub threads: usize,
167 pub production_override: Option<bool>,
168 pub quiet: bool,
169}
170
171#[derive(Clone, Copy)]
175pub struct LoadConfigArgs {
176 pub output: OutputFormat,
177 pub no_cache: bool,
178 pub threads: usize,
179 pub production: bool,
180 pub quiet: bool,
181}
182
183#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
184pub fn load_config(
185 root: &Path,
186 config_path: &Option<PathBuf>,
187 args: LoadConfigArgs,
188) -> Result<ResolvedConfig, ExitCode> {
189 let LoadConfigArgs {
190 output,
191 no_cache,
192 threads,
193 production,
194 quiet,
195 } = args;
196 load_config_for_analysis(
197 root,
198 config_path,
199 ConfigLoadOptions {
200 output,
201 no_cache,
202 threads,
203 production_override: production.then_some(true),
204 quiet,
205 },
206 ProductionAnalysis::DeadCode,
207 )
208}
209
210#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
211pub fn load_config_for_analysis(
212 root: &Path,
213 config_path: &Option<PathBuf>,
214 options: ConfigLoadOptions,
215 analysis: ProductionAnalysis,
216) -> Result<ResolvedConfig, ExitCode> {
217 let user_config = load_user_config(root, config_path, &options)?;
218
219 let loaded_user_config = user_config.is_some();
220 let final_config = match user_config {
221 Some(mut config) => {
222 let production = options
223 .production_override
224 .unwrap_or_else(|| config.production.for_analysis(analysis));
225 config.production = production.into();
226 config
227 }
228 None => FallowConfig {
229 production: options.production_override.unwrap_or(false).into(),
230 ..FallowConfig::default()
231 },
232 };
233 crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
234
235 validate_config_extensions(root, &final_config, &options)?;
236
237 let cache_max_size_mb = resolve_cache_max_size_env();
238 let mut resolved = final_config.resolve(
239 root.to_path_buf(),
240 options.output,
241 options.threads,
242 options.no_cache,
243 options.quiet,
244 cache_max_size_mb,
245 );
246 if let Some(mb) = resolve_max_file_size_mb() {
247 resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
248 }
249 apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
250 crate::cache_notice::record_candidate(
251 root,
252 &resolved.cache_dir,
253 options.output,
254 options.quiet,
255 resolved.no_cache,
256 );
257
258 report_workspace_diagnostics(root, &resolved, &options)?;
259
260 Ok(resolved)
261}
262
263#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
266fn load_user_config(
267 root: &Path,
268 config_path: &Option<PathBuf>,
269 options: &ConfigLoadOptions,
270) -> Result<Option<FallowConfig>, ExitCode> {
271 if let Some(path) = config_path {
272 return match FallowConfig::load(path) {
273 Ok(c) => {
274 log_config_loaded(path, options.output, options.quiet);
275 Ok(Some(c))
276 }
277 Err(e) => {
278 let msg = format!("failed to load config '{}': {e}", path.display());
279 Err(crate::error::emit_error(&msg, 2, options.output))
280 }
281 };
282 }
283 match FallowConfig::find_and_load(root) {
284 Ok(Some((config, found_path))) => {
285 log_config_loaded(&found_path, options.output, options.quiet);
286 Ok(Some(config))
287 }
288 Ok(None) => Ok(None),
289 Err(e) => Err(crate::error::emit_error(&e, 2, options.output)),
290 }
291}
292
293fn emit_joined_config_errors<E: ToString>(
295 label: &str,
296 errors: &[E],
297 output: OutputFormat,
298) -> ExitCode {
299 let joined = errors
300 .iter()
301 .map(ToString::to_string)
302 .collect::<Vec<_>>()
303 .join("\n - ");
304 crate::error::emit_error(&format!("{label}:\n - {joined}"), 2, output)
305}
306
307fn validate_config_extensions(
311 root: &Path,
312 config: &FallowConfig,
313 options: &ConfigLoadOptions,
314) -> Result<(), ExitCode> {
315 if let Err(errors) =
316 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
317 {
318 return Err(emit_joined_config_errors(
319 "invalid external plugin definition",
320 &errors,
321 options.output,
322 ));
323 }
324 if let Err(errors) = config.validate_resolved_boundaries(root) {
325 return Err(emit_joined_config_errors(
326 "invalid boundary configuration",
327 &errors,
328 options.output,
329 ));
330 }
331 if let Err(errors) = fallow_config::load_rule_packs(root, &config.rule_packs) {
332 return Err(emit_joined_config_errors(
333 "invalid rule pack",
334 &errors,
335 options.output,
336 ));
337 }
338 Ok(())
339}
340
341fn report_workspace_diagnostics(
344 root: &Path,
345 resolved: &ResolvedConfig,
346 options: &ConfigLoadOptions,
347) -> Result<(), ExitCode> {
348 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
349 Ok((_, diagnostics)) => {
350 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
351 if !diagnostics.is_empty()
352 && matches!(options.output, OutputFormat::Human)
353 && !options.quiet
354 {
355 eprintln!(
356 "fallow: {} workspace discovery diagnostic{}. \
357 Run `fallow list --workspaces` for detail.",
358 diagnostics.len(),
359 if diagnostics.len() == 1 { "" } else { "s" }
360 );
361 }
362 Ok(())
363 }
364 Err(err) => Err(crate::error::emit_error(
365 &err.to_string(),
366 2,
367 options.output,
368 )),
369 }
370}
371
372fn config_shape_for(
373 config: &FallowConfig,
374 loaded_user_config: bool,
375) -> crate::telemetry::ConfigShape {
376 if !config.plugins.is_empty() || !config.framework.is_empty() {
377 return crate::telemetry::ConfigShape::PluginsEnabled;
378 }
379 if config.rules != RulesConfig::default()
380 || config
381 .overrides
382 .iter()
383 .any(|entry| partial_rules_config_has_values(&entry.rules))
384 {
385 return crate::telemetry::ConfigShape::CustomRules;
386 }
387 if loaded_user_config {
388 return crate::telemetry::ConfigShape::CustomConfig;
389 }
390 crate::telemetry::ConfigShape::Default
391}
392
393fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
394 serde_json::to_value(rules)
395 .ok()
396 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
397 .unwrap_or(false)
398}
399
400#[must_use]
405pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
406 fallow_config::workspace_diagnostics_for(root)
407}
408
409fn resolve_cache_max_size_env() -> Option<u32> {
415 std::env::var("FALLOW_CACHE_MAX_SIZE")
416 .ok()
417 .and_then(|raw| raw.trim().parse::<u32>().ok())
418 .filter(|mb| *mb > 0)
419}
420
421fn resolve_cache_dir_env() -> Option<PathBuf> {
424 std::env::var_os("FALLOW_CACHE_DIR")
425 .map(PathBuf::from)
426 .filter(|path| !path.as_os_str().is_empty())
427}
428
429fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
430 if path.is_absolute() {
431 path
432 } else {
433 root.join(path)
434 }
435}
436
437fn apply_cache_dir_env_override(
438 root: &Path,
439 resolved: &mut ResolvedConfig,
440 env_value: Option<PathBuf>,
441) {
442 if let Some(path) = env_value {
443 resolved.cache_dir = resolve_cache_dir_value(root, path);
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn config_loaded_notice_dedupes_by_config_path() {
453 let dir = tempfile::tempdir().unwrap();
454 let first = dir.path().join("first.fallow.json");
455 let second = dir.path().join("second.fallow.json");
456 std::fs::write(&first, "{}").unwrap();
457 std::fs::write(&second, "{}").unwrap();
458
459 assert!(should_log_config_loaded(&first));
460 assert!(!should_log_config_loaded(&first));
461 assert!(should_log_config_loaded(&second));
462 }
463
464 #[test]
465 fn cache_dir_env_value_resolves_relative_to_project_root() {
466 assert_eq!(
467 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
468 PathBuf::from("/repo/.cache/fallow")
469 );
470 assert_eq!(
471 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
472 PathBuf::from("/tmp/fallow-cache")
473 );
474 }
475
476 #[test]
477 fn cache_dir_env_value_wins_over_configured_cache_dir() {
478 let mut resolved = FallowConfig {
479 cache: fallow_config::CacheConfig {
480 dir: Some(PathBuf::from(".cache/from-config")),
481 ..Default::default()
482 },
483 ..Default::default()
484 }
485 .resolve(
486 PathBuf::from("/repo"),
487 OutputFormat::Human,
488 1,
489 false,
490 true,
491 None,
492 );
493
494 apply_cache_dir_env_override(
495 Path::new("/repo"),
496 &mut resolved,
497 Some(PathBuf::from(".cache/from-env")),
498 );
499
500 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
501 }
502}