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/// The `--max-file-size` global flag value, set once from `main()` after clap
15/// parse. `Some(Some(mb))` means the flag was passed; `Some(None)` / unset
16/// means it was not. Held in a `OnceLock` rather than threaded through the ten
17/// `load_config_for_analysis` callers (the skill-endorsed set-once-read-by-many
18/// pattern; avoids `set_var`, which is unsafe under edition 2024).
19static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
20
21/// Record the `--max-file-size` flag value (megabytes; `Some(0)` = unlimited).
22/// Called once from `main()` before dispatch. Subsequent calls are ignored.
23pub 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
27/// Resolve the effective per-file size ceiling override (in megabytes): the
28/// `--max-file-size` flag wins, then `FALLOW_MAX_FILE_SIZE`, else `None` (the
29/// built-in default applies). `Some(0)` from either source means unlimited.
30fn 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/// Analysis types for --only/--skip selection.
40#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
41pub enum AnalysisKind {
42    #[value(alias = "check")]
43    DeadCode,
44    Dupes,
45    Health,
46}
47
48/// Grouping mode for `--group-by`.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
50pub enum GroupBy {
51    /// Group by CODEOWNERS file ownership (first owner, last matching rule).
52    #[value(alias = "team", alias = "codeowner")]
53    Owner,
54    /// Group by first directory component of the file path.
55    Directory,
56    /// Group by workspace package (monorepo).
57    #[value(alias = "workspace", alias = "pkg")]
58    Package,
59    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
60    /// Stable across reviewer rotation; produces distinct groups when
61    /// multiple sections share a common default owner.
62    #[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
77/// Build an `OwnershipResolver` from CLI `--group-by` and config settings.
78///
79/// Returns `None` when no grouping is requested. Returns `Err(ExitCode)` when
80/// `--group-by owner` is requested but no CODEOWNERS file can be found.
81pub 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
90/// Build an `OwnershipResolver` from a typed output grouping mode.
91pub 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_config::discover_workspaces(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
141/// Emit a terse `"loaded config: <path>"` line on stderr so users can verify
142/// which config was picked up. Suppressed for non-human output formats (so
143/// JSON/SARIF/markdown consumers get clean machine-readable output) and when
144/// `--quiet` is set.
145fn 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/// The scalar config-loading knobs for [`load_config`], bundled so the entry
172/// point takes the root + config path plus one args struct instead of seven
173/// positional parameters.
174#[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/// Load the user config from an explicit `--config` path or via auto-discovery,
264/// logging the resolved path. Returns `None` when no config file is found.
265#[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
293/// Join a list of validation errors into one indented `emit_error` exit code.
294fn 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
307/// Validate external plugins, resolved boundaries, and rule packs. A rule pack
308/// that fails to load must fail the run: silently skipping policy is the exact
309/// failure mode rule packs document themselves as preventing.
310fn 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    if let Err(errors) = fallow_config::load_rule_packs(root, &config.rule_packs) {
332        return Err(emit_joined_config_errors(
333            "invalid rule pack",
334            &errors,
335            options.output,
336        ));
337    }
338    Ok(())
339}
340
341/// Discover and stash workspace diagnostics, surfacing a one-line stderr notice
342/// in human mode when any are present.
343fn report_workspace_diagnostics(
344    root: &Path,
345    resolved: &ResolvedConfig,
346    options: &ConfigLoadOptions,
347) -> Result<(), ExitCode> {
348    match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
349        Ok((_, diagnostics)) => {
350            fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
351            if !diagnostics.is_empty()
352                && matches!(options.output, OutputFormat::Human)
353                && !options.quiet
354            {
355                eprintln!(
356                    "fallow: {} workspace discovery diagnostic{}. \
357                     Run `fallow list --workspaces` for detail.",
358                    diagnostics.len(),
359                    if diagnostics.len() == 1 { "" } else { "s" }
360                );
361            }
362            Ok(())
363        }
364        Err(err) => Err(crate::error::emit_error(
365            &err.to_string(),
366            2,
367            options.output,
368        )),
369    }
370}
371
372fn config_shape_for(
373    config: &FallowConfig,
374    loaded_user_config: bool,
375) -> crate::telemetry::ConfigShape {
376    if !config.plugins.is_empty() || !config.framework.is_empty() {
377        return crate::telemetry::ConfigShape::PluginsEnabled;
378    }
379    if config.rules != RulesConfig::default()
380        || config
381            .overrides
382            .iter()
383            .any(|entry| partial_rules_config_has_values(&entry.rules))
384    {
385        return crate::telemetry::ConfigShape::CustomRules;
386    }
387    if loaded_user_config {
388        return crate::telemetry::ConfigShape::CustomConfig;
389    }
390    crate::telemetry::ConfigShape::Default
391}
392
393fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
394    serde_json::to_value(rules)
395        .ok()
396        .and_then(|value| value.as_object().map(|object| !object.is_empty()))
397        .unwrap_or(false)
398}
399
400/// Read the workspace-discovery diagnostics produced by the most recent
401/// `load_config_for_analysis` call for `root`. Thin re-export over
402/// [`fallow_config::workspace_diagnostics_for`] so call sites inside the
403/// CLI crate (`report::json::build_json*`) keep a stable module-local path.
404#[must_use]
405pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
406    fallow_config::workspace_diagnostics_for(root)
407}
408
409/// Read `FALLOW_CACHE_MAX_SIZE` (megabytes) into `Option<u32>`, returning
410/// `None` when the env var is unset or fails to parse as a positive integer.
411/// Resolved here rather than as a clap flag because the cache cap is a
412/// platform/CI ergonomic concern, not an analysis input; an env var keeps
413/// it out of the `--help` surface (see ADR-009).
414fn resolve_cache_max_size_env() -> Option<u32> {
415    std::env::var("FALLOW_CACHE_MAX_SIZE")
416        .ok()
417        .and_then(|raw| raw.trim().parse::<u32>().ok())
418        .filter(|mb| *mb > 0)
419}
420
421/// Read `FALLOW_CACHE_DIR` into an optional project-root-resolved cache path.
422/// Relative values use the same project-root base as `cache.dir`.
423fn resolve_cache_dir_env() -> Option<PathBuf> {
424    std::env::var_os("FALLOW_CACHE_DIR")
425        .map(PathBuf::from)
426        .filter(|path| !path.as_os_str().is_empty())
427}
428
429fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
430    if path.is_absolute() {
431        path
432    } else {
433        root.join(path)
434    }
435}
436
437fn apply_cache_dir_env_override(
438    root: &Path,
439    resolved: &mut ResolvedConfig,
440    env_value: Option<PathBuf>,
441) {
442    if let Some(path) = env_value {
443        resolved.cache_dir = resolve_cache_dir_value(root, path);
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn config_loaded_notice_dedupes_by_config_path() {
453        let dir = tempfile::tempdir().unwrap();
454        let first = dir.path().join("first.fallow.json");
455        let second = dir.path().join("second.fallow.json");
456        std::fs::write(&first, "{}").unwrap();
457        std::fs::write(&second, "{}").unwrap();
458
459        assert!(should_log_config_loaded(&first));
460        assert!(!should_log_config_loaded(&first));
461        assert!(should_log_config_loaded(&second));
462    }
463
464    #[test]
465    fn cache_dir_env_value_resolves_relative_to_project_root() {
466        assert_eq!(
467            resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
468            PathBuf::from("/repo/.cache/fallow")
469        );
470        assert_eq!(
471            resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
472            PathBuf::from("/tmp/fallow-cache")
473        );
474    }
475
476    #[test]
477    fn cache_dir_env_value_wins_over_configured_cache_dir() {
478        let mut resolved = FallowConfig {
479            cache: fallow_config::CacheConfig {
480                dir: Some(PathBuf::from(".cache/from-config")),
481                ..Default::default()
482            },
483            ..Default::default()
484        }
485        .resolve(
486            PathBuf::from("/repo"),
487            OutputFormat::Human,
488            1,
489            false,
490            true,
491            None,
492        );
493
494        apply_cache_dir_env_override(
495            Path::new("/repo"),
496            &mut resolved,
497            Some(PathBuf::from(".cache/from-env")),
498        );
499
500        assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
501    }
502}