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 SECURITY_CATEGORY_WARNED: LazyLock<Mutex<FxHashSet<String>>> =
17 LazyLock::new(|| Mutex::new(FxHashSet::default()));
18
19pub struct UnknownSecurityCategory {
21 pub field: &'static str,
23 pub id: String,
25 pub suggestion: Option<String>,
27}
28
29#[must_use]
36pub fn find_unknown_security_categories(
37 security: &fallow_config::SecurityConfig,
38) -> Vec<UnknownSecurityCategory> {
39 let Some(categories) = &security.categories else {
40 return Vec::new();
41 };
42 let valid: Vec<String> = fallow_security::security_categories()
43 .into_iter()
44 .map(|category| category.id)
45 .collect();
46 let valid_set: FxHashSet<&str> = valid.iter().map(String::as_str).collect();
47 let mut unknown = Vec::new();
48 for (field, ids) in [
49 ("include", categories.include.as_ref()),
50 ("exclude", categories.exclude.as_ref()),
51 ] {
52 let Some(ids) = ids else { continue };
53 for id in ids {
54 if valid_set.contains(id.as_str()) {
55 continue;
56 }
57 unknown.push(UnknownSecurityCategory {
58 field,
59 id: id.clone(),
60 suggestion: fallow_config::levenshtein::closest_match(
61 id,
62 valid.iter().map(String::as_str),
63 )
64 .map(str::to_owned),
65 });
66 }
67 }
68 unknown
69}
70
71pub fn warn_unknown_security_categories(security: &fallow_config::SecurityConfig) {
76 for unknown in find_unknown_security_categories(security) {
77 let dedup_key = format!("{}:{}", unknown.field, unknown.id);
78 if let Ok(mut seen) = SECURITY_CATEGORY_WARNED.lock()
79 && !seen.insert(dedup_key)
80 {
81 continue;
82 }
83 if let Some(suggestion) = &unknown.suggestion {
84 tracing::warn!(
85 "unknown security category '{}' in security.categories.{} (did you mean '{}'?); it is ignored. Valid ids: fallow schema (security_categories) or fallow security --help",
86 unknown.id,
87 unknown.field,
88 suggestion
89 );
90 } else {
91 tracing::warn!(
92 "unknown security category '{}' in security.categories.{}; it is ignored. Valid ids: fallow schema (security_categories) or fallow security --help",
93 unknown.id,
94 unknown.field
95 );
96 }
97 }
98}
99
100static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
106
107pub fn set_max_file_size_override(max_file_size_mb: Option<u32>) {
110 let _ = MAX_FILE_SIZE_OVERRIDE.set(max_file_size_mb);
111}
112
113fn resolve_max_file_size_mb() -> Option<u32> {
117 if let Some(Some(mb)) = MAX_FILE_SIZE_OVERRIDE.get() {
118 return Some(*mb);
119 }
120 std::env::var("FALLOW_MAX_FILE_SIZE")
121 .ok()
122 .and_then(|raw| raw.trim().parse::<u32>().ok())
123}
124
125#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
127pub enum AnalysisKind {
128 #[value(alias = "check")]
129 DeadCode,
130 Dupes,
131 Health,
132}
133
134#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
136pub enum GroupBy {
137 #[value(alias = "team", alias = "codeowner")]
139 Owner,
140 Directory,
142 #[value(alias = "workspace", alias = "pkg")]
144 Package,
145 #[value(alias = "gl-section")]
149 Section,
150}
151
152impl From<GroupBy> for GroupByMode {
153 fn from(value: GroupBy) -> Self {
154 match value {
155 GroupBy::Owner => Self::Owner,
156 GroupBy::Directory => Self::Directory,
157 GroupBy::Package => Self::Package,
158 GroupBy::Section => Self::Section,
159 }
160 }
161}
162
163pub fn build_ownership_resolver(
168 group_by: Option<GroupBy>,
169 root: &Path,
170 codeowners_path: Option<&str>,
171 output: OutputFormat,
172) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
173 build_ownership_resolver_for_mode(group_by.map(Into::into), root, codeowners_path, output)
174}
175
176pub fn build_ownership_resolver_for_mode(
178 group_by: Option<GroupByMode>,
179 root: &Path,
180 codeowners_path: Option<&str>,
181 output: OutputFormat,
182) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
183 let Some(mode) = group_by else {
184 return Ok(None);
185 };
186 match mode {
187 GroupByMode::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
188 Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
189 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
190 },
191 GroupByMode::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
192 Ok(co) => {
193 if co.has_sections() {
194 Ok(Some(crate::report::OwnershipResolver::Section(co)))
195 } else {
196 Err(crate::error::emit_error(
197 "--group-by section requires a GitLab-style CODEOWNERS file \
198 with `[Section]` headers. This CODEOWNERS has no sections; \
199 use --group-by owner instead.",
200 2,
201 output,
202 ))
203 }
204 }
205 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
206 },
207 GroupByMode::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
208 GroupByMode::Package => {
209 let workspaces = fallow_engine::discover::discover_workspace_packages(root);
210 if workspaces.is_empty() {
211 Err(crate::error::emit_error(
212 "--group-by package requires a monorepo with workspace packages \
213 (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
214 For single-package projects try --group-by directory instead.",
215 2,
216 output,
217 ))
218 } else {
219 Ok(Some(crate::report::OwnershipResolver::Package(
220 crate::report::grouping::PackageResolver::new(root, &workspaces),
221 )))
222 }
223 }
224 }
225}
226
227fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
232 if quiet || !matches!(output, OutputFormat::Human) {
233 return;
234 }
235 if !should_log_config_loaded(path) {
236 return;
237 }
238 eprintln!("loaded config: {}", path.display());
239}
240
241fn should_log_config_loaded(path: &Path) -> bool {
242 let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
243 CONFIG_LOADED_LOGGED
244 .lock()
245 .is_ok_and(|mut logged| logged.insert(key))
246}
247
248#[derive(Clone, Copy)]
249pub struct ConfigLoadOptions {
250 pub output: OutputFormat,
251 pub no_cache: bool,
252 pub threads: usize,
253 pub production_override: Option<bool>,
254 pub quiet: bool,
255 pub allow_remote_extends: bool,
256}
257
258#[derive(Clone, Copy)]
262pub struct LoadConfigArgs {
263 pub output: OutputFormat,
264 pub no_cache: bool,
265 pub threads: usize,
266 pub production: bool,
267 pub quiet: bool,
268 pub allow_remote_extends: bool,
269}
270
271#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
272pub fn load_config(
273 root: &Path,
274 config_path: &Option<PathBuf>,
275 args: LoadConfigArgs,
276) -> Result<ResolvedConfig, ExitCode> {
277 let LoadConfigArgs {
278 output,
279 no_cache,
280 threads,
281 production,
282 quiet,
283 allow_remote_extends,
284 } = args;
285 load_config_for_analysis(
286 root,
287 config_path,
288 ConfigLoadOptions {
289 output,
290 no_cache,
291 threads,
292 production_override: production.then_some(true),
293 quiet,
294 allow_remote_extends,
295 },
296 ProductionAnalysis::DeadCode,
297 )
298}
299
300#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
301pub fn load_config_for_analysis(
302 root: &Path,
303 config_path: &Option<PathBuf>,
304 options: ConfigLoadOptions,
305 analysis: ProductionAnalysis,
306) -> Result<ResolvedConfig, ExitCode> {
307 let user_config = load_user_config(root, config_path, &options)?;
308
309 let loaded_user_config = user_config.is_some();
310 let final_config = match user_config {
311 Some(mut config) => {
312 let production = options
313 .production_override
314 .unwrap_or_else(|| config.production.for_analysis(analysis));
315 config.production = production.into();
316 config
317 }
318 None => FallowConfig {
319 production: options.production_override.unwrap_or(false).into(),
320 ..FallowConfig::default()
321 },
322 };
323 crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
324
325 validate_config_extensions(root, &final_config, &options)?;
326
327 let cache_max_size_mb = resolve_cache_max_size_env();
328 let mut resolved = final_config.resolve(
329 root.to_path_buf(),
330 options.output,
331 options.threads,
332 options.no_cache,
333 options.quiet,
334 cache_max_size_mb,
335 );
336 if let Some(mb) = resolve_max_file_size_mb() {
337 resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
338 }
339 apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
340 crate::cache_notice::record_candidate(
341 root,
342 &resolved.cache_dir,
343 options.output,
344 options.quiet,
345 resolved.no_cache,
346 );
347
348 report_workspace_diagnostics(root, &resolved, &options)?;
349 warn_unknown_security_categories(&resolved.security);
350
351 Ok(resolved)
352}
353
354#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
357fn load_user_config(
358 root: &Path,
359 config_path: &Option<PathBuf>,
360 options: &ConfigLoadOptions,
361) -> Result<Option<FallowConfig>, ExitCode> {
362 let load_options = fallow_config::ConfigLoadOptions {
363 allow_remote_extends: options.allow_remote_extends,
364 };
365 if let Some(path) = config_path {
366 return match FallowConfig::load_with_options(path, load_options) {
367 Ok(c) => {
368 log_config_loaded(path, options.output, options.quiet);
369 Ok(Some(c))
370 }
371 Err(e) => {
372 let msg = format!("failed to load config '{}': {e}", path.display());
373 Err(crate::error::emit_error(&msg, 2, options.output))
374 }
375 };
376 }
377 match FallowConfig::find_and_load_with_options(root, load_options) {
378 Ok(Some((config, found_path))) => {
379 log_config_loaded(&found_path, options.output, options.quiet);
380 Ok(Some(config))
381 }
382 Ok(None) => Ok(None),
383 Err(e) => Err(crate::error::emit_error(&e, 2, options.output)),
384 }
385}
386
387fn emit_joined_config_errors<E: ToString>(
389 label: &str,
390 errors: &[E],
391 output: OutputFormat,
392) -> ExitCode {
393 let joined = errors
394 .iter()
395 .map(ToString::to_string)
396 .collect::<Vec<_>>()
397 .join("\n - ");
398 crate::error::emit_error(&format!("{label}:\n - {joined}"), 2, output)
399}
400
401fn validate_config_extensions(
405 root: &Path,
406 config: &FallowConfig,
407 options: &ConfigLoadOptions,
408) -> Result<(), ExitCode> {
409 if let Err(errors) =
410 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
411 {
412 return Err(emit_joined_config_errors(
413 "invalid external plugin definition",
414 &errors,
415 options.output,
416 ));
417 }
418 if let Err(errors) = config.validate_resolved_boundaries(root) {
419 return Err(emit_joined_config_errors(
420 "invalid boundary configuration",
421 &errors,
422 options.output,
423 ));
424 }
425 let packs = match fallow_config::load_rule_packs(root, &config.rule_packs) {
426 Ok(packs) => packs,
427 Err(errors) => {
428 return Err(emit_joined_config_errors(
429 "invalid rule pack",
430 &errors,
431 options.output,
432 ));
433 }
434 };
435 let boundaries =
436 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
437 let zone_errors = fallow_config::validate_rule_pack_zone_references(
438 root,
439 &config.rule_packs,
440 &packs,
441 &boundaries,
442 );
443 if !zone_errors.is_empty() {
444 return Err(emit_joined_config_errors(
445 "invalid rule pack",
446 &zone_errors,
447 options.output,
448 ));
449 }
450 Ok(())
451}
452
453fn report_workspace_diagnostics(
456 root: &Path,
457 resolved: &ResolvedConfig,
458 options: &ConfigLoadOptions,
459) -> Result<(), ExitCode> {
460 match fallow_engine::discover::discover_workspace_packages_with_diagnostics(
461 root,
462 &resolved.ignore_patterns,
463 ) {
464 Ok((_, diagnostics)) => {
465 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
466 if !diagnostics.is_empty()
467 && matches!(options.output, OutputFormat::Human)
468 && !options.quiet
469 {
470 eprintln!(
471 "fallow: {} workspace discovery diagnostic{}. \
472 Run `fallow list --workspaces` for detail.",
473 diagnostics.len(),
474 if diagnostics.len() == 1 { "" } else { "s" }
475 );
476 }
477 Ok(())
478 }
479 Err(err) => Err(crate::error::emit_error(err.message(), 2, options.output)),
480 }
481}
482
483fn config_shape_for(
484 config: &FallowConfig,
485 loaded_user_config: bool,
486) -> crate::telemetry::ConfigShape {
487 if !config.plugins.is_empty() || !config.framework.is_empty() {
488 return crate::telemetry::ConfigShape::PluginsEnabled;
489 }
490 if config.rules != RulesConfig::default()
491 || config
492 .overrides
493 .iter()
494 .any(|entry| partial_rules_config_has_values(&entry.rules))
495 {
496 return crate::telemetry::ConfigShape::CustomRules;
497 }
498 if loaded_user_config {
499 return crate::telemetry::ConfigShape::CustomConfig;
500 }
501 crate::telemetry::ConfigShape::Default
502}
503
504fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
505 serde_json::to_value(rules)
506 .ok()
507 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
508 .unwrap_or(false)
509}
510
511#[must_use]
516pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
517 fallow_config::workspace_diagnostics_for(root)
518}
519
520fn resolve_cache_max_size_env() -> Option<u32> {
526 std::env::var("FALLOW_CACHE_MAX_SIZE")
527 .ok()
528 .and_then(|raw| raw.trim().parse::<u32>().ok())
529 .filter(|mb| *mb > 0)
530}
531
532fn resolve_cache_dir_env() -> Option<PathBuf> {
535 std::env::var_os("FALLOW_CACHE_DIR")
536 .map(PathBuf::from)
537 .filter(|path| !path.as_os_str().is_empty())
538}
539
540fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
541 if path.is_absolute() {
542 path
543 } else {
544 root.join(path)
545 }
546}
547
548fn apply_cache_dir_env_override(
549 root: &Path,
550 resolved: &mut ResolvedConfig,
551 env_value: Option<PathBuf>,
552) {
553 if let Some(path) = env_value {
554 resolved.cache_dir = resolve_cache_dir_value(root, path);
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn find_unknown_security_categories_flags_typos_with_suggestion() {
564 let security = fallow_config::SecurityConfig {
565 categories: Some(fallow_config::SecurityCategories {
566 include: Some(vec!["sql-injection".to_owned(), "sql-injektion".to_owned()]),
567 exclude: Some(vec!["hardcoded-secret".to_owned()]),
568 }),
569 ..fallow_config::SecurityConfig::default()
570 };
571 let unknown = find_unknown_security_categories(&security);
572 assert_eq!(unknown.len(), 1, "only the typo is unknown");
573 assert_eq!(unknown[0].id, "sql-injektion");
574 assert_eq!(unknown[0].field, "include");
575 assert_eq!(unknown[0].suggestion.as_deref(), Some("sql-injection"));
576 }
577
578 #[test]
579 fn find_unknown_security_categories_empty_when_all_valid_or_unset() {
580 assert!(
581 find_unknown_security_categories(&fallow_config::SecurityConfig::default()).is_empty()
582 );
583 let security = fallow_config::SecurityConfig {
584 categories: Some(fallow_config::SecurityCategories {
585 include: Some(vec!["secret-to-network".to_owned()]),
586 exclude: None,
587 }),
588 ..fallow_config::SecurityConfig::default()
589 };
590 assert!(find_unknown_security_categories(&security).is_empty());
591 }
592
593 #[test]
594 fn config_loaded_notice_dedupes_by_config_path() {
595 let dir = tempfile::tempdir().unwrap();
596 let first = dir.path().join("first.fallow.json");
597 let second = dir.path().join("second.fallow.json");
598 std::fs::write(&first, "{}").unwrap();
599 std::fs::write(&second, "{}").unwrap();
600
601 assert!(should_log_config_loaded(&first));
602 assert!(!should_log_config_loaded(&first));
603 assert!(should_log_config_loaded(&second));
604 }
605
606 #[test]
607 fn cache_dir_env_value_resolves_relative_to_project_root() {
608 assert_eq!(
609 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
610 PathBuf::from("/repo/.cache/fallow")
611 );
612 assert_eq!(
613 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
614 PathBuf::from("/tmp/fallow-cache")
615 );
616 }
617
618 #[test]
619 fn cache_dir_env_value_wins_over_configured_cache_dir() {
620 let mut resolved = FallowConfig {
621 cache: fallow_config::CacheConfig {
622 dir: Some(PathBuf::from(".cache/from-config")),
623 ..Default::default()
624 },
625 ..Default::default()
626 }
627 .resolve(
628 PathBuf::from("/repo"),
629 OutputFormat::Human,
630 1,
631 false,
632 true,
633 None,
634 );
635
636 apply_cache_dir_env_override(
637 Path::new("/repo"),
638 &mut resolved,
639 Some(PathBuf::from(".cache/from-env")),
640 );
641
642 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
643 }
644}