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