Skip to main content

fallow_cli/
runtime_support.rs

1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3use std::sync::{LazyLock, Mutex};
4
5use fallow_config::{FallowConfig, OutputFormat, ProductionAnalysis, ResolvedConfig};
6use rustc_hash::FxHashSet;
7
8static CONFIG_LOADED_LOGGED: LazyLock<Mutex<FxHashSet<PathBuf>>> =
9    LazyLock::new(|| Mutex::new(FxHashSet::default()));
10
11/// Analysis types for --only/--skip selection.
12#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
13pub enum AnalysisKind {
14    #[value(alias = "check")]
15    DeadCode,
16    Dupes,
17    Health,
18}
19
20/// Grouping mode for `--group-by`.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
22pub enum GroupBy {
23    /// Group by CODEOWNERS file ownership (first owner, last matching rule).
24    #[value(alias = "team", alias = "codeowner")]
25    Owner,
26    /// Group by first directory component of the file path.
27    Directory,
28    /// Group by workspace package (monorepo).
29    #[value(alias = "workspace", alias = "pkg")]
30    Package,
31    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
32    /// Stable across reviewer rotation; produces distinct groups when
33    /// multiple sections share a common default owner.
34    #[value(alias = "gl-section")]
35    Section,
36}
37
38/// Build an `OwnershipResolver` from CLI `--group-by` and config settings.
39///
40/// Returns `None` when no grouping is requested. Returns `Err(ExitCode)` when
41/// `--group-by owner` is requested but no CODEOWNERS file can be found.
42pub fn build_ownership_resolver(
43    group_by: Option<GroupBy>,
44    root: &Path,
45    codeowners_path: Option<&str>,
46    output: OutputFormat,
47) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
48    let Some(mode) = group_by else {
49        return Ok(None);
50    };
51    match mode {
52        GroupBy::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
53            Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
54            Err(e) => Err(crate::error::emit_error(&e, 2, output)),
55        },
56        GroupBy::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
57            Ok(co) => {
58                if co.has_sections() {
59                    Ok(Some(crate::report::OwnershipResolver::Section(co)))
60                } else {
61                    Err(crate::error::emit_error(
62                        "--group-by section requires a GitLab-style CODEOWNERS file \
63                         with `[Section]` headers. This CODEOWNERS has no sections; \
64                         use --group-by owner instead.",
65                        2,
66                        output,
67                    ))
68                }
69            }
70            Err(e) => Err(crate::error::emit_error(&e, 2, output)),
71        },
72        GroupBy::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
73        GroupBy::Package => {
74            let workspaces = fallow_config::discover_workspaces(root);
75            if workspaces.is_empty() {
76                Err(crate::error::emit_error(
77                    "--group-by package requires a monorepo with workspace packages \
78                     (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
79                     For single-package projects try --group-by directory instead.",
80                    2,
81                    output,
82                ))
83            } else {
84                Ok(Some(crate::report::OwnershipResolver::Package(
85                    crate::report::grouping::PackageResolver::new(root, &workspaces),
86                )))
87            }
88        }
89    }
90}
91
92/// Emit a terse `"loaded config: <path>"` line on stderr so users can verify
93/// which config was picked up. Suppressed for non-human output formats (so
94/// JSON/SARIF/markdown consumers get clean machine-readable output) and when
95/// `--quiet` is set.
96fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
97    if quiet || !matches!(output, OutputFormat::Human) {
98        return;
99    }
100    if !should_log_config_loaded(path) {
101        return;
102    }
103    eprintln!("loaded config: {}", path.display());
104}
105
106fn should_log_config_loaded(path: &Path) -> bool {
107    let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
108    CONFIG_LOADED_LOGGED
109        .lock()
110        .is_ok_and(|mut logged| logged.insert(key))
111}
112
113#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
114pub fn load_config(
115    root: &Path,
116    config_path: &Option<PathBuf>,
117    output: OutputFormat,
118    no_cache: bool,
119    threads: usize,
120    production: bool,
121    quiet: bool,
122) -> Result<ResolvedConfig, ExitCode> {
123    load_config_for_analysis(
124        root,
125        config_path,
126        output,
127        no_cache,
128        threads,
129        production.then_some(true),
130        quiet,
131        ProductionAnalysis::DeadCode,
132    )
133}
134
135#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
136#[expect(
137    clippy::too_many_arguments,
138    reason = "central config loader mirrors CLI dispatch options"
139)]
140pub fn load_config_for_analysis(
141    root: &Path,
142    config_path: &Option<PathBuf>,
143    output: OutputFormat,
144    no_cache: bool,
145    threads: usize,
146    production_override: Option<bool>,
147    quiet: bool,
148    analysis: ProductionAnalysis,
149) -> Result<ResolvedConfig, ExitCode> {
150    let user_config = if let Some(path) = config_path {
151        match FallowConfig::load(path) {
152            Ok(c) => {
153                log_config_loaded(path, output, quiet);
154                Some(c)
155            }
156            Err(e) => {
157                let msg = format!("failed to load config '{}': {e}", path.display());
158                return Err(crate::error::emit_error(&msg, 2, output));
159            }
160        }
161    } else {
162        match FallowConfig::find_and_load(root) {
163            Ok(Some((config, found_path))) => {
164                log_config_loaded(&found_path, output, quiet);
165                Some(config)
166            }
167            Ok(None) => None,
168            Err(e) => {
169                return Err(crate::error::emit_error(&e, 2, output));
170            }
171        }
172    };
173
174    let final_config = match user_config {
175        Some(mut config) => {
176            let production =
177                production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
178            config.production = production.into();
179            config
180        }
181        None => FallowConfig {
182            production: production_override.unwrap_or(false).into(),
183            ..FallowConfig::default()
184        },
185    };
186
187    if let Err(errors) =
188        fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
189    {
190        let joined = errors
191            .iter()
192            .map(ToString::to_string)
193            .collect::<Vec<_>>()
194            .join("\n  - ");
195        let msg = format!("invalid external plugin definition:\n  - {joined}");
196        return Err(crate::error::emit_error(&msg, 2, output));
197    }
198
199    if let Err(errors) = final_config.validate_resolved_boundaries(root) {
200        let joined = errors
201            .iter()
202            .map(ToString::to_string)
203            .collect::<Vec<_>>()
204            .join("\n  - ");
205        let msg = format!("invalid boundary configuration:\n  - {joined}");
206        return Err(crate::error::emit_error(&msg, 2, output));
207    }
208
209    let cache_max_size_mb = resolve_cache_max_size_env();
210    let mut resolved = final_config.resolve(
211        root.to_path_buf(),
212        output,
213        threads,
214        no_cache,
215        quiet,
216        cache_max_size_mb,
217    );
218    apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
219    crate::cache_notice::record_candidate(
220        root,
221        &resolved.cache_dir,
222        output,
223        quiet,
224        resolved.no_cache,
225    );
226
227    match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
228        Ok((_, diagnostics)) => {
229            fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
230            if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
231                eprintln!(
232                    "fallow: {} workspace discovery diagnostic{}. \
233                     Run `fallow list --workspaces` for detail.",
234                    diagnostics.len(),
235                    if diagnostics.len() == 1 { "" } else { "s" }
236                );
237            }
238        }
239        Err(err) => {
240            return Err(crate::error::emit_error(&err.to_string(), 2, output));
241        }
242    }
243
244    Ok(resolved)
245}
246
247/// Read the workspace-discovery diagnostics produced by the most recent
248/// `load_config_for_analysis` call for `root`. Thin re-export over
249/// [`fallow_config::workspace_diagnostics_for`] so call sites inside the
250/// CLI crate (`report::json::build_json*`) keep a stable module-local path.
251#[must_use]
252pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
253    fallow_config::workspace_diagnostics_for(root)
254}
255
256/// Read `FALLOW_CACHE_MAX_SIZE` (megabytes) into `Option<u32>`, returning
257/// `None` when the env var is unset or fails to parse as a positive integer.
258/// Resolved here rather than as a clap flag because the cache cap is a
259/// platform/CI ergonomic concern, not an analysis input; an env var keeps
260/// it out of the `--help` surface (see ADR-009).
261fn resolve_cache_max_size_env() -> Option<u32> {
262    std::env::var("FALLOW_CACHE_MAX_SIZE")
263        .ok()
264        .and_then(|raw| raw.trim().parse::<u32>().ok())
265        .filter(|mb| *mb > 0)
266}
267
268/// Read `FALLOW_CACHE_DIR` into an optional project-root-resolved cache path.
269/// Relative values use the same project-root base as `cache.dir`.
270fn resolve_cache_dir_env() -> Option<PathBuf> {
271    std::env::var_os("FALLOW_CACHE_DIR")
272        .map(PathBuf::from)
273        .filter(|path| !path.as_os_str().is_empty())
274}
275
276fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
277    if path.is_absolute() {
278        path
279    } else {
280        root.join(path)
281    }
282}
283
284fn apply_cache_dir_env_override(
285    root: &Path,
286    resolved: &mut ResolvedConfig,
287    env_value: Option<PathBuf>,
288) {
289    if let Some(path) = env_value {
290        resolved.cache_dir = resolve_cache_dir_value(root, path);
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn config_loaded_notice_dedupes_by_config_path() {
300        let dir = tempfile::tempdir().unwrap();
301        let first = dir.path().join("first.fallow.json");
302        let second = dir.path().join("second.fallow.json");
303        std::fs::write(&first, "{}").unwrap();
304        std::fs::write(&second, "{}").unwrap();
305
306        assert!(should_log_config_loaded(&first));
307        assert!(!should_log_config_loaded(&first));
308        assert!(should_log_config_loaded(&second));
309    }
310
311    #[test]
312    fn cache_dir_env_value_resolves_relative_to_project_root() {
313        assert_eq!(
314            resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
315            PathBuf::from("/repo/.cache/fallow")
316        );
317        assert_eq!(
318            resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
319            PathBuf::from("/tmp/fallow-cache")
320        );
321    }
322
323    #[test]
324    fn cache_dir_env_value_wins_over_configured_cache_dir() {
325        let mut resolved = FallowConfig {
326            cache: fallow_config::CacheConfig {
327                dir: Some(PathBuf::from(".cache/from-config")),
328                ..Default::default()
329            },
330            ..Default::default()
331        }
332        .resolve(
333            PathBuf::from("/repo"),
334            OutputFormat::Human,
335            1,
336            false,
337            true,
338            None,
339        );
340
341        apply_cache_dir_env_override(
342            Path::new("/repo"),
343            &mut resolved,
344            Some(PathBuf::from(".cache/from-env")),
345        );
346
347        assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
348    }
349}