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    pub allow_remote_extends: bool,
256}
257
258/// The scalar config-loading knobs for [`load_config`], bundled so the entry
259/// point takes the root + config path plus one args struct instead of seven
260/// positional parameters.
261#[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/// Load the user config from an explicit `--config` path or via auto-discovery,
355/// logging the resolved path. Returns `None` when no config file is found.
356#[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
387/// Join a list of validation errors into one indented `emit_error` exit code.
388fn 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
401/// Validate external plugins, resolved boundaries, and rule packs. A rule pack
402/// that fails to load must fail the run: silently skipping policy is the exact
403/// failure mode rule packs document themselves as preventing.
404fn 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
453/// Discover and stash workspace diagnostics, surfacing a one-line stderr notice
454/// in human mode when any are present.
455fn 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/// Read the workspace-discovery diagnostics produced by the most recent
512/// `load_config_for_analysis` call for `root`. Thin re-export over
513/// [`fallow_config::workspace_diagnostics_for`] so call sites inside the
514/// CLI crate (`report::json::build_json*`) keep a stable module-local path.
515#[must_use]
516pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
517    fallow_config::workspace_diagnostics_for(root)
518}
519
520/// Read `FALLOW_CACHE_MAX_SIZE` (megabytes) into `Option<u32>`, returning
521/// `None` when the env var is unset or fails to parse as a positive integer.
522/// Resolved here rather than as a clap flag because the cache cap is a
523/// platform/CI ergonomic concern, not an analysis input; an env var keeps
524/// it out of the `--help` surface (see ADR-009).
525fn 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
532/// Read `FALLOW_CACHE_DIR` into an optional project-root-resolved cache path.
533/// Relative values use the same project-root base as `cache.dir`.
534fn 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}