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#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
13pub enum AnalysisKind {
14 #[value(alias = "check")]
15 DeadCode,
16 Dupes,
17 Health,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
22pub enum GroupBy {
23 #[value(alias = "team", alias = "codeowner")]
25 Owner,
26 Directory,
28 #[value(alias = "workspace", alias = "pkg")]
30 Package,
31 #[value(alias = "gl-section")]
35 Section,
36}
37
38pub 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
92fn 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.lock().unwrap().insert(key)
109}
110
111#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
112pub fn load_config(
113 root: &Path,
114 config_path: &Option<PathBuf>,
115 output: OutputFormat,
116 no_cache: bool,
117 threads: usize,
118 production: bool,
119 quiet: bool,
120) -> Result<ResolvedConfig, ExitCode> {
121 load_config_for_analysis(
122 root,
123 config_path,
124 output,
125 no_cache,
126 threads,
127 production.then_some(true),
128 quiet,
129 ProductionAnalysis::DeadCode,
130 )
131}
132
133#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
134#[expect(
135 clippy::too_many_arguments,
136 reason = "central config loader mirrors CLI dispatch options"
137)]
138pub fn load_config_for_analysis(
139 root: &Path,
140 config_path: &Option<PathBuf>,
141 output: OutputFormat,
142 no_cache: bool,
143 threads: usize,
144 production_override: Option<bool>,
145 quiet: bool,
146 analysis: ProductionAnalysis,
147) -> Result<ResolvedConfig, ExitCode> {
148 let user_config = if let Some(path) = config_path {
149 match FallowConfig::load(path) {
150 Ok(c) => {
151 log_config_loaded(path, output, quiet);
152 Some(c)
153 }
154 Err(e) => {
155 let msg = format!("failed to load config '{}': {e}", path.display());
156 return Err(crate::error::emit_error(&msg, 2, output));
157 }
158 }
159 } else {
160 match FallowConfig::find_and_load(root) {
161 Ok(Some((config, found_path))) => {
162 log_config_loaded(&found_path, output, quiet);
163 Some(config)
164 }
165 Ok(None) => None,
166 Err(e) => {
167 return Err(crate::error::emit_error(&e, 2, output));
168 }
169 }
170 };
171
172 let final_config = match user_config {
173 Some(mut config) => {
174 let production =
175 production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
176 config.production = production.into();
177 config
178 }
179 None => FallowConfig {
180 production: production_override.unwrap_or(false).into(),
181 ..FallowConfig::default()
182 },
183 };
184
185 if let Err(errors) =
191 fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
192 {
193 let joined = errors
194 .iter()
195 .map(ToString::to_string)
196 .collect::<Vec<_>>()
197 .join("\n - ");
198 let msg = format!("invalid external plugin definition:\n - {joined}");
199 return Err(crate::error::emit_error(&msg, 2, output));
200 }
201
202 if let Err(errors) = final_config.validate_resolved_boundaries(root) {
207 let joined = errors
208 .iter()
209 .map(ToString::to_string)
210 .collect::<Vec<_>>()
211 .join("\n - ");
212 let msg = format!("invalid boundary configuration:\n - {joined}");
213 return Err(crate::error::emit_error(&msg, 2, output));
214 }
215
216 let cache_max_size_mb = resolve_cache_max_size_env();
217 let resolved = final_config.resolve(
218 root.to_path_buf(),
219 output,
220 threads,
221 no_cache,
222 quiet,
223 cache_max_size_mb,
224 );
225
226 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
236 Ok((_, diagnostics)) => {
237 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
245 if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
246 eprintln!(
247 "fallow: {} workspace discovery diagnostic{}. \
248 Run `fallow list --workspaces` for detail.",
249 diagnostics.len(),
250 if diagnostics.len() == 1 { "" } else { "s" }
251 );
252 }
253 }
254 Err(err) => {
255 return Err(crate::error::emit_error(&err.to_string(), 2, output));
256 }
257 }
258
259 Ok(resolved)
260}
261
262#[must_use]
267pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
268 fallow_config::workspace_diagnostics_for(root)
269}
270
271fn resolve_cache_max_size_env() -> Option<u32> {
277 std::env::var("FALLOW_CACHE_MAX_SIZE")
278 .ok()
279 .and_then(|raw| raw.trim().parse::<u32>().ok())
280 .filter(|mb| *mb > 0)
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn config_loaded_notice_dedupes_by_config_path() {
289 let dir = tempfile::tempdir().unwrap();
290 let first = dir.path().join("first.fallow.json");
291 let second = dir.path().join("second.fallow.json");
292 std::fs::write(&first, "{}").unwrap();
293 std::fs::write(&second, "{}").unwrap();
294
295 assert!(should_log_config_loaded(&first));
296 assert!(!should_log_config_loaded(&first));
297 assert!(should_log_config_loaded(&second));
298 }
299}