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                    2,
75                    output,
76                ))
77            } else {
78                Ok(Some(crate::report::OwnershipResolver::Package(
79                    crate::report::grouping::PackageResolver::new(root, &workspaces),
80                )))
81            }
82        }
83    }
84}
85
86/// Emit a terse `"loaded config: <path>"` line on stderr so users can verify
87/// which config was picked up. Suppressed for non-human output formats (so
88/// JSON/SARIF/markdown consumers get clean machine-readable output) and when
89/// `--quiet` is set.
90fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
91    if quiet || !matches!(output, OutputFormat::Human) {
92        return;
93    }
94    eprintln!("loaded config: {}", path.display());
95}
96
97#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
98pub fn load_config(
99    root: &Path,
100    config_path: &Option<PathBuf>,
101    output: OutputFormat,
102    no_cache: bool,
103    threads: usize,
104    production: bool,
105    quiet: bool,
106) -> Result<ResolvedConfig, ExitCode> {
107    load_config_for_analysis(
108        root,
109        config_path,
110        output,
111        no_cache,
112        threads,
113        production.then_some(true),
114        quiet,
115        ProductionAnalysis::DeadCode,
116    )
117}
118
119#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
120#[expect(
121    clippy::too_many_arguments,
122    reason = "central config loader mirrors CLI dispatch options"
123)]
124pub fn load_config_for_analysis(
125    root: &Path,
126    config_path: &Option<PathBuf>,
127    output: OutputFormat,
128    no_cache: bool,
129    threads: usize,
130    production_override: Option<bool>,
131    quiet: bool,
132    analysis: ProductionAnalysis,
133) -> Result<ResolvedConfig, ExitCode> {
134    let user_config = if let Some(path) = config_path {
135        match FallowConfig::load(path) {
136            Ok(c) => {
137                log_config_loaded(path, output, quiet);
138                Some(c)
139            }
140            Err(e) => {
141                let msg = format!("failed to load config '{}': {e}", path.display());
142                return Err(crate::error::emit_error(&msg, 2, output));
143            }
144        }
145    } else {
146        match FallowConfig::find_and_load(root) {
147            Ok(Some((config, found_path))) => {
148                log_config_loaded(&found_path, output, quiet);
149                Some(config)
150            }
151            Ok(None) => None,
152            Err(e) => {
153                return Err(crate::error::emit_error(&e, 2, output));
154            }
155        }
156    };
157
158    Ok(match user_config {
159        Some(mut config) => {
160            let production =
161                production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
162            config.production = production.into();
163            config.resolve(root.to_path_buf(), output, threads, no_cache, quiet)
164        }
165        None => FallowConfig {
166            production: production_override.unwrap_or(false).into(),
167            ..FallowConfig::default()
168        }
169        .resolve(root.to_path_buf(), output, threads, no_cache, quiet),
170    })
171}