Skip to main content

fallow_cli/
runtime_support.rs

1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{FallowConfig, OutputFormat, ProductionAnalysis, ResolvedConfig};
5
6/// Analysis types for --only/--skip selection.
7#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
8pub enum AnalysisKind {
9    #[value(alias = "check")]
10    DeadCode,
11    Dupes,
12    Health,
13}
14
15/// Grouping mode for `--group-by`.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
17pub enum GroupBy {
18    /// Group by CODEOWNERS file ownership (first owner, last matching rule).
19    #[value(alias = "team", alias = "codeowner")]
20    Owner,
21    /// Group by first directory component of the file path.
22    Directory,
23    /// Group by workspace package (monorepo).
24    #[value(alias = "workspace", alias = "pkg")]
25    Package,
26    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
27    /// Stable across reviewer rotation; produces distinct groups when
28    /// multiple sections share a common default owner.
29    #[value(alias = "gl-section")]
30    Section,
31}
32
33/// Build an `OwnershipResolver` from CLI `--group-by` and config settings.
34///
35/// Returns `None` when no grouping is requested. Returns `Err(ExitCode)` when
36/// `--group-by owner` is requested but no CODEOWNERS file can be found.
37pub fn build_ownership_resolver(
38    group_by: Option<GroupBy>,
39    root: &Path,
40    codeowners_path: Option<&str>,
41    output: OutputFormat,
42) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
43    let Some(mode) = group_by else {
44        return Ok(None);
45    };
46    match mode {
47        GroupBy::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
48            Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
49            Err(e) => Err(crate::error::emit_error(&e, 2, output)),
50        },
51        GroupBy::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
52            Ok(co) => {
53                if co.has_sections() {
54                    Ok(Some(crate::report::OwnershipResolver::Section(co)))
55                } else {
56                    Err(crate::error::emit_error(
57                        "--group-by section requires a GitLab-style CODEOWNERS file \
58                         with `[Section]` headers. This CODEOWNERS has no sections; \
59                         use --group-by owner instead.",
60                        2,
61                        output,
62                    ))
63                }
64            }
65            Err(e) => Err(crate::error::emit_error(&e, 2, output)),
66        },
67        GroupBy::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
68        GroupBy::Package => {
69            let workspaces = fallow_config::discover_workspaces(root);
70            if workspaces.is_empty() {
71                Err(crate::error::emit_error(
72                    "--group-by package requires a monorepo with workspace packages \
73                     (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
74                     For single-package projects try --group-by directory instead.",
75                    2,
76                    output,
77                ))
78            } else {
79                Ok(Some(crate::report::OwnershipResolver::Package(
80                    crate::report::grouping::PackageResolver::new(root, &workspaces),
81                )))
82            }
83        }
84    }
85}
86
87/// Emit a terse `"loaded config: <path>"` line on stderr so users can verify
88/// which config was picked up. Suppressed for non-human output formats (so
89/// JSON/SARIF/markdown consumers get clean machine-readable output) and when
90/// `--quiet` is set.
91fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
92    if quiet || !matches!(output, OutputFormat::Human) {
93        return;
94    }
95    eprintln!("loaded config: {}", path.display());
96}
97
98#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
99pub fn load_config(
100    root: &Path,
101    config_path: &Option<PathBuf>,
102    output: OutputFormat,
103    no_cache: bool,
104    threads: usize,
105    production: bool,
106    quiet: bool,
107) -> Result<ResolvedConfig, ExitCode> {
108    load_config_for_analysis(
109        root,
110        config_path,
111        output,
112        no_cache,
113        threads,
114        production.then_some(true),
115        quiet,
116        ProductionAnalysis::DeadCode,
117    )
118}
119
120#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
121#[expect(
122    clippy::too_many_arguments,
123    reason = "central config loader mirrors CLI dispatch options"
124)]
125pub fn load_config_for_analysis(
126    root: &Path,
127    config_path: &Option<PathBuf>,
128    output: OutputFormat,
129    no_cache: bool,
130    threads: usize,
131    production_override: Option<bool>,
132    quiet: bool,
133    analysis: ProductionAnalysis,
134) -> Result<ResolvedConfig, ExitCode> {
135    let user_config = if let Some(path) = config_path {
136        match FallowConfig::load(path) {
137            Ok(c) => {
138                log_config_loaded(path, output, quiet);
139                Some(c)
140            }
141            Err(e) => {
142                let msg = format!("failed to load config '{}': {e}", path.display());
143                return Err(crate::error::emit_error(&msg, 2, output));
144            }
145        }
146    } else {
147        match FallowConfig::find_and_load(root) {
148            Ok(Some((config, found_path))) => {
149                log_config_loaded(&found_path, output, quiet);
150                Some(config)
151            }
152            Ok(None) => None,
153            Err(e) => {
154                return Err(crate::error::emit_error(&e, 2, output));
155            }
156        }
157    };
158
159    let final_config = match user_config {
160        Some(mut config) => {
161            let production =
162                production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
163            config.production = production.into();
164            config
165        }
166        None => FallowConfig {
167            production: production_override.unwrap_or(false).into(),
168            ..FallowConfig::default()
169        },
170    };
171
172    // Issue #463: validate user-supplied glob patterns on EXTERNAL plugin files
173    // loaded from `.fallow/plugins/` / `fallow-plugin-*` / config-listed paths.
174    // Inline `framework[]` blocks are already validated by `FallowConfig::load`.
175    // The external-plugin step runs here because plugins are root-dependent and
176    // `load` does not know the project root.
177    if let Err(errors) =
178        fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
179    {
180        let joined = errors
181            .iter()
182            .map(ToString::to_string)
183            .collect::<Vec<_>>()
184            .join("\n  - ");
185        let msg = format!("invalid external plugin definition:\n  - {joined}");
186        return Err(crate::error::emit_error(&msg, 2, output));
187    }
188
189    // Issue #468: validate boundary zone references and root-prefix conflicts
190    // AFTER preset and auto-discover expansion. Mirrors the upstream
191    // `discover_and_validate_external_plugins` pattern: both checks need the
192    // project root, both surface every offending entry in one rendered run.
193    if let Err(errors) = final_config.validate_resolved_boundaries(root) {
194        let joined = errors
195            .iter()
196            .map(ToString::to_string)
197            .collect::<Vec<_>>()
198            .join("\n  - ");
199        let msg = format!("invalid boundary configuration:\n  - {joined}");
200        return Err(crate::error::emit_error(&msg, 2, output));
201    }
202
203    let cache_max_size_mb = resolve_cache_max_size_env();
204    let resolved = final_config.resolve(
205        root.to_path_buf(),
206        output,
207        threads,
208        no_cache,
209        quiet,
210        cache_max_size_mb,
211    );
212
213    // Issue #473: discover workspaces here so any silent-fail in
214    // crates/config/src/workspace/ surfaces with a typed diagnostic (and a
215    // tracing::warn! per (root, kind, path)). A malformed ROOT package.json
216    // is unrecoverable; promote to exit 2 to match the boundary-validation
217    // exit-code policy above. The diagnostics that come back from this call
218    // stay in a process-wide registry keyed by canonical root so downstream
219    // renderers (check.rs, audit.rs, combined.rs, list.rs) can fold them
220    // into their JSON envelope and stderr summary without re-walking the
221    // workspace tree.
222    match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
223        Ok((_, diagnostics)) => {
224            // Stash diagnostics so downstream JSON-envelope builders
225            // (`report::json::build_json*`, audit, combined) and the analyze
226            // pipeline's later `find_undeclared_workspaces_with_ignores`
227            // pass can fold their results into the same registry without
228            // re-walking the workspace tree. The registry lives in
229            // `fallow-config` so both crates can populate it without a
230            // cyclic dep.
231            fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
232            if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
233                eprintln!(
234                    "fallow: {} workspace discovery diagnostic{}. \
235                     Run `fallow list --workspaces` for detail.",
236                    diagnostics.len(),
237                    if diagnostics.len() == 1 { "" } else { "s" }
238                );
239            }
240        }
241        Err(err) => {
242            return Err(crate::error::emit_error(&err.to_string(), 2, output));
243        }
244    }
245
246    Ok(resolved)
247}
248
249/// Read the workspace-discovery diagnostics produced by the most recent
250/// `load_config_for_analysis` call for `root`. Thin re-export over
251/// [`fallow_config::workspace_diagnostics_for`] so call sites inside the
252/// CLI crate (`report::json::build_json*`) keep a stable module-local path.
253#[must_use]
254pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
255    fallow_config::workspace_diagnostics_for(root)
256}
257
258/// Read `FALLOW_CACHE_MAX_SIZE` (megabytes) into `Option<u32>`, returning
259/// `None` when the env var is unset or fails to parse as a positive integer.
260/// Resolved here rather than as a clap flag because the cache cap is a
261/// platform/CI ergonomic concern, not an analysis input; an env var keeps
262/// it out of the `--help` surface (see ADR-009).
263fn resolve_cache_max_size_env() -> Option<u32> {
264    std::env::var("FALLOW_CACHE_MAX_SIZE")
265        .ok()
266        .and_then(|raw| raw.trim().parse::<u32>().ok())
267        .filter(|mb| *mb > 0)
268}