Skip to main content

fallow_cli/
runtime_support.rs

1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{FallowConfig, OutputFormat, 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    let user_config = if let Some(path) = config_path {
108        match FallowConfig::load(path) {
109            Ok(c) => {
110                log_config_loaded(path, output, quiet);
111                Some(c)
112            }
113            Err(e) => {
114                let msg = format!("failed to load config '{}': {e}", path.display());
115                return Err(crate::error::emit_error(&msg, 2, output));
116            }
117        }
118    } else {
119        match FallowConfig::find_and_load(root) {
120            Ok(Some((config, found_path))) => {
121                log_config_loaded(&found_path, output, quiet);
122                Some(config)
123            }
124            Ok(None) => None,
125            Err(e) => {
126                return Err(crate::error::emit_error(&e, 2, output));
127            }
128        }
129    };
130
131    Ok(match user_config {
132        Some(mut config) => {
133            if production {
134                config.production = true;
135            }
136            config.resolve(root.to_path_buf(), output, threads, no_cache, quiet)
137        }
138        None => FallowConfig {
139            production,
140            ..FallowConfig::default()
141        }
142        .resolve(root.to_path_buf(), output, threads, no_cache, quiet),
143    })
144}