Skip to main content

fallow_cli/
runtime_support.rs

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
14/// Process-wide dedup of `security.categories` typo warnings so combined mode's
15/// repeated config loads emit each at most once.
16static SECURITY_CATEGORY_WARNED: LazyLock<Mutex<FxHashSet<String>>> =
17    LazyLock::new(|| Mutex::new(FxHashSet::default()));
18
19/// An unknown `security.categories` id plus the closest valid suggestion.
20pub struct UnknownSecurityCategory {
21    /// `include` or `exclude`.
22    pub field: &'static str,
23    /// The unrecognized category id.
24    pub id: String,
25    /// The closest valid category id, when one is near enough to suggest.
26    pub suggestion: Option<String>,
27}
28
29/// Find ids in `security.categories.{include,exclude}` that are not real
30/// catalogue categories, each with a closest-match suggestion. Pure: no output.
31///
32/// The security config crate cannot depend on `fallow-security` (the catalogue
33/// lives there), so this validation runs at the CLI layer where both are
34/// available, mirroring the config crate's own unknown-rule-key check.
35#[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
71/// Emit one deduped `tracing::warn!` per unknown `security.categories` id.
72///
73/// Advisory only: an unknown id is silently ignored by the detector, so a typo
74/// otherwise disables a category with no signal at all.
75pub 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
100/// The `--max-file-size` global flag value, set once from `main()` after clap
101/// parse. `Some(Some(mb))` means the flag was passed; `Some(None)` / unset
102/// means it was not. Held in a `OnceLock` rather than threaded through the ten
103/// `load_config_for_analysis` callers (the skill-endorsed set-once-read-by-many
104/// pattern; avoids `set_var`, which is unsafe under edition 2024).
105static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
106
107/// Record the `--max-file-size` flag value (megabytes; `Some(0)` = unlimited).
108/// Called once from `main()` before dispatch. Subsequent calls are ignored.
109pub 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
113/// Resolve the effective per-file size ceiling override (in megabytes): the
114/// `--max-file-size` flag wins, then `FALLOW_MAX_FILE_SIZE`, else `None` (the
115/// built-in default applies). `Some(0)` from either source means unlimited.
116fn 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/// Analysis types for --only/--skip selection.
126#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
127pub enum AnalysisKind {
128    #[value(alias = "check")]
129    DeadCode,
130    Dupes,
131    Health,
132}
133
134/// Grouping mode for `--group-by`.
135#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
136pub enum GroupBy {
137    /// Group by CODEOWNERS file ownership (first owner, last matching rule).
138    #[value(alias = "team", alias = "codeowner")]
139    Owner,
140    /// Group by first directory component of the file path.
141    Directory,
142    /// Group by workspace package (monorepo).
143    #[value(alias = "workspace", alias = "pkg")]
144    Package,
145    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
146    /// Stable across reviewer rotation; produces distinct groups when
147    /// multiple sections share a common default owner.
148    #[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
163/// Build an `OwnershipResolver` from CLI `--group-by` and config settings.
164///
165/// Returns `None` when no grouping is requested. Returns `Err(ExitCode)` when
166/// `--group-by owner` is requested but no CODEOWNERS file can be found.
167pub 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
176/// Build an `OwnershipResolver` from a typed output grouping mode.
177pub 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
227/// Emit a terse `"loaded config: <path>"` line on stderr so users can verify
228/// which config was picked up. Suppressed for non-human output formats (so
229/// JSON/SARIF/markdown consumers get clean machine-readable output) and when
230/// `--quiet` is set.
231fn 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/// The scalar config-loading knobs for [`load_config`], bundled so the entry
258/// point takes the root + config path plus one args struct instead of seven
259/// positional parameters.
260#[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/// Load the user config from an explicit `--config` path or via auto-discovery,
351/// logging the resolved path. Returns `None` when no config file is found.
352#[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
380/// Join a list of validation errors into one indented `emit_error` exit code.
381fn 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
394/// Validate external plugins, resolved boundaries, and rule packs. A rule pack
395/// that fails to load must fail the run: silently skipping policy is the exact
396/// failure mode rule packs document themselves as preventing.
397fn 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
446/// Discover and stash workspace diagnostics, surfacing a one-line stderr notice
447/// in human mode when any are present.
448fn 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/// Read the workspace-discovery diagnostics produced by the most recent
505/// `load_config_for_analysis` call for `root`. Thin re-export over
506/// [`fallow_config::workspace_diagnostics_for`] so call sites inside the
507/// CLI crate (`report::json::build_json*`) keep a stable module-local path.
508#[must_use]
509pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
510    fallow_config::workspace_diagnostics_for(root)
511}
512
513/// Read `FALLOW_CACHE_MAX_SIZE` (megabytes) into `Option<u32>`, returning
514/// `None` when the env var is unset or fails to parse as a positive integer.
515/// Resolved here rather than as a clap flag because the cache cap is a
516/// platform/CI ergonomic concern, not an analysis input; an env var keeps
517/// it out of the `--help` surface (see ADR-009).
518fn 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
525/// Read `FALLOW_CACHE_DIR` into an optional project-root-resolved cache path.
526/// Relative values use the same project-root base as `cache.dir`.
527fn 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}