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
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 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
219 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
220 Ok((_, diagnostics)) => {
221 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
222 if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
223 eprintln!(
224 "fallow: {} workspace discovery diagnostic{}. \
225 Run `fallow list --workspaces` for detail.",
226 diagnostics.len(),
227 if diagnostics.len() == 1 { "" } else { "s" }
228 );
229 }
230 }
231 Err(err) => {
232 return Err(crate::error::emit_error(&err.to_string(), 2, output));
233 }
234 }
235
236 Ok(resolved)
237}
238
239#[must_use]
244pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
245 fallow_config::workspace_diagnostics_for(root)
246}
247
248fn resolve_cache_max_size_env() -> Option<u32> {
254 std::env::var("FALLOW_CACHE_MAX_SIZE")
255 .ok()
256 .and_then(|raw| raw.trim().parse::<u32>().ok())
257 .filter(|mb| *mb > 0)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn config_loaded_notice_dedupes_by_config_path() {
266 let dir = tempfile::tempdir().unwrap();
267 let first = dir.path().join("first.fallow.json");
268 let second = dir.path().join("second.fallow.json");
269 std::fs::write(&first, "{}").unwrap();
270 std::fs::write(&second, "{}").unwrap();
271
272 assert!(should_log_config_loaded(&first));
273 assert!(!should_log_config_loaded(&first));
274 assert!(should_log_config_loaded(&second));
275 }
276}