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_engine::discover::discover_workspace_packages(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 let packs = match fallow_config::load_rule_packs(root, &config.rule_packs) {
332 Ok(packs) => packs,
333 Err(errors) => {
334 return Err(emit_joined_config_errors(
335 "invalid rule pack",
336 &errors,
337 options.output,
338 ));
339 }
340 };
341 let boundaries =
342 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
343 let zone_errors = fallow_config::validate_rule_pack_zone_references(
344 root,
345 &config.rule_packs,
346 &packs,
347 &boundaries,
348 );
349 if !zone_errors.is_empty() {
350 return Err(emit_joined_config_errors(
351 "invalid rule pack",
352 &zone_errors,
353 options.output,
354 ));
355 }
356 Ok(())
357}
358
359fn report_workspace_diagnostics(
362 root: &Path,
363 resolved: &ResolvedConfig,
364 options: &ConfigLoadOptions,
365) -> Result<(), ExitCode> {
366 match fallow_engine::discover::discover_workspace_packages_with_diagnostics(
367 root,
368 &resolved.ignore_patterns,
369 ) {
370 Ok((_, diagnostics)) => {
371 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
372 if !diagnostics.is_empty()
373 && matches!(options.output, OutputFormat::Human)
374 && !options.quiet
375 {
376 eprintln!(
377 "fallow: {} workspace discovery diagnostic{}. \
378 Run `fallow list --workspaces` for detail.",
379 diagnostics.len(),
380 if diagnostics.len() == 1 { "" } else { "s" }
381 );
382 }
383 Ok(())
384 }
385 Err(err) => Err(crate::error::emit_error(err.message(), 2, options.output)),
386 }
387}
388
389fn config_shape_for(
390 config: &FallowConfig,
391 loaded_user_config: bool,
392) -> crate::telemetry::ConfigShape {
393 if !config.plugins.is_empty() || !config.framework.is_empty() {
394 return crate::telemetry::ConfigShape::PluginsEnabled;
395 }
396 if config.rules != RulesConfig::default()
397 || config
398 .overrides
399 .iter()
400 .any(|entry| partial_rules_config_has_values(&entry.rules))
401 {
402 return crate::telemetry::ConfigShape::CustomRules;
403 }
404 if loaded_user_config {
405 return crate::telemetry::ConfigShape::CustomConfig;
406 }
407 crate::telemetry::ConfigShape::Default
408}
409
410fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
411 serde_json::to_value(rules)
412 .ok()
413 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
414 .unwrap_or(false)
415}
416
417#[must_use]
422pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
423 fallow_config::workspace_diagnostics_for(root)
424}
425
426fn resolve_cache_max_size_env() -> Option<u32> {
432 std::env::var("FALLOW_CACHE_MAX_SIZE")
433 .ok()
434 .and_then(|raw| raw.trim().parse::<u32>().ok())
435 .filter(|mb| *mb > 0)
436}
437
438fn resolve_cache_dir_env() -> Option<PathBuf> {
441 std::env::var_os("FALLOW_CACHE_DIR")
442 .map(PathBuf::from)
443 .filter(|path| !path.as_os_str().is_empty())
444}
445
446fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
447 if path.is_absolute() {
448 path
449 } else {
450 root.join(path)
451 }
452}
453
454fn apply_cache_dir_env_override(
455 root: &Path,
456 resolved: &mut ResolvedConfig,
457 env_value: Option<PathBuf>,
458) {
459 if let Some(path) = env_value {
460 resolved.cache_dir = resolve_cache_dir_value(root, path);
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn config_loaded_notice_dedupes_by_config_path() {
470 let dir = tempfile::tempdir().unwrap();
471 let first = dir.path().join("first.fallow.json");
472 let second = dir.path().join("second.fallow.json");
473 std::fs::write(&first, "{}").unwrap();
474 std::fs::write(&second, "{}").unwrap();
475
476 assert!(should_log_config_loaded(&first));
477 assert!(!should_log_config_loaded(&first));
478 assert!(should_log_config_loaded(&second));
479 }
480
481 #[test]
482 fn cache_dir_env_value_resolves_relative_to_project_root() {
483 assert_eq!(
484 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
485 PathBuf::from("/repo/.cache/fallow")
486 );
487 assert_eq!(
488 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
489 PathBuf::from("/tmp/fallow-cache")
490 );
491 }
492
493 #[test]
494 fn cache_dir_env_value_wins_over_configured_cache_dir() {
495 let mut resolved = FallowConfig {
496 cache: fallow_config::CacheConfig {
497 dir: Some(PathBuf::from(".cache/from-config")),
498 ..Default::default()
499 },
500 ..Default::default()
501 }
502 .resolve(
503 PathBuf::from("/repo"),
504 OutputFormat::Human,
505 1,
506 false,
507 true,
508 None,
509 );
510
511 apply_cache_dir_env_override(
512 Path::new("/repo"),
513 &mut resolved,
514 Some(PathBuf::from(".cache/from-env")),
515 );
516
517 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
518 }
519}