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    Ok(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.resolve(root.to_path_buf(), output, threads, no_cache, quiet)
165        }
166        None => FallowConfig {
167            production: production_override.unwrap_or(false).into(),
168            ..FallowConfig::default()
169        }
170        .resolve(root.to_path_buf(), output, threads, no_cache, quiet),
171    })
172}