1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{FallowConfig, OutputFormat, ProductionAnalysis, ResolvedConfig};
5
6#[derive(Clone, PartialEq, Eq, clap::ValueEnum)]
8pub enum AnalysisKind {
9 #[value(alias = "check")]
10 DeadCode,
11 Dupes,
12 Health,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
17pub enum GroupBy {
18 #[value(alias = "team", alias = "codeowner")]
20 Owner,
21 Directory,
23 #[value(alias = "workspace", alias = "pkg")]
25 Package,
26 #[value(alias = "gl-section")]
30 Section,
31}
32
33pub fn build_ownership_resolver(
38 group_by: Option<GroupBy>,
39 root: &Path,
40 codeowners_path: Option<&str>,
41 output: OutputFormat,
42) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
43 let Some(mode) = group_by else {
44 return Ok(None);
45 };
46 match mode {
47 GroupBy::Owner => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
48 Ok(co) => Ok(Some(crate::report::OwnershipResolver::Owner(co))),
49 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
50 },
51 GroupBy::Section => match crate::codeowners::CodeOwners::load(root, codeowners_path) {
52 Ok(co) => {
53 if co.has_sections() {
54 Ok(Some(crate::report::OwnershipResolver::Section(co)))
55 } else {
56 Err(crate::error::emit_error(
57 "--group-by section requires a GitLab-style CODEOWNERS file \
58 with `[Section]` headers. This CODEOWNERS has no sections; \
59 use --group-by owner instead.",
60 2,
61 output,
62 ))
63 }
64 }
65 Err(e) => Err(crate::error::emit_error(&e, 2, output)),
66 },
67 GroupBy::Directory => Ok(Some(crate::report::OwnershipResolver::Directory)),
68 GroupBy::Package => {
69 let workspaces = fallow_config::discover_workspaces(root);
70 if workspaces.is_empty() {
71 Err(crate::error::emit_error(
72 "--group-by package requires a monorepo with workspace packages \
73 (package.json workspaces, pnpm-workspace.yaml, or tsconfig references). \
74 For single-package projects try --group-by directory instead.",
75 2,
76 output,
77 ))
78 } else {
79 Ok(Some(crate::report::OwnershipResolver::Package(
80 crate::report::grouping::PackageResolver::new(root, &workspaces),
81 )))
82 }
83 }
84 }
85}
86
87fn log_config_loaded(path: &Path, output: OutputFormat, quiet: bool) {
92 if quiet || !matches!(output, OutputFormat::Human) {
93 return;
94 }
95 eprintln!("loaded config: {}", path.display());
96}
97
98#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
99pub fn load_config(
100 root: &Path,
101 config_path: &Option<PathBuf>,
102 output: OutputFormat,
103 no_cache: bool,
104 threads: usize,
105 production: bool,
106 quiet: bool,
107) -> Result<ResolvedConfig, ExitCode> {
108 load_config_for_analysis(
109 root,
110 config_path,
111 output,
112 no_cache,
113 threads,
114 production.then_some(true),
115 quiet,
116 ProductionAnalysis::DeadCode,
117 )
118}
119
120#[expect(clippy::ref_option, reason = "&Option matches clap's field type")]
121#[expect(
122 clippy::too_many_arguments,
123 reason = "central config loader mirrors CLI dispatch options"
124)]
125pub fn load_config_for_analysis(
126 root: &Path,
127 config_path: &Option<PathBuf>,
128 output: OutputFormat,
129 no_cache: bool,
130 threads: usize,
131 production_override: Option<bool>,
132 quiet: bool,
133 analysis: ProductionAnalysis,
134) -> Result<ResolvedConfig, ExitCode> {
135 let user_config = if let Some(path) = config_path {
136 match FallowConfig::load(path) {
137 Ok(c) => {
138 log_config_loaded(path, output, quiet);
139 Some(c)
140 }
141 Err(e) => {
142 let msg = format!("failed to load config '{}': {e}", path.display());
143 return Err(crate::error::emit_error(&msg, 2, output));
144 }
145 }
146 } else {
147 match FallowConfig::find_and_load(root) {
148 Ok(Some((config, found_path))) => {
149 log_config_loaded(&found_path, output, quiet);
150 Some(config)
151 }
152 Ok(None) => None,
153 Err(e) => {
154 return Err(crate::error::emit_error(&e, 2, output));
155 }
156 }
157 };
158
159 let final_config = match user_config {
160 Some(mut config) => {
161 let production =
162 production_override.unwrap_or_else(|| config.production.for_analysis(analysis));
163 config.production = production.into();
164 config
165 }
166 None => FallowConfig {
167 production: production_override.unwrap_or(false).into(),
168 ..FallowConfig::default()
169 },
170 };
171
172 if let Err(errors) =
178 fallow_config::discover_and_validate_external_plugins(root, &final_config.plugins)
179 {
180 let joined = errors
181 .iter()
182 .map(ToString::to_string)
183 .collect::<Vec<_>>()
184 .join("\n - ");
185 let msg = format!("invalid external plugin definition:\n - {joined}");
186 return Err(crate::error::emit_error(&msg, 2, output));
187 }
188
189 if let Err(errors) = final_config.validate_resolved_boundaries(root) {
194 let joined = errors
195 .iter()
196 .map(ToString::to_string)
197 .collect::<Vec<_>>()
198 .join("\n - ");
199 let msg = format!("invalid boundary configuration:\n - {joined}");
200 return Err(crate::error::emit_error(&msg, 2, output));
201 }
202
203 let cache_max_size_mb = resolve_cache_max_size_env();
204 let resolved = final_config.resolve(
205 root.to_path_buf(),
206 output,
207 threads,
208 no_cache,
209 quiet,
210 cache_max_size_mb,
211 );
212
213 match fallow_config::discover_workspaces_with_diagnostics(root, &resolved.ignore_patterns) {
223 Ok((_, diagnostics)) => {
224 fallow_config::stash_workspace_diagnostics(root, diagnostics.clone());
232 if !diagnostics.is_empty() && matches!(output, OutputFormat::Human) && !quiet {
233 eprintln!(
234 "fallow: {} workspace discovery diagnostic{}. \
235 Run `fallow list --workspaces` for detail.",
236 diagnostics.len(),
237 if diagnostics.len() == 1 { "" } else { "s" }
238 );
239 }
240 }
241 Err(err) => {
242 return Err(crate::error::emit_error(&err.to_string(), 2, output));
243 }
244 }
245
246 Ok(resolved)
247}
248
249#[must_use]
254pub fn workspace_diagnostics_for(root: &Path) -> Vec<fallow_config::WorkspaceDiagnostic> {
255 fallow_config::workspace_diagnostics_for(root)
256}
257
258fn resolve_cache_max_size_env() -> Option<u32> {
264 std::env::var("FALLOW_CACHE_MAX_SIZE")
265 .ok()
266 .and_then(|raw| raw.trim().parse::<u32>().ok())
267 .filter(|mb| *mb > 0)
268}