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 = if let Some(path) = config_path {
181 match FallowConfig::load(path) {
182 Ok(c) => {
183 log_config_loaded(path, options.output, options.quiet);
184 Some(c)
185 }
186 Err(e) => {
187 let msg = format!("failed to load config '{}': {e}", path.display());
188 return Err(crate::error::emit_error(&msg, 2, options.output));
189 }
190 }
191 } else {
192 match FallowConfig::find_and_load(root) {
193 Ok(Some((config, found_path))) => {
194 log_config_loaded(&found_path, options.output, options.quiet);
195 Some(config)
196 }
197 Ok(None) => None,
198 Err(e) => {
199 return Err(crate::error::emit_error(&e, 2, options.output));
200 }
201 }
202 };
203
204 let loaded_user_config = user_config.is_some();
205 let final_config = match user_config {
206 Some(mut config) => {
207 let production = options
208 .production_override
209 .unwrap_or_else(|| config.production.for_analysis(analysis));
210 config.production = production.into();
211 config
212 }
213 None => FallowConfig {
214 production: options.production_override.unwrap_or(false).into(),
215 ..FallowConfig::default()
216 },
217 };
218 crate::telemetry::note_config_shape(config_shape_for(&final_config, loaded_user_config));
219
220 if let Err(errors) =
221 fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
222 {
223 let joined = errors
224 .iter()
225 .map(ToString::to_string)
226 .collect::<Vec<_>>()
227 .join("\n - ");
228 let msg = format!("invalid external plugin definition:\n - {joined}");
229 return Err(crate::error::emit_error(&msg, 2, options.output));
230 }
231
232 if let Err(errors) = final_config.validate_resolved_boundaries(root) {
233 let joined = errors
234 .iter()
235 .map(ToString::to_string)
236 .collect::<Vec<_>>()
237 .join("\n - ");
238 let msg = format!("invalid boundary configuration:\n - {joined}");
239 return Err(crate::error::emit_error(&msg, 2, options.output));
240 }
241
242 if let Err(errors) = fallow_config::load_rule_packs(root, &final_config.rule_packs) {
245 let joined = errors
246 .iter()
247 .map(ToString::to_string)
248 .collect::<Vec<_>>()
249 .join("\n - ");
250 let msg = format!("invalid rule pack:\n - {joined}");
251 return Err(crate::error::emit_error(&msg, 2, options.output));
252 }
253
254 let cache_max_size_mb = resolve_cache_max_size_env();
255 let mut resolved = final_config.resolve(
256 root.to_path_buf(),
257 options.output,
258 options.threads,
259 options.no_cache,
260 options.quiet,
261 cache_max_size_mb,
262 );
263 if let Some(mb) = resolve_max_file_size_mb() {
264 resolved.max_file_size_bytes = fallow_config::resolve_max_file_size_bytes(Some(mb));
265 }
266 apply_cache_dir_env_override(root, &mut resolved, resolve_cache_dir_env());
267 crate::cache_notice::record_candidate(
268 root,
269 &resolved.cache_dir,
270 options.output,
271 options.quiet,
272 resolved.no_cache,
273 );
274
275 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
276 Ok((_, diagnostics)) => {
277 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
278 if !diagnostics.is_empty()
279 && matches!(options.output, OutputFormat::Human)
280 && !options.quiet
281 {
282 eprintln!(
283 "fallow: {} workspace discovery diagnostic{}. \
284 Run `fallow list --workspaces` for detail.",
285 diagnostics.len(),
286 if diagnostics.len() == 1 { "" } else { "s" }
287 );
288 }
289 }
290 Err(err) => {
291 return Err(crate::error::emit_error(
292 &err.to_string(),
293 2,
294 options.output,
295 ));
296 }
297 }
298
299 Ok(resolved)
300}
301
302fn config_shape_for(
303 config: &FallowConfig,
304 loaded_user_config: bool,
305) -> crate::telemetry::ConfigShape {
306 if !config.plugins.is_empty() || !config.framework.is_empty() {
307 return crate::telemetry::ConfigShape::PluginsEnabled;
308 }
309 if config.rules != RulesConfig::default()
310 || config
311 .overrides
312 .iter()
313 .any(|entry| partial_rules_config_has_values(&entry.rules))
314 {
315 return crate::telemetry::ConfigShape::CustomRules;
316 }
317 if loaded_user_config {
318 return crate::telemetry::ConfigShape::CustomConfig;
319 }
320 crate::telemetry::ConfigShape::Default
321}
322
323fn partial_rules_config_has_values(rules: &PartialRulesConfig) -> bool {
324 serde_json::to_value(rules)
325 .ok()
326 .and_then(|value| value.as_object().map(|object| !object.is_empty()))
327 .unwrap_or(false)
328}
329
330#[must_use]
335pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
336 fallow_config::workspace_diagnostics_for(root)
337}
338
339fn resolve_cache_max_size_env() -> Option<u32> {
345 std::env::var("FALLOW_CACHE_MAX_SIZE")
346 .ok()
347 .and_then(|raw| raw.trim().parse::<u32>().ok())
348 .filter(|mb| *mb > 0)
349}
350
351fn resolve_cache_dir_env() -> Option<PathBuf> {
354 std::env::var_os("FALLOW_CACHE_DIR")
355 .map(PathBuf::from)
356 .filter(|path| !path.as_os_str().is_empty())
357}
358
359fn resolve_cache_dir_value(root: &Path, path: PathBuf) -> PathBuf {
360 if path.is_absolute() {
361 path
362 } else {
363 root.join(path)
364 }
365}
366
367fn apply_cache_dir_env_override(
368 root: &Path,
369 resolved: &mut ResolvedConfig,
370 env_value: Option<PathBuf>,
371) {
372 if let Some(path) = env_value {
373 resolved.cache_dir = resolve_cache_dir_value(root, path);
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn config_loaded_notice_dedupes_by_config_path() {
383 let dir = tempfile::tempdir().unwrap();
384 let first = dir.path().join("first.fallow.json");
385 let second = dir.path().join("second.fallow.json");
386 std::fs::write(&first, "{}").unwrap();
387 std::fs::write(&second, "{}").unwrap();
388
389 assert!(should_log_config_loaded(&first));
390 assert!(!should_log_config_loaded(&first));
391 assert!(should_log_config_loaded(&second));
392 }
393
394 #[test]
395 fn cache_dir_env_value_resolves_relative_to_project_root() {
396 assert_eq!(
397 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from(".cache/fallow")),
398 PathBuf::from("/repo/.cache/fallow")
399 );
400 assert_eq!(
401 resolve_cache_dir_value(Path::new("/repo"), PathBuf::from("/tmp/fallow-cache")),
402 PathBuf::from("/tmp/fallow-cache")
403 );
404 }
405
406 #[test]
407 fn cache_dir_env_value_wins_over_configured_cache_dir() {
408 let mut resolved = FallowConfig {
409 cache: fallow_config::CacheConfig {
410 dir: Some(PathBuf::from(".cache/from-config")),
411 ..Default::default()
412 },
413 ..Default::default()
414 }
415 .resolve(
416 PathBuf::from("/repo"),
417 OutputFormat::Human,
418 1,
419 false,
420 true,
421 None,
422 );
423
424 apply_cache_dir_env_override(
425 Path::new("/repo"),
426 &mut resolved,
427 Some(PathBuf::from(".cache/from-env")),
428 );
429
430 assert_eq!(resolved.cache_dir, PathBuf::from("/repo/.cache/from-env"));
431 }
432}