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}
256
257#[derive(Clone, Copy)]
261pub struct LoadConfigArgs {
262 pub output: OutputFormat,
263 pub no_cache: bool,
264 pub threads: usize,
265 pub production: bool,
266 pub quiet: bool,
267}
268
269#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
270pub fn load_config(
271 root: &Path,
272 config_path: &Option<PathBuf>,
273 args: LoadConfigArgs,
274) -> Result<ResolvedConfig, ExitCode> {
275 let LoadConfigArgs {
276 output,
277 no_cache,
278 threads,
279 production,
280 quiet,
281 } = args;
282 load_config_for_analysis(
283 root,
284 config_path,
285 ConfigLoadOptions {
286 output,
287 no_cache,
288 threads,
289 production_override: production.then_some(true),
290 quiet,
291 },
292 ProductionAnalysis::DeadCode,
293 )
294}
295
296#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
297pub fn load_config_for_analysis(
298 root: &Path,
299 config_path: &Option<PathBuf>,
300 options: ConfigLoadOptions,
301 analysis: ProductionAnalysis,
302) -> Result<ResolvedConfig, ExitCode> {
303 let user_config = load_user_config(root, config_path, &options)?;
304
305 let loaded_user_config = user_config.is_some();
306 let final_config = match user_config {
307 Some(mut config) => {
308 let production = options
309 .production_override
310 .unwrap_or_else(|| config.production.for_analysis(analysis));
311 config.production = production.into();
312 config
313 }
314 None => FallowConfig {
315 production: options.production_override.unwrap_or(false).into(),
316 ..FallowConfig::default()
317 },
318 };
319 crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
320
321 validate_config_extensions(root, &final_config, &options)?;
322
323 let cache_max_size_mb = resolve_cache_max_size_env();
324 let mut resolved = final_config.resolve(
325 root.to_path_buf(),
326 options.output,
327 options.threads,
328 options.no_cache,
329 options.quiet,
330 cache_max_size_mb,
331 );
332 if let Some(mb) = resolve_max_file_size_mb() {
333 resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
334 }
335 apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
336 crate::cache_notice::record_candidate(
337 root,
338 &resolved.cache_dir,
339 options.output,
340 options.quiet,
341 resolved.no_cache,
342 );
343
344 report_workspace_diagnostics(root, &resolved, &options)?;
345 warn_unknown_security_categories(&resolved.security);
346
347 Ok(resolved)
348}
349
350#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
353fn load_user_config(
354 root: &Path,
355 config_path: &Option<PathBuf>,
356 options: &ConfigLoadOptions,
357) -> Result<Option<FallowConfig>, ExitCode> {
358 if let Some(path) = config_path {
359 return match FallowConfig::load(path) {
360 Ok(c) => {
361 log_config_loaded(path, options.output, options.quiet);
362 Ok(Some(c))
363 }
364 Err(e) => {
365 let msg = format!("failed to load config '{}': {e}", path.display());
366 Err(crate::error::emit_error(&msg, 2, options.output))
367 }
368 };
369 }
370 match FallowConfig::find_and_load(root) {
371 Ok(Some((config, found_path))) => {
372 log_config_loaded(&found_path, options.output, options.quiet);
373 Ok(Some(config))
374 }
375 Ok(None) => Ok(None),
376 Err(e) => Err(crate::error::emit_error(&e, 2, options.output)),
377 }
378}
379
380fn emit_joined_config_errors<E: ToString>(
382 label: &str,
383 errors: &[E],
384 output: OutputFormat,
385) -> ExitCode {
386 let joined = errors
387 .iter()
388 .map(ToString::to_string)
389 .collect::<Vec<_>>()
390 .join("\n - ");
391 crate::error::emit_error(&format!("{label}:\n - {joined}"), 2, output)
392}
393
394fn validate_config_extensions(
398 root: &Path,
399 config: &FallowConfig,
400 options: &ConfigLoadOptions,
401) -> Result<(), ExitCode> {
402 if let Err(errors) =
403 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
404 {
405 return Err(emit_joined_config_errors(
406 "invalid external plugin definition",
407 &errors,
408 options.output,
409 ));
410 }
411 if let Err(errors) = config.validate_resolved_boundaries(root) {
412 return Err(emit_joined_config_errors(
413 "invalid boundary configuration",
414 &errors,
415 options.output,
416 ));
417 }
418 let packs = match fallow_config::load_rule_packs(root, &config.rule_packs) {
419 Ok(packs) => packs,
420 Err(errors) => {
421 return Err(emit_joined_config_errors(
422 "invalid rule pack",
423 &errors,
424 options.output,
425 ));
426 }
427 };
428 let boundaries =
429 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
430 let zone_errors = fallow_config::validate_rule_pack_zone_references(
431 root,
432 &config.rule_packs,
433 &packs,
434 &boundaries,
435 );
436 if !zone_errors.is_empty() {
437 return Err(emit_joined_config_errors(
438 "invalid rule pack",
439 &zone_errors,
440 options.output,
441 ));
442 }
443 Ok(())
444}
445
446fn report_workspace_diagnostics(
449 root: &Path,
450 resolved: &ResolvedConfig,
451 options: &ConfigLoadOptions,
452) -> Result<(), ExitCode> {
453 match fallow_engine::discover::discover_workspace_packages_with_diagnostics(
454 root,
455 &resolved.ignore_patterns,
456 ) {
457 Ok((_, diagnostics)) => {
458 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
459 if !diagnostics.is_empty()
460 && matches!(options.output, OutputFormat::Human)
461 && !options.quiet
462 {
463 eprintln!(
464 "fallow: {} workspace discovery diagnostic{}. \
465 Run `fallow list --workspaces` for detail.",
466 diagnostics.len(),
467 if diagnostics.len() == 1 { "" } else { "s" }
468 );
469 }
470 Ok(())
471 }
472 Err(err) => Err(crate::error::emit_error(err.message(), 2, options.output)),
473 }
474}
475
476fn config_shape_for(
477 config: &FallowConfig,
478 loaded_user_config: bool,
479) -> crate::telemetry::ConfigShape {
480 if !config.plugins.is_empty() || !config.framework.is_empty() {
481 return crate::telemetry::ConfigShape::PluginsEnabled;
482 }
483 if config.rules != RulesConfig::default()
484 || config
485 .overrides
486 .iter()
487 .any(|entry| partial_rules_config_has_values(&entry.rules))
488 {
489 return crate::telemetry::ConfigShape::CustomRules;
490 }
491 if loaded_user_config {
492 return crate::telemetry::ConfigShape::CustomConfig;
493 }
494 crate::telemetry::ConfigShape::Default
495}
496
497fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
498 serde_json::to_value(rules)
499 .ok()
500 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
501 .unwrap_or(false)
502}
503
504#[must_use]
509pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
510 fallow_config::workspace_diagnostics_for(root)
511}
512
513fn resolve_cache_max_size_env() -> Option<u32> {
519 std::env::var("FALLOW_CACHE_MAX_SIZE")
520 .ok()
521 .and_then(|raw| raw.trim().parse::<u32>().ok())
522 .filter(|mb| *mb > 0)
523}
524
525fn resolve_cache_dir_env() -> Option<PathBuf> {
528 std::env::var_os("FALLOW_CACHE_DIR")
529 .map(PathBuf::from)
530 .filter(|path| !path.as_os_str().is_empty())
531}
532
533fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
534 if path.is_absolute() {
535 path
536 } else {
537 root.join(path)
538 }
539}
540
541fn apply_cache_dir_env_override(
542 root: &Path,
543 resolved: &mut ResolvedConfig,
544 env_value: Option<PathBuf>,
545) {
546 if let Some(path) = env_value {
547 resolved.cache_dir = resolve_cache_dir_value(root, path);
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn find_unknown_security_categories_flags_typos_with_suggestion() {
557 let security = fallow_config::SecurityConfig {
558 categories: Some(fallow_config::SecurityCategories {
559 include: Some(vec!["sql-injection".to_owned(), "sql-injektion".to_owned()]),
560 exclude: Some(vec!["hardcoded-secret".to_owned()]),
561 }),
562 ..fallow_config::SecurityConfig::default()
563 };
564 let unknown = find_unknown_security_categories(&security);
565 assert_eq!(unknown.len(), 1, "only the typo is unknown");
566 assert_eq!(unknown[0].id, "sql-injektion");
567 assert_eq!(unknown[0].field, "include");
568 assert_eq!(unknown[0].suggestion.as_deref(), Some("sql-injection"));
569 }
570
571 #[test]
572 fn find_unknown_security_categories_empty_when_all_valid_or_unset() {
573 assert!(
574 find_unknown_security_categories(&fallow_config::SecurityConfig::default()).is_empty()
575 );
576 let security = fallow_config::SecurityConfig {
577 categories: Some(fallow_config::SecurityCategories {
578 include: Some(vec!["secret-to-network".to_owned()]),
579 exclude: None,
580 }),
581 ..fallow_config::SecurityConfig::default()
582 };
583 assert!(find_unknown_security_categories(&security).is_empty());
584 }
585
586 #[test]
587 fn config_loaded_notice_dedupes_by_config_path() {
588 let dir = tempfile::tempdir().unwrap();
589 let first = dir.path().join("first.fallow.json");
590 let second = dir.path().join("second.fallow.json");
591 std::fs::write(&first, "{}").unwrap();
592 std::fs::write(&second, "{}").unwrap();
593
594 assert!(should_log_config_loaded(&first));
595 assert!(!should_log_config_loaded(&first));
596 assert!(should_log_config_loaded(&second));
597 }
598
599 #[test]
600 fn cache_dir_env_value_resolves_relative_to_project_root() {
601 assert_eq!(
602 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
603 PathBuf::from("/repo/.cache/fallow")
604 );
605 assert_eq!(
606 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
607 PathBuf::from("/tmp/fallow-cache")
608 );
609 }
610
611 #[test]
612 fn cache_dir_env_value_wins_over_configured_cache_dir() {
613 let mut resolved = FallowConfig {
614 cache: fallow_config::CacheConfig {
615 dir: Some(PathBuf::from(".cache/from-config")),
616 ..Default::default()
617 },
618 ..Default::default()
619 }
620 .resolve(
621 PathBuf::from("/repo"),
622 OutputFormat::Human,
623 1,
624 false,
625 true,
626 None,
627 );
628
629 apply_cache_dir_env_override(
630 Path::new("/repo"),
631 &mut resolved,
632 Some(PathBuf::from(".cache/from-env")),
633 );
634
635 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
636 }
637}