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#[derive(Clone, Copy)]
141pub struct ConfigLoadOptions {
142 pub output: OutputFormat,
143 pub no_cache: bool,
144 pub threads: usize,
145 pub production_override: Option<bool>,
146 pub quiet: bool,
147}
148
149#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
150pub fn load_config(
151 root: &Path,
152 config_path: &Option<PathBuf>,
153 output: OutputFormat,
154 no_cache: bool,
155 threads: usize,
156 production: bool,
157 quiet: bool,
158) -> Result<ResolvedConfig, ExitCode> {
159 load_config_for_analysis(
160 root,
161 config_path,
162 ConfigLoadOptions {
163 output,
164 no_cache,
165 threads,
166 production_override: production.then_some(true),
167 quiet,
168 },
169 ProductionAnalysis::DeadCode,
170 )
171}
172
173#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
174pub fn load_config_for_analysis(
175 root: &Path,
176 config_path: &Option<PathBuf>,
177 options: ConfigLoadOptions,
178 analysis: ProductionAnalysis,
179) -> Result<ResolvedConfig, ExitCode> {
180 let user_config = load_user_config(root, config_path, &options)?;
181
182 let loaded_user_config = user_config.is_some();
183 let final_config = match user_config {
184 Some(mut config) => {
185 let production = options
186 .production_override
187 .unwrap_or_else(|| config.production.for_analysis(analysis));
188 config.production = production.into();
189 config
190 }
191 None => FallowConfig {
192 production: options.production_override.unwrap_or(false).into(),
193 ..FallowConfig::default()
194 },
195 };
196 crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
197
198 validate_config_extensions(root, &final_config, &options)?;
199
200 let cache_max_size_mb = resolve_cache_max_size_env();
201 let mut resolved = final_config.resolve(
202 root.to_path_buf(),
203 options.output,
204 options.threads,
205 options.no_cache,
206 options.quiet,
207 cache_max_size_mb,
208 );
209 if let Some(mb) = resolve_max_file_size_mb() {
210 resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
211 }
212 apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
213 crate::cache_notice::record_candidate(
214 root,
215 &resolved.cache_dir,
216 options.output,
217 options.quiet,
218 resolved.no_cache,
219 );
220
221 report_workspace_diagnostics(root, &resolved, &options)?;
222
223 Ok(resolved)
224}
225
226#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
229fn load_user_config(
230 root: &Path,
231 config_path: &Option<PathBuf>,
232 options: &ConfigLoadOptions,
233) -> Result<Option<FallowConfig>, ExitCode> {
234 if let Some(path) = config_path {
235 return match FallowConfig::load(path) {
236 Ok(c) => {
237 log_config_loaded(path, options.output, options.quiet);
238 Ok(Some(c))
239 }
240 Err(e) => {
241 let msg = format!("failed to load config '{}': {e}", path.display());
242 Err(crate::error::emit_error(&msg, 2, options.output))
243 }
244 };
245 }
246 match FallowConfig::find_and_load(root) {
247 Ok(Some((config, found_path))) => {
248 log_config_loaded(&found_path, options.output, options.quiet);
249 Ok(Some(config))
250 }
251 Ok(None) => Ok(None),
252 Err(e) => Err(crate::error::emit_error(&e, 2, options.output)),
253 }
254}
255
256fn emit_joined_config_errors<E: ToString>(
258 label: &str,
259 errors: &[E],
260 output: OutputFormat,
261) -> ExitCode {
262 let joined = errors
263 .iter()
264 .map(ToString::to_string)
265 .collect::<Vec<_>>()
266 .join("\n - ");
267 crate::error::emit_error(&format!("{label}:\n - {joined}"), 2, output)
268}
269
270fn validate_config_extensions(
274 root: &Path,
275 config: &FallowConfig,
276 options: &ConfigLoadOptions,
277) -> Result<(), ExitCode> {
278 if let Err(errors) =
279 fallow_config::discover_and_validate_external_plugins(root, &config.plugins)
280 {
281 return Err(emit_joined_config_errors(
282 "invalid external plugin definition",
283 &errors,
284 options.output,
285 ));
286 }
287 if let Err(errors) = config.validate_resolved_boundaries(root) {
288 return Err(emit_joined_config_errors(
289 "invalid boundary configuration",
290 &errors,
291 options.output,
292 ));
293 }
294 if let Err(errors) = fallow_config::load_rule_packs(root, &config.rule_packs) {
295 return Err(emit_joined_config_errors(
296 "invalid rule pack",
297 &errors,
298 options.output,
299 ));
300 }
301 Ok(())
302}
303
304fn report_workspace_diagnostics(
307 root: &Path,
308 resolved: &ResolvedConfig,
309 options: &ConfigLoadOptions,
310) -> Result<(), ExitCode> {
311 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
312 Ok((_, diagnostics)) => {
313 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
314 if !diagnostics.is_empty()
315 && matches!(options.output, OutputFormat::Human)
316 && !options.quiet
317 {
318 eprintln!(
319 "fallow: {} workspace discovery diagnostic{}. \
320 Run `fallow list --workspaces` for detail.",
321 diagnostics.len(),
322 if diagnostics.len() == 1 { "" } else { "s" }
323 );
324 }
325 Ok(())
326 }
327 Err(err) => Err(crate::error::emit_error(
328 &err.to_string(),
329 2,
330 options.output,
331 )),
332 }
333}
334
335fn config_shape_for(
336 config: &FallowConfig,
337 loaded_user_config: bool,
338) -> crate::telemetry::ConfigShape {
339 if !config.plugins.is_empty() || !config.framework.is_empty() {
340 return crate::telemetry::ConfigShape::PluginsEnabled;
341 }
342 if config.rules != RulesConfig::default()
343 || config
344 .overrides
345 .iter()
346 .any(|entry| partial_rules_config_has_values(&entry.rules))
347 {
348 return crate::telemetry::ConfigShape::CustomRules;
349 }
350 if loaded_user_config {
351 return crate::telemetry::ConfigShape::CustomConfig;
352 }
353 crate::telemetry::ConfigShape::Default
354}
355
356fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
357 serde_json::to_value(rules)
358 .ok()
359 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
360 .unwrap_or(false)
361}
362
363#[must_use]
368pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
369 fallow_config::workspace_diagnostics_for(root)
370}
371
372fn resolve_cache_max_size_env() -> Option<u32> {
378 std::env::var("FALLOW_CACHE_MAX_SIZE")
379 .ok()
380 .and_then(|raw| raw.trim().parse::<u32>().ok())
381 .filter(|mb| *mb > 0)
382}
383
384fn resolve_cache_dir_env() -> Option<PathBuf> {
387 std::env::var_os("FALLOW_CACHE_DIR")
388 .map(PathBuf::from)
389 .filter(|path| !path.as_os_str().is_empty())
390}
391
392fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
393 if path.is_absolute() {
394 path
395 } else {
396 root.join(path)
397 }
398}
399
400fn apply_cache_dir_env_override(
401 root: &Path,
402 resolved: &mut ResolvedConfig,
403 env_value: Option<PathBuf>,
404) {
405 if let Some(path) = env_value {
406 resolved.cache_dir = resolve_cache_dir_value(root, path);
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn config_loaded_notice_dedupes_by_config_path() {
416 let dir = tempfile::tempdir().unwrap();
417 let first = dir.path().join("first.fallow.json");
418 let second = dir.path().join("second.fallow.json");
419 std::fs::write(&first, "{}").unwrap();
420 std::fs::write(&second, "{}").unwrap();
421
422 assert!(should_log_config_loaded(&first));
423 assert!(!should_log_config_loaded(&first));
424 assert!(should_log_config_loaded(&second));
425 }
426
427 #[test]
428 fn cache_dir_env_value_resolves_relative_to_project_root() {
429 assert_eq!(
430 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
431 PathBuf::from("/repo/.cache/fallow")
432 );
433 assert_eq!(
434 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
435 PathBuf::from("/tmp/fallow-cache")
436 );
437 }
438
439 #[test]
440 fn cache_dir_env_value_wins_over_configured_cache_dir() {
441 let mut resolved = FallowConfig {
442 cache: fallow_config::CacheConfig {
443 dir: Some(PathBuf::from(".cache/from-config")),
444 ..Default::default()
445 },
446 ..Default::default()
447 }
448 .resolve(
449 PathBuf::from("/repo"),
450 OutputFormat::Human,
451 1,
452 false,
453 true,
454 None,
455 );
456
457 apply_cache_dir_env_override(
458 Path::new("/repo"),
459 &mut resolved,
460 Some(PathBuf::from(".cache/from-env")),
461 );
462
463 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
464 }
465}