1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3use std::sync::{LazyLock, Mutex, OnceLock};
4
5use fallow_config::{
6 FallowConfig, OutputFormat, PartialRulesConfig, ProductionAnalysis, ResolvedConfig, RulesConfig,
7};
8use rustc_hash::FxHashSet;
9
10static CONFIG_LOADED_LOGGED: LazyLock<Mutex<FxHashSet<PathBuf>>> =
11 LazyLock::new(|| Mutex::new(FxHashSet::default()));
12
13static MAX_FILE_SIZE_OVERRIDE: OnceLock<Option<u32>> = OnceLock::new();
19
20pub fn set_max_file_size_override(max_file_size_mb: Option<u32>) {
23 let _ = MAX_FILE_SIZE_OVERRIDE.set(max_file_size_mb);
24}
25
26fn resolve_max_file_size_mb() -> Option<u32> {
30 if let Some(Some(mb)) = MAX_FILE_SIZE_OVERRIDE.get() {
31 return Some(*mb);
32 }
33 std::env::var("FALLOW_MAX_FILE_SIZE")
34 .ok()
35 .and_then(|raw| raw.trim().parse::<u32>().ok())
36}
37
38#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
40pub enum AnalysisKind {
41 #[value(alias = "check")]
42 DeadCode,
43 Dupes,
44 Health,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
49pub enum GroupBy {
50 #[value(alias = "team", alias = "codeowner")]
52 Owner,
53 Directory,
55 #[value(alias = "workspace", alias = "pkg")]
57 Package,
58 #[value(alias = "gl-section")]
62 Section,
63}
64
65pub fn build_ownership_resolver(
70 group_by: Option<GroupBy>,
71 root: &Path,
72 codeowners_path: Option<&str>,
73 output: OutputFormat,
74) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
75 let Some(mode) = group_by else {
76 return Ok(None);
77 };
78 match mode {
79 GroupBy::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
80 Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
81 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
82 },
83 GroupBy::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
84 Ok(co) => {
85 if co.has_sections() {
86 Ok(Some(crate::report::OwnershipResolver::Section(co)))
87 } else {
88 Err(crate::error::emit_error(
89 "--group-by section requires a GitLab-style CODEOWNERS file \
90 with `[Section]` headers. This CODEOWNERS has no sections; \
91 use --group-by owner instead.",
92 2,
93 output,
94 ))
95 }
96 }
97 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
98 },
99 GroupBy::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
100 GroupBy::Package => {
101 let workspaces = fallow_config::discover_workspaces(root);
102 if workspaces.is_empty() {
103 Err(crate::error::emit_error(
104 "--group-by package requires a monorepo with workspace packages \
105 (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
106 For single-package projects try --group-by directory instead.",
107 2,
108 output,
109 ))
110 } else {
111 Ok(Some(crate::report::OwnershipResolver::Package(
112 crate::report::grouping::PackageResolver::new(root, &workspaces),
113 )))
114 }
115 }
116 }
117}
118
119fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
124 if quiet || !matches!(output, OutputFormat::Human) {
125 return;
126 }
127 if !should_log_config_loaded(path) {
128 return;
129 }
130 eprintln!("loaded config: {}", path.display());
131}
132
133fn should_log_config_loaded(path: &Path) -> bool {
134 let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
135 CONFIG_LOADED_LOGGED
136 .lock()
137 .is_ok_and(|mut logged| logged.insert(key))
138}
139
140#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
141pub fn load_config(
142 root: &Path,
143 config_path: &Option<PathBuf>,
144 output: OutputFormat,
145 no_cache: bool,
146 threads: usize,
147 production: bool,
148 quiet: bool,
149) -> Result<ResolvedConfig, ExitCode> {
150 load_config_for_analysis(
151 root,
152 config_path,
153 output,
154 no_cache,
155 threads,
156 production.then_some(true),
157 quiet,
158 ProductionAnalysis::DeadCode,
159 )
160}
161
162#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
163#[expect(
164 clippy::too_many_arguments,
165 reason = "central config loader mirrors CLI dispatch options"
166)]
167pub fn load_config_for_analysis(
168 root: &Path,
169 config_path: &Option<PathBuf>,
170 output: OutputFormat,
171 no_cache: bool,
172 threads: usize,
173 production_override: Option<bool>,
174 quiet: bool,
175 analysis: ProductionAnalysis,
176) -> Result<ResolvedConfig, ExitCode> {
177 let user_config = if let Some(path) = config_path {
178 match FallowConfig::load(path) {
179 Ok(c) => {
180 log_config_loaded(path, output, quiet);
181 Some(c)
182 }
183 Err(e) => {
184 let msg = format!("failed to load config '{}': {e}", path.display());
185 return Err(crate::error::emit_error(&msg, 2, output));
186 }
187 }
188 } else {
189 match FallowConfig::find_and_load(root) {
190 Ok(Some((config, found_path))) => {
191 log_config_loaded(&found_path, output, quiet);
192 Some(config)
193 }
194 Ok(None) => None,
195 Err(e) => {
196 return Err(crate::error::emit_error(&e, 2, output));
197 }
198 }
199 };
200
201 let loaded_user_config = user_config.is_some();
202 let final_config = match user_config {
203 Some(mut config) => {
204 let production =
205 production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
206 config.production = production.into();
207 config
208 }
209 None => FallowConfig {
210 production: production_override.unwrap_or(false).into(),
211 ..FallowConfig::default()
212 },
213 };
214 crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
215
216 if let Err(errors) =
217 fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
218 {
219 let joined = errors
220 .iter()
221 .map(ToString::to_string)
222 .collect::<Vec<_>>()
223 .join("\n - ");
224 let msg = format!("invalid external plugin definition:\n - {joined}");
225 return Err(crate::error::emit_error(&msg, 2, output));
226 }
227
228 if let Err(errors) = final_config.validate_resolved_boundaries(root) {
229 let joined = errors
230 .iter()
231 .map(ToString::to_string)
232 .collect::<Vec<_>>()
233 .join("\n - ");
234 let msg = format!("invalid boundary configuration:\n - {joined}");
235 return Err(crate::error::emit_error(&msg, 2, output));
236 }
237
238 let cache_max_size_mb = resolve_cache_max_size_env();
239 let mut resolved = final_config.resolve(
240 root.to_path_buf(),
241 output,
242 threads,
243 no_cache,
244 quiet,
245 cache_max_size_mb,
246 );
247 if let Some(mb) = resolve_max_file_size_mb() {
248 resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
249 }
250 apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
251 crate::cache_notice::record_candidate(
252 root,
253 &resolved.cache_dir,
254 output,
255 quiet,
256 resolved.no_cache,
257 );
258
259 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
260 Ok((_, diagnostics)) => {
261 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
262 if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
263 eprintln!(
264 "fallow: {} workspace discovery diagnostic{}. \
265 Run `fallow list --workspaces` for detail.",
266 diagnostics.len(),
267 if diagnostics.len() == 1 { "" } else { "s" }
268 );
269 }
270 }
271 Err(err) => {
272 return Err(crate::error::emit_error(&err.to_string(), 2, output));
273 }
274 }
275
276 Ok(resolved)
277}
278
279fn config_shape_for(
280 config: &FallowConfig,
281 loaded_user_config: bool,
282) -> crate::telemetry::ConfigShape {
283 if !config.plugins.is_empty() || !config.framework.is_empty() {
284 return crate::telemetry::ConfigShape::PluginsEnabled;
285 }
286 if config.rules != RulesConfig::default()
287 || config
288 .overrides
289 .iter()
290 .any(|entry| partial_rules_config_has_values(&entry.rules))
291 {
292 return crate::telemetry::ConfigShape::CustomRules;
293 }
294 if loaded_user_config {
295 return crate::telemetry::ConfigShape::CustomConfig;
296 }
297 crate::telemetry::ConfigShape::Default
298}
299
300fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
301 serde_json::to_value(rules)
302 .ok()
303 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
304 .unwrap_or(false)
305}
306
307#[must_use]
312pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
313 fallow_config::workspace_diagnostics_for(root)
314}
315
316fn resolve_cache_max_size_env() -> Option<u32> {
322 std::env::var("FALLOW_CACHE_MAX_SIZE")
323 .ok()
324 .and_then(|raw| raw.trim().parse::<u32>().ok())
325 .filter(|mb| *mb > 0)
326}
327
328fn resolve_cache_dir_env() -> Option<PathBuf> {
331 std::env::var_os("FALLOW_CACHE_DIR")
332 .map(PathBuf::from)
333 .filter(|path| !path.as_os_str().is_empty())
334}
335
336fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
337 if path.is_absolute() {
338 path
339 } else {
340 root.join(path)
341 }
342}
343
344fn apply_cache_dir_env_override(
345 root: &Path,
346 resolved: &mut ResolvedConfig,
347 env_value: Option<PathBuf>,
348) {
349 if let Some(path) = env_value {
350 resolved.cache_dir = resolve_cache_dir_value(root, path);
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn config_loaded_notice_dedupes_by_config_path() {
360 let dir = tempfile::tempdir().unwrap();
361 let first = dir.path().join("first.fallow.json");
362 let second = dir.path().join("second.fallow.json");
363 std::fs::write(&first, "{}").unwrap();
364 std::fs::write(&second, "{}").unwrap();
365
366 assert!(should_log_config_loaded(&first));
367 assert!(!should_log_config_loaded(&first));
368 assert!(should_log_config_loaded(&second));
369 }
370
371 #[test]
372 fn cache_dir_env_value_resolves_relative_to_project_root() {
373 assert_eq!(
374 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
375 PathBuf::from("/repo/.cache/fallow")
376 );
377 assert_eq!(
378 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
379 PathBuf::from("/tmp/fallow-cache")
380 );
381 }
382
383 #[test]
384 fn cache_dir_env_value_wins_over_configured_cache_dir() {
385 let mut resolved = FallowConfig {
386 cache: fallow_config::CacheConfig {
387 dir: Some(PathBuf::from(".cache/from-config")),
388 ..Default::default()
389 },
390 ..Default::default()
391 }
392 .resolve(
393 PathBuf::from("/repo"),
394 OutputFormat::Human,
395 1,
396 false,
397 true,
398 None,
399 );
400
401 apply_cache_dir_env_override(
402 Path::new("/repo"),
403 &mut resolved,
404 Some(PathBuf::from(".cache/from-env")),
405 );
406
407 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
408 }
409}