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 rustc_hash::FxHashSet;
9
10static CONFIG_LOADED_LOGGED: LazyLock<Mutex<FxHashSet<PathBuf>>> =
11    LazyLock::new(|| Mutex::new(FxHashSet::default()));
12
13/// The `--max-file-size` global flag value, set once from `main()` after clap
14/// parse. `Some(Some(mb))` means the flag was passed; `Some(None)` / unset
15/// means it was not. Held in a `OnceLock` rather than threaded through the ten
16/// `load_config_for_analysis` callers (the skill-endorsed set-once-read-by-many
17/// pattern; avoids `set_var`, which is unsafe under edition 2024).
18static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
19
20/// Record the `--max-file-size` flag value (megabytes; `Some(0)` = unlimited).
21/// Called once from `main()` before dispatch. Subsequent calls are ignored.
22pub fn set_max_file_size_override(max_file_size_mb: Option<u32>) {
23    let _ = MAX_FILE_SIZE_OVERRIDE.set(max_file_size_mb);
24}
25
26/// Resolve the effective per-file size ceiling override (in megabytes): the
27/// `--max-file-size` flag wins, then `FALLOW_MAX_FILE_SIZE`, else `None` (the
28/// built-in default applies). `Some(0)` from either source means unlimited.
29fn resolve_max_file_size_mb() -> Option<u32> {
30    if let Some(Some(mb)) = MAX_FILE_SIZE_OVERRIDE.get() {
31        return Some(*mb);
32    }
33    std::env::var("FALLOW_MAX_FILE_SIZE")
34        .ok()
35        .and_then(|raw| raw.trim().parse::<u32>().ok())
36}
37
38/// Analysis types for --only/--skip selection.
39#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
40pub enum AnalysisKind {
41    #[value(alias = "check")]
42    DeadCode,
43    Dupes,
44    Health,
45}
46
47/// Grouping mode for `--group-by`.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
49pub enum GroupBy {
50    /// Group by CODEOWNERS file ownership (first owner, last matching rule).
51    #[value(alias = "team", alias = "codeowner")]
52    Owner,
53    /// Group by first directory component of the file path.
54    Directory,
55    /// Group by workspace package (monorepo).
56    #[value(alias = "workspace", alias = "pkg")]
57    Package,
58    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
59    /// Stable across reviewer rotation; produces distinct groups when
60    /// multiple sections share a common default owner.
61    #[value(alias = "gl-section")]
62    Section,
63}
64
65/// Build an `OwnershipResolver` from CLI `--group-by` and config settings.
66///
67/// Returns `None` when no grouping is requested. Returns `Err(ExitCode)` when
68/// `--group-by owner` is requested but no CODEOWNERS file can be found.
69pub fn build_ownership_resolver(
70    group_by: Option<GroupBy>,
71    root: &Path,
72    codeowners_path: Option<&str>,
73    output: OutputFormat,
74) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
75    let Some(mode) = group_by else {
76        return Ok(None);
77    };
78    match mode {
79        GroupBy::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
80            Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
81            Err(e) => Err(crate::error::emit_error(&e, 2, output)),
82        },
83        GroupBy::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
84            Ok(co) => {
85                if co.has_sections() {
86                    Ok(Some(crate::report::OwnershipResolver::Section(co)))
87                } else {
88                    Err(crate::error::emit_error(
89                        "--group-by section requires a GitLab-style CODEOWNERS file \
90                         with `[Section]` headers. This CODEOWNERS has no sections; \
91                         use --group-by owner instead.",
92                        2,
93                        output,
94                    ))
95                }
96            }
97            Err(e) => Err(crate::error::emit_error(&e, 2, output)),
98        },
99        GroupBy::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
100        GroupBy::Package => {
101            let workspaces = fallow_config::discover_workspaces(root);
102            if workspaces.is_empty() {
103                Err(crate::error::emit_error(
104                    "--group-by package requires a monorepo with workspace packages \
105                     (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
106                     For single-package projects try --group-by directory instead.",
107                    2,
108                    output,
109                ))
110            } else {
111                Ok(Some(crate::report::OwnershipResolver::Package(
112                    crate::report::grouping::PackageResolver::new(root, &workspaces),
113                )))
114            }
115        }
116    }
117}
118
119/// Emit a terse `"loaded config: <path>"` line on stderr so users can verify
120/// which config was picked up. Suppressed for non-human output formats (so
121/// JSON/SARIF/markdown consumers get clean machine-readable output) and when
122/// `--quiet` is set.
123fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
124    if quiet || !matches!(output, OutputFormat::Human) {
125        return;
126    }
127    if !should_log_config_loaded(path) {
128        return;
129    }
130    eprintln!("loaded config: {}", path.display());
131}
132
133fn should_log_config_loaded(path: &Path) -> bool {
134    let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
135    CONFIG_LOADED_LOGGED
136        .lock()
137        .is_ok_and(|mut logged| logged.insert(key))
138}
139
140#[derive(Clone, Copy)]
141pub struct ConfigLoadOptions {
142    pub output: OutputFormat,
143    pub no_cache: bool,
144    pub threads: usize,
145    pub production_override: Option<bool>,
146    pub quiet: bool,
147}
148
149/// The scalar config-loading knobs for [`load_config`], bundled so the entry
150/// point takes the root + config path plus one args struct instead of seven
151/// positional parameters.
152#[derive(Clone, Copy)]
153pub struct LoadConfigArgs {
154    pub output: OutputFormat,
155    pub no_cache: bool,
156    pub threads: usize,
157    pub production: bool,
158    pub quiet: bool,
159}
160
161#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
162pub fn load_config(
163    root: &Path,
164    config_path: &Option<PathBuf>,
165    args: LoadConfigArgs,
166) -> Result<ResolvedConfig, ExitCode> {
167    let LoadConfigArgs {
168        output,
169        no_cache,
170        threads,
171        production,
172        quiet,
173    } = args;
174    load_config_for_analysis(
175        root,
176        config_path,
177        ConfigLoadOptions {
178            output,
179            no_cache,
180            threads,
181            production_override: production.then_some(true),
182            quiet,
183        },
184        ProductionAnalysis::DeadCode,
185    )
186}
187
188#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
189pub fn load_config_for_analysis(
190    root: &Path,
191    config_path: &Option<PathBuf>,
192    options: ConfigLoadOptions,
193    analysis: ProductionAnalysis,
194) -> Result<ResolvedConfig, ExitCode> {
195    let user_config = load_user_config(root, config_path, &options)?;
196
197    let loaded_user_config = user_config.is_some();
198    let final_config = match user_config {
199        Some(mut config) => {
200            let production = options
201                .production_override
202                .unwrap_or_else(|| config.production.for_analysis(analysis));
203            config.production = production.into();
204            config
205        }
206        None => FallowConfig {
207            production: options.production_override.unwrap_or(false).into(),
208            ..FallowConfig::default()
209        },
210    };
211    crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
212
213    validate_config_extensions(root, &final_config, &options)?;
214
215    let cache_max_size_mb = resolve_cache_max_size_env();
216    let mut resolved = final_config.resolve(
217        root.to_path_buf(),
218        options.output,
219        options.threads,
220        options.no_cache,
221        options.quiet,
222        cache_max_size_mb,
223    );
224    if let Some(mb) = resolve_max_file_size_mb() {
225        resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
226    }
227    apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
228    crate::cache_notice::record_candidate(
229        root,
230        &resolved.cache_dir,
231        options.output,
232        options.quiet,
233        resolved.no_cache,
234    );
235
236    report_workspace_diagnostics(root, &resolved, &options)?;
237
238    Ok(resolved)
239}
240
241/// Load the user config from an explicit `--config` path or via auto-discovery,
242/// logging the resolved path. Returns `None` when no config file is found.
243#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
244fn load_user_config(
245    root: &Path,
246    config_path: &Option<PathBuf>,
247    options: &ConfigLoadOptions,
248) -> Result<Option<FallowConfig>, ExitCode> {
249    if let Some(path) = config_path {
250        return match FallowConfig::load(path) {
251            Ok(c) => {
252                log_config_loaded(path, options.output, options.quiet);
253                Ok(Some(c))
254            }
255            Err(e) => {
256                let msg = format!("failed to load config '{}': {e}", path.display());
257                Err(crate::error::emit_error(&msg, 2, options.output))
258            }
259        };
260    }
261    match FallowConfig::find_and_load(root) {
262        Ok(Some((config, found_path))) => {
263            log_config_loaded(&found_path, options.output, options.quiet);
264            Ok(Some(config))
265        }
266        Ok(None) => Ok(None),
267        Err(e) => Err(crate::error::emit_error(&e, 2, options.output)),
268    }
269}
270
271/// Join a list of validation errors into one indented `emit_error` exit code.
272fn emit_joined_config_errors<E: ToString>(
273    label: &str,
274    errors: &[E],
275    output: OutputFormat,
276) -> ExitCode {
277    let joined = errors
278        .iter()
279        .map(ToString::to_string)
280        .collect::<Vec<_>>()
281        .join("\n  - ");
282    crate::error::emit_error(&format!("{label}:\n  - {joined}"), 2, output)
283}
284
285/// Validate external plugins, resolved boundaries, and rule packs. A rule pack
286/// that fails to load must fail the run: silently skipping policy is the exact
287/// failure mode rule packs document themselves as preventing.
288fn validate_config_extensions(
289    root: &Path,
290    config: &FallowConfig,
291    options: &ConfigLoadOptions,
292) -> Result<(), ExitCode> {
293    if let Err(errors) =
294        fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
295    {
296        return Err(emit_joined_config_errors(
297            "invalid external plugin definition",
298            &errors,
299            options.output,
300        ));
301    }
302    if let Err(errors) = config.validate_resolved_boundaries(root) {
303        return Err(emit_joined_config_errors(
304            "invalid boundary configuration",
305            &errors,
306            options.output,
307        ));
308    }
309    if let Err(errors) = fallow_config::load_rule_packs(root, &config.rule_packs) {
310        return Err(emit_joined_config_errors(
311            "invalid rule pack",
312            &errors,
313            options.output,
314        ));
315    }
316    Ok(())
317}
318
319/// Discover and stash workspace diagnostics, surfacing a one-line stderr notice
320/// in human mode when any are present.
321fn report_workspace_diagnostics(
322    root: &Path,
323    resolved: &ResolvedConfig,
324    options: &ConfigLoadOptions,
325) -> Result<(), ExitCode> {
326    match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
327        Ok((_, diagnostics)) => {
328            fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
329            if !diagnostics.is_empty()
330                && matches!(options.output, OutputFormat::Human)
331                && !options.quiet
332            {
333                eprintln!(
334                    "fallow: {} workspace discovery diagnostic{}. \
335                     Run `fallow list --workspaces` for detail.",
336                    diagnostics.len(),
337                    if diagnostics.len() == 1 { "" } else { "s" }
338                );
339            }
340            Ok(())
341        }
342        Err(err) => Err(crate::error::emit_error(
343            &err.to_string(),
344            2,
345            options.output,
346        )),
347    }
348}
349
350fn config_shape_for(
351    config: &FallowConfig,
352    loaded_user_config: bool,
353) -> crate::telemetry::ConfigShape {
354    if !config.plugins.is_empty() || !config.framework.is_empty() {
355        return crate::telemetry::ConfigShape::PluginsEnabled;
356    }
357    if config.rules != RulesConfig::default()
358        || config
359            .overrides
360            .iter()
361            .any(|entry| partial_rules_config_has_values(&entry.rules))
362    {
363        return crate::telemetry::ConfigShape::CustomRules;
364    }
365    if loaded_user_config {
366        return crate::telemetry::ConfigShape::CustomConfig;
367    }
368    crate::telemetry::ConfigShape::Default
369}
370
371fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
372    serde_json::to_value(rules)
373        .ok()
374        .and_then(|value| value.as_object().map(|object| !object.is_empty()))
375        .unwrap_or(false)
376}
377
378/// Read the workspace-discovery diagnostics produced by the most recent
379/// `load_config_for_analysis` call for `root`. Thin re-export over
380/// [`fallow_config::workspace_diagnostics_for`] so call sites inside the
381/// CLI crate (`report::json::build_json*`) keep a stable module-local path.
382#[must_use]
383pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
384    fallow_config::workspace_diagnostics_for(root)
385}
386
387/// Read `FALLOW_CACHE_MAX_SIZE` (megabytes) into `Option<u32>`, returning
388/// `None` when the env var is unset or fails to parse as a positive integer.
389/// Resolved here rather than as a clap flag because the cache cap is a
390/// platform/CI ergonomic concern, not an analysis input; an env var keeps
391/// it out of the `--help` surface (see ADR-009).
392fn resolve_cache_max_size_env() -> Option<u32> {
393    std::env::var("FALLOW_CACHE_MAX_SIZE")
394        .ok()
395        .and_then(|raw| raw.trim().parse::<u32>().ok())
396        .filter(|mb| *mb > 0)
397}
398
399/// Read `FALLOW_CACHE_DIR` into an optional project-root-resolved cache path.
400/// Relative values use the same project-root base as `cache.dir`.
401fn resolve_cache_dir_env() -> Option<PathBuf> {
402    std::env::var_os("FALLOW_CACHE_DIR")
403        .map(PathBuf::from)
404        .filter(|path| !path.as_os_str().is_empty())
405}
406
407fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
408    if path.is_absolute() {
409        path
410    } else {
411        root.join(path)
412    }
413}
414
415fn apply_cache_dir_env_override(
416    root: &Path,
417    resolved: &mut ResolvedConfig,
418    env_value: Option<PathBuf>,
419) {
420    if let Some(path) = env_value {
421        resolved.cache_dir = resolve_cache_dir_value(root, path);
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn config_loaded_notice_dedupes_by_config_path() {
431        let dir = tempfile::tempdir().unwrap();
432        let first = dir.path().join("first.fallow.json");
433        let second = dir.path().join("second.fallow.json");
434        std::fs::write(&first, "{}").unwrap();
435        std::fs::write(&second, "{}").unwrap();
436
437        assert!(should_log_config_loaded(&first));
438        assert!(!should_log_config_loaded(&first));
439        assert!(should_log_config_loaded(&second));
440    }
441
442    #[test]
443    fn cache_dir_env_value_resolves_relative_to_project_root() {
444        assert_eq!(
445            resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
446            PathBuf::from("/repo/.cache/fallow")
447        );
448        assert_eq!(
449            resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
450            PathBuf::from("/tmp/fallow-cache")
451        );
452    }
453
454    #[test]
455    fn cache_dir_env_value_wins_over_configured_cache_dir() {
456        let mut resolved = FallowConfig {
457            cache: fallow_config::CacheConfig {
458                dir: Some(PathBuf::from(".cache/from-config")),
459                ..Default::default()
460            },
461            ..Default::default()
462        }
463        .resolve(
464            PathBuf::from("/repo"),
465            OutputFormat::Human,
466            1,
467            false,
468            true,
469            None,
470        );
471
472        apply_cache_dir_env_override(
473            Path::new("/repo"),
474            &mut resolved,
475            Some(PathBuf::from(".cache/from-env")),
476        );
477
478        assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
479    }
480}